mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
refactor(channels): deslop small channel and access plugins (#158441)
* refactor(channels): deslop small channel and access plugins Consolidate repeated transport and webhook flows, reuse documented SDK owners, and remove redundant private forwarding layers while preserving authorization, credential, formatting, protocol, and state contracts. Correct Teams setup diagnostics to reflect its existing live-caption support. * fix(nextcloud-talk): retain typed outbound context projection * fix(zalo): retain inbound media kind normalization * refactor(channels): resolve SDK import and setup lint findings * refactor(imap): retain SDK authentication strength exhaustiveness
This commit is contained in:
parent
443a0676b9
commit
eaf8d916f7
97 changed files with 468 additions and 1585 deletions
|
|
@ -510,7 +510,7 @@ extensions/irc/src/message-adapter.ts 1
|
|||
extensions/irc/src/monitor.ts 1
|
||||
extensions/irc/src/send.ts 1
|
||||
extensions/irc/src/setup-core.ts 6
|
||||
extensions/irc/src/setup-surface.ts 31
|
||||
extensions/irc/src/setup-surface.ts 25
|
||||
extensions/kilocode/provider-models.ts 3
|
||||
extensions/kilocode/stream.ts 1
|
||||
extensions/kimi-coding/provider-policy-api.ts 2
|
||||
|
|
@ -756,12 +756,12 @@ extensions/nextcloud-talk/src/doctor.ts 2
|
|||
extensions/nextcloud-talk/src/gateway.ts 2
|
||||
extensions/nextcloud-talk/src/inbound.ts 9
|
||||
extensions/nextcloud-talk/src/message-actions.ts 3
|
||||
extensions/nextcloud-talk/src/message-adapter.ts 2
|
||||
extensions/nextcloud-talk/src/message-adapter.ts 1
|
||||
extensions/nextcloud-talk/src/monitor-runtime.ts 1
|
||||
extensions/nextcloud-talk/src/monitor.ts 1
|
||||
extensions/nextcloud-talk/src/policy.ts 2
|
||||
extensions/nextcloud-talk/src/send.ts 1
|
||||
extensions/nextcloud-talk/src/setup-core.ts 10
|
||||
extensions/nextcloud-talk/src/setup-core.ts 8
|
||||
extensions/nextcloud-talk/src/setup-surface.ts 11
|
||||
extensions/nostr/index.ts 4
|
||||
extensions/nostr/src/channel.setup.ts 2
|
||||
|
|
@ -837,7 +837,7 @@ extensions/qa-channel/src/channel-actions.ts 2
|
|||
extensions/qa-channel/src/channel-base.ts 6
|
||||
extensions/qa-channel/src/channel.ts 5
|
||||
extensions/qa-channel/src/gateway.ts 2
|
||||
extensions/qa-channel/src/inbound.ts 2
|
||||
extensions/qa-channel/src/inbound.ts 1
|
||||
extensions/qa-channel/src/setup.ts 2
|
||||
extensions/qa-lab/src/agentic-parity-report.ts 1
|
||||
extensions/qa-lab/src/auth-profile.fixture.ts 3
|
||||
|
|
@ -1146,9 +1146,7 @@ extensions/tlon/src/tlon-api.ts 2
|
|||
extensions/tlon/src/types.ts 3
|
||||
extensions/tlon/src/urbit/sse-client.ts 4
|
||||
extensions/tokenjuice/tool-result-middleware.ts 1
|
||||
extensions/twitch/src/actions.ts 1
|
||||
extensions/twitch/src/config.ts 10
|
||||
extensions/twitch/src/monitor.ts 3
|
||||
extensions/twitch/src/outbound.ts 2
|
||||
extensions/twitch/src/resolver.ts 1
|
||||
extensions/twitch/src/setup-surface.ts 1
|
||||
|
|
@ -1255,7 +1253,6 @@ extensions/zai/model-definitions.ts 1
|
|||
extensions/zalo/setup-api.ts 3
|
||||
extensions/zalo/src/accounts.ts 3
|
||||
extensions/zalo/src/proxy.ts 1
|
||||
extensions/zalo/src/setup-allow-from.ts 1
|
||||
extensions/zalo/src/token.ts 1
|
||||
extensions/zalo/src/webhook-spool.ts 1
|
||||
extensions/zalouser/src/accounts.ts 1
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { IdentifierAuthentication } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { asNonArrayRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { asNonArrayRecord, filterStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const SENDER_STRENGTHS = [
|
||||
"mutable",
|
||||
|
|
@ -36,12 +36,6 @@ export type ImapAccountConfig = {
|
|||
|
||||
export type ImapPluginConfig = { accounts: Record<string, ImapAccountConfig> };
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
export function resolveImapConfig(
|
||||
value: unknown,
|
||||
onUnavailableAccount?: (accountId: string) => void,
|
||||
|
|
@ -98,21 +92,21 @@ export function resolveImapConfig(
|
|||
mode: mode === "idle" || mode === "interval" ? mode : "auto",
|
||||
pollSeconds: Math.max(15, typeof watch?.pollSeconds === "number" ? watch.pollSeconds : 60),
|
||||
},
|
||||
allowedSenders: stringList(account.allowedSenders),
|
||||
allowedSenders: filterStringEntries(account.allowedSenders),
|
||||
senderAuth: {
|
||||
// The predicate requires every SDK strength to remain in the local config values.
|
||||
min:
|
||||
SENDER_STRENGTHS.find(
|
||||
(strength): strength is IdentifierAuthentication => strength === min,
|
||||
) ?? "verified",
|
||||
trustedAuthservIds: stringList(senderAuth?.trustedAuthservIds),
|
||||
trustedAuthservIds: filterStringEntries(senderAuth?.trustedAuthservIds),
|
||||
acceptTrustedAuthservId: senderAuth?.acceptTrustedAuthservId === true,
|
||||
},
|
||||
addressTokens: Array.isArray(account.addressTokens)
|
||||
? account.addressTokens.flatMap((entry) => {
|
||||
const tokenEntry = asNonArrayRecord(entry);
|
||||
return typeof tokenEntry?.token === "string"
|
||||
? [{ token: tokenEntry.token, senders: stringList(tokenEntry.senders) }]
|
||||
? [{ token: tokenEntry.token, senders: filterStringEntries(tokenEntry.senders) }]
|
||||
: [];
|
||||
})
|
||||
: [],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { timingSafeEqual } from "node:crypto";
|
||||
import { Resolver } from "node:dns/promises";
|
||||
import { authenticate, type AuthenticateResult } from "mailauth";
|
||||
import type { AddressObject, ParsedMail } from "mailparser";
|
||||
|
|
@ -6,6 +5,7 @@ import {
|
|||
meetsIdentifierAuthentication,
|
||||
type IdentifierAuthentication,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { ImapAccountConfig } from "./config.js";
|
||||
|
||||
const AUTH_FRESHNESS_MS = 48 * 60 * 60 * 1_000;
|
||||
|
|
@ -77,12 +77,7 @@ function constantTokenMatch(address: string, expected: string): boolean {
|
|||
if (plus < 0) {
|
||||
return false;
|
||||
}
|
||||
const actualBytes = Buffer.from(local.slice(plus + 1));
|
||||
const expectedBytes = Buffer.from(expected);
|
||||
return (
|
||||
actualBytes.byteLength === expectedBytes.byteLength &&
|
||||
timingSafeEqual(actualBytes, expectedBytes)
|
||||
);
|
||||
return safeEqualSecret(local.slice(plus + 1), expected);
|
||||
}
|
||||
|
||||
function matchingSenderToken(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Irc plugin module implements client behavior.
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/security-runtime";
|
||||
|
|
@ -114,10 +113,6 @@ function buildFallbackNick(nick: string): string {
|
|||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
function normalizeIrcNick(value: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(value);
|
||||
}
|
||||
|
||||
function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
|
||||
if (!options || options.enabled === false) {
|
||||
return [];
|
||||
|
|
@ -139,7 +134,7 @@ function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
|
|||
}
|
||||
|
||||
export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> {
|
||||
const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000;
|
||||
const timeoutMs = options.connectTimeoutMs ?? 15000;
|
||||
const messageChunkMaxChars = Math.max(1, Math.floor(options.messageChunkMaxChars ?? 350));
|
||||
|
||||
if (!options.host.trim()) {
|
||||
|
|
@ -176,9 +171,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
|
|||
|
||||
const fail = (err: unknown) => {
|
||||
const error = toIrcError(err);
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
}
|
||||
options.onError?.(error);
|
||||
if (!ready && rejectReady) {
|
||||
rejectReady(error);
|
||||
rejectReady = null;
|
||||
|
|
@ -217,7 +210,10 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
|
|||
if (!fallbackNickAttempted) {
|
||||
fallbackNickAttempted = true;
|
||||
const fallbackNick = buildFallbackNick(desiredNick);
|
||||
if (normalizeIrcNick(fallbackNick) !== normalizeIrcNick(currentNick)) {
|
||||
if (
|
||||
normalizeLowercaseStringOrEmpty(fallbackNick) !==
|
||||
normalizeLowercaseStringOrEmpty(currentNick)
|
||||
) {
|
||||
try {
|
||||
sendRaw(`NICK ${fallbackNick}`);
|
||||
currentNick = fallbackNick;
|
||||
|
|
@ -262,7 +258,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
|
|||
closed = true;
|
||||
removeAbortListener?.();
|
||||
removeAbortListener = null;
|
||||
const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye");
|
||||
const safeReason = sanitizeIrcOutboundText(reason ?? "bye");
|
||||
try {
|
||||
if (safeReason) {
|
||||
sendRaw(`QUIT :${safeReason}`);
|
||||
|
|
@ -297,9 +293,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
|
|||
if (!rawLine) {
|
||||
continue;
|
||||
}
|
||||
if (options.onLine) {
|
||||
options.onLine(rawLine);
|
||||
}
|
||||
options.onLine?.(rawLine);
|
||||
|
||||
const line = parseIrcLine(rawLine);
|
||||
if (!line) {
|
||||
|
|
@ -307,42 +301,32 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
|
|||
}
|
||||
|
||||
if (line.command === "PING") {
|
||||
const payload =
|
||||
line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : "";
|
||||
const payload = line.trailing ?? line.params[0] ?? "";
|
||||
sendRaw(`PONG :${payload}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "NICK") {
|
||||
const prefix = parseIrcPrefix(line.prefix);
|
||||
if (prefix.nick && normalizeIrcNick(prefix.nick) === normalizeIrcNick(currentNick)) {
|
||||
const next =
|
||||
line.trailing != null
|
||||
? line.trailing
|
||||
: line.params[0] != null
|
||||
? line.params[0]
|
||||
: currentNick;
|
||||
currentNick = next.trim();
|
||||
if (
|
||||
prefix.nick &&
|
||||
normalizeLowercaseStringOrEmpty(prefix.nick) ===
|
||||
normalizeLowercaseStringOrEmpty(currentNick)
|
||||
) {
|
||||
currentNick = (line.trailing ?? line.params[0] ?? currentNick).trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) {
|
||||
if (tryRecoverNickCollision()) {
|
||||
const nickCollision = IRC_NICK_COLLISION_CODES.has(line.command);
|
||||
if (!ready && (nickCollision || IRC_ERROR_CODES.has(line.command))) {
|
||||
if (nickCollision && tryRecoverNickCollision()) {
|
||||
continue;
|
||||
}
|
||||
const detail =
|
||||
line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use";
|
||||
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ready && IRC_ERROR_CODES.has(line.command)) {
|
||||
const detail =
|
||||
line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected";
|
||||
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
|
||||
close();
|
||||
line.trailing ??
|
||||
(line.params.join(" ") || (nickCollision ? "nickname in use" : "login rejected"));
|
||||
failAndClose(new Error(`IRC login failed (${line.command}): ${detail}`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -371,27 +355,23 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
|
|||
fail(err);
|
||||
}
|
||||
}
|
||||
if (resolveReady) {
|
||||
resolveReady();
|
||||
}
|
||||
resolveReady?.();
|
||||
resolveReady = null;
|
||||
rejectReady = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "NOTICE") {
|
||||
if (options.onNotice) {
|
||||
options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]);
|
||||
}
|
||||
options.onNotice?.(line.trailing ?? "", line.params[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "PRIVMSG") {
|
||||
const targetParam = line.params[0];
|
||||
const target = targetParam ? targetParam.trim() : "";
|
||||
const target = targetParam?.trim() ?? "";
|
||||
const text = line.trailing ?? line.params[1] ?? "";
|
||||
const prefix = parseIrcPrefix(line.prefix);
|
||||
const senderNick = prefix.nick ? prefix.nick.trim() : "";
|
||||
const senderNick = prefix.nick?.trim() ?? "";
|
||||
if (!target || !senderNick || !text.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -422,8 +402,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
|
|||
sendRaw(`NICK ${options.nick.trim()}`);
|
||||
sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
close();
|
||||
failAndClose(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Irc plugin module implements setup surface behavior.
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import type {
|
||||
|
|
@ -43,6 +42,24 @@ const channel = "irc" as const;
|
|||
const USE_ENV_FLAG = "__ircUseEnv";
|
||||
const TLS_FLAG = "__ircTls";
|
||||
|
||||
type IrcTextInput = NonNullable<ChannelSetupWizard["textInputs"]>[number];
|
||||
|
||||
function ircAccountTextInput(
|
||||
configKey: "host" | "nick" | "username" | "realname",
|
||||
input: Pick<IrcTextInput, "inputKey" | "message" | "initialValue">,
|
||||
): IrcTextInput {
|
||||
return {
|
||||
...input,
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config[configKey] || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, { enabled: true, [configKey]: value }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseListInput(raw: string): string[] {
|
||||
return normalizeStringEntries(raw.split(/[\n,;]+/g));
|
||||
}
|
||||
|
|
@ -246,20 +263,10 @@ export const ircSetupWizard: ChannelSetupWizard = {
|
|||
},
|
||||
credentials: [],
|
||||
textInputs: [
|
||||
{
|
||||
ircAccountTextInput("host", {
|
||||
inputKey: "httpHost",
|
||||
message: t("wizard.irc.serverHostPrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.host || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
host: value,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{
|
||||
inputKey: "httpPort",
|
||||
message: t("wizard.irc.serverPortPrompt"),
|
||||
|
|
@ -286,54 +293,24 @@ export const ircSetupWizard: ChannelSetupWizard = {
|
|||
port: parsePort(value, 6697),
|
||||
}),
|
||||
},
|
||||
{
|
||||
ircAccountTextInput("nick", {
|
||||
inputKey: "token",
|
||||
message: t("wizard.irc.nickPrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.nick || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
nick: value,
|
||||
}),
|
||||
},
|
||||
{
|
||||
}),
|
||||
ircAccountTextInput("username", {
|
||||
inputKey: "userId",
|
||||
message: t("wizard.irc.usernamePrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
initialValue: ({ cfg, accountId, credentialValues }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username ||
|
||||
credentialValues.token ||
|
||||
"openclaw",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
username: value,
|
||||
}),
|
||||
},
|
||||
{
|
||||
}),
|
||||
ircAccountTextInput("realname", {
|
||||
inputKey: "deviceName",
|
||||
message: t("wizard.irc.realNamePrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
initialValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || "OpenClaw",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
realname: value,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
{
|
||||
inputKey: "groupChannels",
|
||||
message: t("wizard.irc.autoJoinPrompt"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Nextcloud Talk plugin module implements channel behavior.
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
|
||||
|
|
@ -122,7 +121,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
||||
inferTargetChatType: ({ to }) =>
|
||||
normalizeNextcloudTalkMessagingTarget(to) ? "group" : undefined,
|
||||
resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params),
|
||||
resolveOutboundSessionRoute: resolveNextcloudTalkOutboundSessionRoute,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeNextcloudTalkTargetId,
|
||||
hint: "<roomToken>",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Nextcloud Talk helper module supports config schema behavior.
|
||||
import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
|
|
@ -10,8 +9,8 @@ import {
|
|||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { requireChannelOpenAllowFrom } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
export const NextcloudTalkRoomSchema = buildGroupEntrySchema({
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { resolveChannelInboundRouteEnvelope } from "openclaw/plugin-sdk/channel-inbound";
|
||||
// Nextcloud Talk plugin module implements inbound behavior.
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
type ChannelIngressContextBinding,
|
||||
|
|
@ -25,7 +24,6 @@ import {
|
|||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type GroupPolicy,
|
||||
type OpenClawConfig,
|
||||
type OutboundReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
|
|
@ -98,28 +96,6 @@ function roomRoutes(params: {
|
|||
);
|
||||
}
|
||||
|
||||
async function deliverNextcloudTalkReply(params: {
|
||||
cfg: CoreConfig;
|
||||
payload: OutboundReplyPayload;
|
||||
roomToken: string;
|
||||
accountId: string;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}): Promise<{ visibleReplySent: boolean }> {
|
||||
const { cfg, payload, roomToken, accountId, statusSink } = params;
|
||||
const visibleReplySent = await deliverFormattedTextWithAttachments({
|
||||
payload,
|
||||
send: async ({ text, replyToId }) => {
|
||||
await sendMessageNextcloudTalk(roomToken, text, {
|
||||
cfg,
|
||||
accountId,
|
||||
replyTo: replyToId,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
});
|
||||
return { visibleReplySent };
|
||||
}
|
||||
|
||||
export async function handleNextcloudTalkInbound(params: {
|
||||
message: NextcloudTalkInboundMessage;
|
||||
account: ResolvedNextcloudTalkAccount;
|
||||
|
|
@ -405,13 +381,18 @@ export async function handleNextcloudTalkInbound(params: {
|
|||
text: sanitizeAssistantVisibleText(payload.text),
|
||||
},
|
||||
deliver: async (payload) => {
|
||||
return await deliverNextcloudTalkReply({
|
||||
cfg: config,
|
||||
const visibleReplySent = await deliverFormattedTextWithAttachments({
|
||||
payload,
|
||||
roomToken,
|
||||
accountId: account.accountId,
|
||||
statusSink,
|
||||
send: async ({ text, replyToId }) => {
|
||||
await sendMessageNextcloudTalk(roomToken, text, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
replyTo: replyToId,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
});
|
||||
return { visibleReplySent };
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
// Nextcloud Talk plugin module implements message adapter behavior.
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
defineChannelMessageAdapter,
|
||||
type ChannelMessageSendTextContext,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { sendMessageNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function sendNextcloudTalkMessage(ctx: ChannelMessageSendTextContext, text = ctx.text) {
|
||||
return sendMessageNextcloudTalk(ctx.to, text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyTo: ctx.replyToId ?? undefined,
|
||||
cfg: ctx.cfg as CoreConfig,
|
||||
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
|
||||
assertDirectAdapterHandoff: ctx.assertDirectAdapterHandoff,
|
||||
});
|
||||
}
|
||||
|
||||
export const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "nextcloud-talk",
|
||||
durableFinal: {
|
||||
|
|
@ -13,25 +25,11 @@ export const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
|
|||
},
|
||||
},
|
||||
send: {
|
||||
text: async (ctx) =>
|
||||
await sendMessageNextcloudTalk(ctx.to, ctx.text, {
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyTo: ctx.replyToId ?? undefined,
|
||||
cfg: ctx.cfg as CoreConfig,
|
||||
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
|
||||
assertDirectAdapterHandoff: ctx.assertDirectAdapterHandoff,
|
||||
}),
|
||||
media: async (ctx) =>
|
||||
await sendMessageNextcloudTalk(
|
||||
ctx.to,
|
||||
text: sendNextcloudTalkMessage,
|
||||
media: (ctx) =>
|
||||
sendNextcloudTalkMessage(
|
||||
ctx,
|
||||
ctx.mediaUrl ? `${ctx.text}\n\nAttachment: ${ctx.mediaUrl}` : ctx.text,
|
||||
{
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyTo: ctx.replyToId ?? undefined,
|
||||
cfg: ctx.cfg as CoreConfig,
|
||||
onPlatformSendDispatch: ctx.onPlatformSendDispatch,
|
||||
assertDirectAdapterHandoff: ctx.assertDirectAdapterHandoff,
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,29 +1,12 @@
|
|||
// Nextcloud Talk helper module supports normalize behavior.
|
||||
export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let normalized = trimmed;
|
||||
|
||||
if (/^nextcloud-talk:/i.test(normalized)) {
|
||||
normalized = normalized.slice("nextcloud-talk:".length).trim();
|
||||
} else if (/^nc-talk:/i.test(normalized)) {
|
||||
normalized = normalized.slice("nc-talk:".length).trim();
|
||||
} else if (/^nc:/i.test(normalized)) {
|
||||
normalized = normalized.slice("nc:".length).trim();
|
||||
}
|
||||
|
||||
if (/^room:/i.test(normalized)) {
|
||||
normalized = normalized.slice("room:".length).trim();
|
||||
}
|
||||
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
return (
|
||||
raw
|
||||
.trim()
|
||||
.replace(/^(nextcloud-talk|nc-talk|nc):/i, "")
|
||||
.trim()
|
||||
.replace(/^room:/i, "")
|
||||
.trim() || undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
// Nextcloud Talk plugin module implements secret input behavior.
|
||||
export { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input";
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
// Nextcloud Talk plugin module implements send behavior.
|
||||
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
|
|
@ -55,15 +54,11 @@ type NextcloudTalkSendOpts = {
|
|||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function resolveCredentials(
|
||||
explicit: { baseUrl?: string; secret?: string },
|
||||
account: Pick<
|
||||
ReturnType<typeof resolveNextcloudTalkAccount>,
|
||||
"accountId" | "baseUrl" | "secret" | "tokenStatus"
|
||||
>,
|
||||
): { baseUrl: string; secret: string } {
|
||||
const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl;
|
||||
const secret = explicit.secret?.trim() ?? account.secret;
|
||||
function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts) {
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Nextcloud Talk send") as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({ cfg, accountId: opts.accountId });
|
||||
const baseUrl = opts.baseUrl?.trim() ?? account.baseUrl;
|
||||
const secret = opts.secret?.trim() ?? account.secret;
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
|
|
@ -78,7 +73,7 @@ function resolveCredentials(
|
|||
);
|
||||
}
|
||||
|
||||
return { baseUrl, secret };
|
||||
return { cfg, account, baseUrl, secret };
|
||||
}
|
||||
|
||||
function normalizeRoomToken(to: string): string {
|
||||
|
|
@ -89,24 +84,6 @@ function normalizeRoomToken(to: string): string {
|
|||
return normalized;
|
||||
}
|
||||
|
||||
function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): {
|
||||
cfg: CoreConfig;
|
||||
account: ReturnType<typeof resolveNextcloudTalkAccount>;
|
||||
baseUrl: string;
|
||||
secret: string;
|
||||
} {
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "Nextcloud Talk send") as CoreConfig;
|
||||
const account = resolveNextcloudTalkAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { baseUrl, secret } = resolveCredentials(
|
||||
{ baseUrl: opts.baseUrl, secret: opts.secret },
|
||||
account,
|
||||
);
|
||||
return { cfg, account, baseUrl, secret };
|
||||
}
|
||||
|
||||
function recordNextcloudTalkOutboundActivity(accountId: string): void {
|
||||
try {
|
||||
getNextcloudTalkRuntime().channel.activity.record({
|
||||
|
|
|
|||
|
|
@ -1,44 +1,31 @@
|
|||
// Nextcloud Talk plugin module implements session route behavior.
|
||||
import { buildChannelOutboundSessionRoute } from "openclaw/plugin-sdk/channel-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
|
||||
|
||||
type NextcloudTalkOutboundSessionRouteParams = {
|
||||
export function resolveNextcloudTalkOutboundSessionRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
accountId?: string | null;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export function resolveNextcloudTalkOutboundSessionRoute(
|
||||
params: NextcloudTalkOutboundSessionRouteParams,
|
||||
) {
|
||||
}) {
|
||||
const roomId = stripNextcloudTalkTargetPrefix(params.target);
|
||||
if (!roomId) {
|
||||
return null;
|
||||
}
|
||||
const baseSessionKey = buildOutboundBaseSessionKey({
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "nextcloud-talk",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
},
|
||||
});
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
baseSessionKey,
|
||||
// Room tokens do not reveal whether inbound keys by room or sender id.
|
||||
// Keep delivery behavior, but reject this route for explicit session selection.
|
||||
recipientSessionExact: false,
|
||||
peer: {
|
||||
kind: "group" as const,
|
||||
kind: "group",
|
||||
id: roomId,
|
||||
},
|
||||
chatType: "group" as const,
|
||||
chatType: "group",
|
||||
from: `nextcloud-talk:room:${roomId}`,
|
||||
to: `nextcloud-talk:${roomId}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,19 @@
|
|||
import { createChannelDmPolicy } from "openclaw/plugin-sdk/channel-dm-policy";
|
||||
// Nextcloud Talk plugin module implements setup core behavior.
|
||||
import {
|
||||
defineChannelSetupContract,
|
||||
type ChannelSetupAdapter,
|
||||
type ChannelSetupInput,
|
||||
} from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
createPromptParsedAllowFromForAccount,
|
||||
patchScopedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
createSetupInputPresenceValidator,
|
||||
mergeAllowFromEntries,
|
||||
promptParsedAllowFromForAccount,
|
||||
resolveSetupAccountId,
|
||||
createSetupTranslator,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import {
|
||||
|
|
@ -68,16 +64,11 @@ export function setNextcloudTalkAccountConfig(
|
|||
}) as CoreConfig;
|
||||
}
|
||||
|
||||
async function promptNextcloudTalkAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
return await promptParsedAllowFromForAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: params.accountId,
|
||||
prompter: params.prompter,
|
||||
const promptNextcloudTalkAllowFrom: ReturnType<
|
||||
typeof createPromptParsedAllowFromForAccount
|
||||
> = async (params) =>
|
||||
await createPromptParsedAllowFromForAccount<CoreConfig>({
|
||||
defaultAccountId: resolveDefaultNextcloudTalkAccountId,
|
||||
noteTitle: t("wizard.nextcloudTalk.userIdTitle"),
|
||||
noteLines: [
|
||||
t("wizard.nextcloudTalk.userIdHelpAdmin"),
|
||||
|
|
@ -107,24 +98,7 @@ async function promptNextcloudTalkAllowFrom(params: {
|
|||
dmPolicy: "allowlist",
|
||||
allowFrom,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function promptNextcloudTalkAllowFromForAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveSetupAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig),
|
||||
});
|
||||
return await promptNextcloudTalkAllowFrom({
|
||||
cfg: params.cfg as CoreConfig,
|
||||
prompter: params.prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
})(params);
|
||||
|
||||
export const nextcloudTalkDmPolicy = createChannelDmPolicy({
|
||||
label: "Nextcloud Talk",
|
||||
|
|
@ -136,7 +110,7 @@ export const nextcloudTalkDmPolicy = createChannelDmPolicy({
|
|||
}),
|
||||
applyPatch: ({ cfg, account, patch }) =>
|
||||
setNextcloudTalkAccountConfig(cfg as CoreConfig, account.accountId, patch),
|
||||
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
|
||||
promptAllowFrom: promptNextcloudTalkAllowFrom,
|
||||
});
|
||||
|
||||
const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
// Nextcloud Talk plugin module implements signature behavior.
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { NextcloudTalkWebhookHeaders } from "./types.js";
|
||||
|
||||
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
|
||||
|
|
@ -30,14 +28,11 @@ export function verifyNextcloudTalkSignature(params: {
|
|||
return safeEqualSecret(signature, expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract webhook headers from an incoming request.
|
||||
*/
|
||||
export function extractNextcloudTalkHeaders(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
): NextcloudTalkWebhookHeaders | null {
|
||||
const getHeader = (name: string): string | undefined => {
|
||||
const value = headers[name] ?? headers[normalizeLowercaseStringOrEmpty(name)];
|
||||
const value = headers[name];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Nextcloud Talk type declarations define plugin contracts.
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { z } from "zod";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
|
|
@ -26,50 +25,6 @@ export type CoreConfig = {
|
|||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nextcloud Talk webhook payload types based on Activity Streams 2.0 format.
|
||||
* Reference: https://nextcloud-talk.readthedocs.io/en/latest/bots/
|
||||
*/
|
||||
|
||||
/** Actor in the activity (the message sender). */
|
||||
type NextcloudTalkActor = {
|
||||
type: "Person";
|
||||
/** User ID in Nextcloud. */
|
||||
id: string;
|
||||
/** Display name of the user. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** The message object in the activity. */
|
||||
type NextcloudTalkObject = {
|
||||
type: "Note";
|
||||
/** Message ID. */
|
||||
id: string;
|
||||
/** Message text (same as content for text/plain). */
|
||||
name: string;
|
||||
/** Message content. */
|
||||
content: string;
|
||||
/** Media type of the content. */
|
||||
mediaType: string;
|
||||
};
|
||||
|
||||
/** Target conversation/room. */
|
||||
type NextcloudTalkTarget = {
|
||||
type: "Collection";
|
||||
/** Room token. */
|
||||
id: string;
|
||||
/** Room display name. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** Incoming webhook payload from Nextcloud Talk. */
|
||||
export type NextcloudTalkWebhookPayload = {
|
||||
type: "Create" | "Update" | "Delete";
|
||||
actor: NextcloudTalkActor;
|
||||
object: NextcloudTalkObject;
|
||||
target: NextcloudTalkTarget;
|
||||
};
|
||||
|
||||
/** Result from sending a message to Nextcloud Talk. */
|
||||
export type NextcloudTalkSendResult = {
|
||||
messageId: string;
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
NEXTCLOUD_TALK_REPLAY_DEDUPE_TTL_MS,
|
||||
} from "./replay-migration-contract.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import type { NextcloudTalkInboundMessage, NextcloudTalkWebhookPayload } from "./types.js";
|
||||
import type { NextcloudTalkInboundMessage } from "./types.js";
|
||||
import {
|
||||
inspectNextcloudTalkWebhookEnvelope,
|
||||
migrateNextcloudTalkLegacyReplayState,
|
||||
|
|
@ -39,7 +39,8 @@ function describeIgnoredWebhookEvent(rawEvent: string): string {
|
|||
return `type=${type} objectType=${objectType}`;
|
||||
}
|
||||
|
||||
const NextcloudTalkWebhookPayloadSchema: z.ZodType<NextcloudTalkWebhookPayload> = z.object({
|
||||
// Activity Streams payload: https://nextcloud-talk.readthedocs.io/en/latest/bots/
|
||||
const NextcloudTalkWebhookPayloadSchema = z.object({
|
||||
type: z.enum(["Create", "Update", "Delete"]),
|
||||
actor: z.object({
|
||||
type: z.literal("Person"),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel API module exposes the plugin public contract.
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
listEnabledQaChannelAccounts,
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
// Qa Channel API module exposes the plugin public contract.
|
||||
export { qaChannelPlugin } from "./src/channel.js";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
// Qa Channel API module exposes the plugin public contract.
|
||||
export type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelGatewayContext,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
buildChannelOutboundSessionRoute,
|
||||
type ChannelGatewayContext,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
createChatChannelPlugin,
|
||||
defineChannelPluginEntry,
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
export { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
|
||||
export { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
export {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
createPluginRuntimeStore,
|
||||
defineChannelPluginEntry,
|
||||
getChatChannelMeta,
|
||||
jsonResult,
|
||||
type OpenClawConfig,
|
||||
type PluginRuntime,
|
||||
readStringParam,
|
||||
type RuntimeEnv,
|
||||
} from "./src/runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
export { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
export { getQaChannelRuntime, setQaChannelRuntime } from "./src/runtime.js";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel plugin module implements accounts behavior.
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
resolveChannelMediaMaxBytes,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel plugin module implements bus client behavior.
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { toErrorObject } from "openclaw/plugin-sdk/error-runtime";
|
||||
|
|
@ -8,18 +7,21 @@ import {
|
|||
buildQaTarget,
|
||||
parseQaTarget,
|
||||
type QaTargetParts,
|
||||
type QaBusCreateThreadInput,
|
||||
type QaBusDeleteMessageInput,
|
||||
type QaBusEditMessageInput,
|
||||
type QaBusReactToMessageInput,
|
||||
type QaBusReadMessageInput,
|
||||
type QaBusOutboundMessageInput,
|
||||
type QaBusInboundMessageInput,
|
||||
type QaBusMessage,
|
||||
type QaBusPollResult,
|
||||
type QaBusSearchMessagesInput,
|
||||
type QaBusStateSnapshot,
|
||||
type QaBusThread,
|
||||
} from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import { readByteStreamWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type {
|
||||
QaBusInboundMessageInput,
|
||||
QaBusMessage,
|
||||
QaBusPollResult,
|
||||
QaBusSearchMessagesInput,
|
||||
QaBusStateSnapshot,
|
||||
QaBusThread,
|
||||
QaBusToolCall,
|
||||
} from "./protocol.js";
|
||||
|
||||
export { buildQaTarget, parseQaTarget };
|
||||
|
||||
|
|
@ -43,9 +45,12 @@ export type {
|
|||
QaBusThread,
|
||||
QaBusToolCall,
|
||||
QaBusWaitForInput,
|
||||
} from "./protocol.js";
|
||||
} from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
|
||||
type JsonResult<T> = Promise<T>;
|
||||
type QaBusAccountRequest<T> = Omit<T, "accountId" | "timestamp"> & {
|
||||
baseUrl: string;
|
||||
accountId: string;
|
||||
};
|
||||
const QA_BUS_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
||||
/** Total deadline for local qa-bus POST requests and long-poll response grace. */
|
||||
const QA_BUS_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
|
@ -90,7 +95,7 @@ async function postJson<T>(
|
|||
path: string,
|
||||
body: unknown,
|
||||
options: QaBusPostOptions = {},
|
||||
): JsonResult<T> {
|
||||
): Promise<T> {
|
||||
const url = buildQaBusUrl(baseUrl, path);
|
||||
const payload = JSON.stringify(body);
|
||||
const client = url.protocol === "https:" ? https : http;
|
||||
|
|
@ -185,31 +190,13 @@ export async function pollQaBus(params: {
|
|||
);
|
||||
}
|
||||
|
||||
export async function sendQaBusMessage(params: {
|
||||
baseUrl: string;
|
||||
accountId: string;
|
||||
to: string;
|
||||
text: string;
|
||||
isError?: boolean;
|
||||
senderId?: string;
|
||||
senderName?: string;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
attachments?: import("./protocol.js").QaBusAttachment[];
|
||||
toolCalls?: QaBusToolCall[];
|
||||
}) {
|
||||
export async function sendQaBusMessage(params: QaBusAccountRequest<QaBusOutboundMessageInput>) {
|
||||
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/outbound/message", params, {
|
||||
timeoutMs: QA_BUS_MESSAGE_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createQaBusThread(params: {
|
||||
baseUrl: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
title: string;
|
||||
createdBy?: string;
|
||||
}) {
|
||||
export async function createQaBusThread(params: QaBusAccountRequest<QaBusCreateThreadInput>) {
|
||||
return await postJson<{ thread: QaBusThread }>(
|
||||
params.baseUrl,
|
||||
"/v1/actions/thread-create",
|
||||
|
|
@ -217,38 +204,19 @@ export async function createQaBusThread(params: {
|
|||
);
|
||||
}
|
||||
|
||||
export async function reactToQaBusMessage(params: {
|
||||
baseUrl: string;
|
||||
accountId: string;
|
||||
messageId: string;
|
||||
emoji: string;
|
||||
senderId?: string;
|
||||
}) {
|
||||
export async function reactToQaBusMessage(params: QaBusAccountRequest<QaBusReactToMessageInput>) {
|
||||
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/react", params);
|
||||
}
|
||||
|
||||
export async function editQaBusMessage(params: {
|
||||
baseUrl: string;
|
||||
accountId: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
}) {
|
||||
export async function editQaBusMessage(params: QaBusAccountRequest<QaBusEditMessageInput>) {
|
||||
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/edit", params);
|
||||
}
|
||||
|
||||
export async function deleteQaBusMessage(params: {
|
||||
baseUrl: string;
|
||||
accountId: string;
|
||||
messageId: string;
|
||||
}) {
|
||||
export async function deleteQaBusMessage(params: QaBusAccountRequest<QaBusDeleteMessageInput>) {
|
||||
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/delete", params);
|
||||
}
|
||||
|
||||
export async function readQaBusMessage(params: {
|
||||
baseUrl: string;
|
||||
accountId: string;
|
||||
messageId: string;
|
||||
}) {
|
||||
export async function readQaBusMessage(params: QaBusAccountRequest<QaBusReadMessageInput>) {
|
||||
return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/read", params);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
// Qa Channel plugin module implements channel actions behavior.
|
||||
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { Type } from "typebox";
|
||||
|
|
@ -18,7 +21,6 @@ import {
|
|||
type QaBusMessage,
|
||||
} from "./bus-client.js";
|
||||
import { QA_CHANNEL_ID } from "./channel-base.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "./runtime-api.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function listQaChannelActions(
|
||||
|
|
@ -99,16 +101,12 @@ function readQaMessageTarget(
|
|||
};
|
||||
}
|
||||
|
||||
function qaMessageMatchesTarget(message: QaBusMessage, target: QaMessageTarget): boolean {
|
||||
return (
|
||||
message.conversation.id === target.conversationId &&
|
||||
message.conversation.kind === target.conversationKind &&
|
||||
(message.threadId ?? null) === target.threadId
|
||||
);
|
||||
}
|
||||
|
||||
function assertQaMessageMatchesTarget(message: QaBusMessage, target: QaMessageTarget): void {
|
||||
if (!qaMessageMatchesTarget(message, target)) {
|
||||
if (
|
||||
message.conversation.id !== target.conversationId ||
|
||||
message.conversation.kind !== target.conversationKind ||
|
||||
(message.threadId ?? null) !== target.threadId
|
||||
) {
|
||||
throw new Error("qa-channel message is not in the selected conversation");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
|
||||
// Qa Channel plugin module implements channel base behavior.
|
||||
import {
|
||||
listQaChannelAccountIds,
|
||||
resolveDefaultQaChannelAccountId,
|
||||
|
|
@ -7,7 +7,6 @@ import {
|
|||
type ResolvedQaChannelAccount,
|
||||
} from "./accounts.js";
|
||||
import { qaChannelPluginConfigSchema } from "./config-schema.js";
|
||||
import type { ChannelPlugin } from "./runtime-api.js";
|
||||
import { applyQaSetup, type QaChannelSetupInput } from "./setup.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Qa Channel plugin module implements channel.setup behavior.
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import type { ResolvedQaChannelAccount } from "./accounts.js";
|
||||
import { createQaChannelPluginBase } from "./channel-base.js";
|
||||
import type { ChannelPlugin } from "./runtime-api.js";
|
||||
|
||||
export const qaChannelSetupPlugin: ChannelPlugin<ResolvedQaChannelAccount> =
|
||||
createQaChannelPluginBase();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Qa Channel tests cover channel plugin behavior.
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createPluginRuntimeMock,
|
||||
|
|
@ -17,7 +18,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||
import { createQaBusState, startQaBusServer } from "../../qa-lab/bus-api.js";
|
||||
import { qaChannelPlugin, setQaChannelRuntime } from "../api.js";
|
||||
import { listQaChannelAccountIds, resolveDefaultQaChannelAccountId } from "./accounts.js";
|
||||
import type { ChannelMessageActionName } from "./runtime-api.js";
|
||||
|
||||
type QaDispatchTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatch"]>[0];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// Qa Channel plugin module implements channel behavior.
|
||||
import type { ChannelThreadingToolContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
buildThreadAwareOutboundSessionRoute,
|
||||
createChatChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
defineChannelMessageAdapter,
|
||||
|
|
@ -21,7 +21,6 @@ import { qaChannelMessageActions } from "./channel-actions.js";
|
|||
import { createQaChannelPluginBase, QA_CHANNEL_ID, qaChannelRuntimeMeta } from "./channel-base.js";
|
||||
import { startQaGatewayAccount } from "./gateway.js";
|
||||
import { sendQaChannelMedia, sendQaChannelMediaBatch, sendQaChannelText } from "./outbound.js";
|
||||
import type { ChannelPlugin } from "./runtime-api.js";
|
||||
import { qaChannelStatus } from "./status.js";
|
||||
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
|
||||
|
||||
|
|
@ -175,8 +174,7 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
|||
normalizeTarget: normalizeQaTarget,
|
||||
inferTargetChatType: ({ to }) => parseQaTarget(to).chatType,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) =>
|
||||
/^((dm|channel|group):|thread:[^/]+\/)/i.test(raw.trim()) || raw.trim().length > 0,
|
||||
looksLikeId: (raw) => raw.trim().length > 0,
|
||||
hint: "<dm:user|channel:room|group:room|thread:room/thread>",
|
||||
},
|
||||
resolveOutboundSessionRoute: ({
|
||||
|
|
@ -201,12 +199,7 @@ export const qaChannelPlugin: ChannelPlugin<ResolvedQaChannelAccount> = createCh
|
|||
accountId,
|
||||
recipientSessionExact: true,
|
||||
peer: {
|
||||
kind:
|
||||
parsed.chatType === "direct"
|
||||
? "direct"
|
||||
: parsed.chatType === "group"
|
||||
? "group"
|
||||
: "channel",
|
||||
kind: parsed.chatType,
|
||||
id: baseTarget,
|
||||
},
|
||||
chatType: parsed.chatType,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel helper module supports config schema behavior.
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildGroupEntrySchema,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// Qa Channel tests cover gateway lifecycle behavior.
|
||||
import { createServer } from "node:http";
|
||||
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createQaBusState, startQaBusServer } from "../../qa-lab/bus-api.js";
|
||||
import { startQaGatewayAccount } from "./gateway.js";
|
||||
import { handleQaInbound } from "./inbound.js";
|
||||
import type { ChannelGatewayContext } from "./runtime-api.js";
|
||||
import type { ResolvedQaChannelAccount } from "./types.js";
|
||||
|
||||
vi.mock("./inbound.js", () => ({
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// Qa Channel plugin module implements gateway behavior.
|
||||
import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/channel-core";
|
||||
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { channelReadyPatch, channelStoppedPatch } from "openclaw/plugin-sdk/gateway-runtime";
|
||||
import { pollQaBus } from "./bus-client.js";
|
||||
import { handleQaInbound } from "./inbound.js";
|
||||
import type { ChannelGatewayContext } from "./runtime-api.js";
|
||||
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
|
||||
|
||||
export async function startQaGatewayAccount(
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
resolveChannelInboundRouteEnvelope,
|
||||
toInboundMediaFactsWithMetadata,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
// Qa Channel plugin module implements inbound behavior.
|
||||
import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-local-roots";
|
||||
|
|
@ -16,6 +15,7 @@ import {
|
|||
type QaBusToolCall,
|
||||
} from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
import {
|
||||
buildQaTarget,
|
||||
deleteQaBusMessage,
|
||||
|
|
@ -24,7 +24,6 @@ import {
|
|||
type QaBusMessage,
|
||||
} from "./bus-client.js";
|
||||
import { sendQaChannelMediaBatch, sendQaChannelText } from "./outbound.js";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
import { getQaChannelRuntime } from "./runtime.js";
|
||||
import type { CoreConfig, ResolvedQaChannelAccount } from "./types.js";
|
||||
|
||||
|
|
@ -258,20 +257,13 @@ function createQaReplyPreview(params: {
|
|||
previewStopped = true;
|
||||
}
|
||||
return withPreviewLock(async () => {
|
||||
if (mediaUrls.length > 0) {
|
||||
// Tool/block callbacks acknowledge real delivery, not a preview. A new
|
||||
// attachment must survive even when its caption matches an earlier send.
|
||||
if (mediaUrls.length > 0 || isError === true) {
|
||||
// Preview edits cannot add attachments or a typed failure marker.
|
||||
// Both need a durable send even when the text matches earlier output.
|
||||
await clear();
|
||||
await sendDurable(text, isError, mediaUrls);
|
||||
return;
|
||||
}
|
||||
if (isError === true) {
|
||||
// Preview edits cannot add the typed failure marker. Replace any preview
|
||||
// with one durable marked message so QA Lab cannot accept it as success.
|
||||
await clear();
|
||||
await sendDurable(text, true);
|
||||
return;
|
||||
}
|
||||
// Core may close a streamed block with an identical final payload.
|
||||
// The block is already durable, so posting the final again duplicates the reply.
|
||||
if (
|
||||
|
|
@ -327,12 +319,7 @@ export async function handleQaInbound(params: {
|
|||
channel: params.channelId,
|
||||
accountId: params.account.accountId,
|
||||
peer: {
|
||||
kind:
|
||||
inbound.conversation.kind === "direct"
|
||||
? "direct"
|
||||
: inbound.conversation.kind === "group"
|
||||
? "group"
|
||||
: "channel",
|
||||
kind: inbound.conversation.kind,
|
||||
id: target,
|
||||
},
|
||||
});
|
||||
|
|
@ -502,19 +489,10 @@ export async function handleQaInbound(params: {
|
|||
ctxPayload,
|
||||
delivery: {
|
||||
deliver: async (payload, info) => {
|
||||
const reply =
|
||||
payload && typeof payload === "object"
|
||||
? (payload as {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
isError?: boolean;
|
||||
})
|
||||
: undefined;
|
||||
const text = reply?.text ?? "";
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = Array.from(
|
||||
new Set(
|
||||
[reply?.mediaUrl, ...(reply?.mediaUrls ?? [])].filter(
|
||||
[payload.mediaUrl, ...(payload.mediaUrls ?? [])].filter(
|
||||
(mediaUrl): mediaUrl is string =>
|
||||
typeof mediaUrl === "string" && mediaUrl.trim().length > 0,
|
||||
),
|
||||
|
|
@ -523,7 +501,7 @@ export async function handleQaInbound(params: {
|
|||
if (!text.trim() && mediaUrls.length === 0) {
|
||||
return;
|
||||
}
|
||||
await preview.deliver(text, info?.kind ?? "final", reply?.isError, mediaUrls);
|
||||
await preview.deliver(text, info?.kind ?? "final", payload.isError, mediaUrls);
|
||||
},
|
||||
onError: (error) => {
|
||||
void clearPreview();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
// Qa Channel plugin module implements outbound behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
loadOutboundMediaFromUrl,
|
||||
type OutboundMediaLoadOptions,
|
||||
} from "openclaw/plugin-sdk/outbound-media";
|
||||
import type { QaBusAttachment, QaBusToolCall } from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
import { resolveQaChannelAccount } from "./accounts.js";
|
||||
import { buildQaTarget, resolveQaTargetThread, sendQaBusMessage } from "./bus-client.js";
|
||||
import type { QaBusAttachment, QaBusToolCall } from "./protocol.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type QaChannelTextSendParams = {
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
// Qa Channel plugin module implements protocol behavior.
|
||||
export type {
|
||||
QaBusAttachment,
|
||||
QaBusConversation,
|
||||
QaBusConversationKind,
|
||||
QaBusCreateThreadInput,
|
||||
QaBusDeleteMessageInput,
|
||||
QaBusEditMessageInput,
|
||||
QaBusEvent,
|
||||
QaBusInboundMessageInput,
|
||||
QaBusMessage,
|
||||
QaBusOutboundMessageInput,
|
||||
QaBusPollInput,
|
||||
QaBusPollResult,
|
||||
QaBusReactToMessageInput,
|
||||
QaBusReadMessageInput,
|
||||
QaBusSearchMessagesInput,
|
||||
QaBusStateSnapshot,
|
||||
QaBusThread,
|
||||
QaBusToolCall,
|
||||
QaBusWaitForInput,
|
||||
} from "openclaw/plugin-sdk/qa-channel-protocol";
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// Qa Channel API module exposes the plugin public contract.
|
||||
export type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelGatewayContext,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
buildChannelOutboundSessionRoute,
|
||||
createChatChannelPlugin,
|
||||
defineChannelPluginEntry,
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
export { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
|
||||
export { getChatChannelMeta } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
export {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
export { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
// Qa Channel plugin module implements runtime behavior.
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setQaChannelRuntime, getRuntime: getQaChannelRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel setup module handles plugin onboarding behavior.
|
||||
import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// Qa Channel plugin module implements status behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "./runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./accounts.js";
|
||||
import type { ResolvedQaChannelAccount } from "./types.js";
|
||||
|
||||
export const qaChannelStatus = createComputedAccountStatusAdapter<ResolvedQaChannelAccount>({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Qa Channel type declarations define plugin contracts.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { QaChannelAccountConfig, QaChannelConfig } from "./config-schema.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
// Qa Channel API module exposes the plugin public contract.
|
||||
export * from "./src/protocol.js";
|
||||
export * from "./src/bus-client.js";
|
||||
|
|
|
|||
|
|
@ -1,20 +1,13 @@
|
|||
// Raft channel plugin wires the wake bridge into the canonical channel runtime.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
|
||||
import {
|
||||
buildBaseChannelStatusSummary,
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
listRaftAccountIds,
|
||||
RAFT_CHANNEL_ID,
|
||||
resolveDefaultRaftAccountId,
|
||||
resolveRaftAccount,
|
||||
type ResolvedRaftAccount,
|
||||
} from "./accounts.js";
|
||||
import { RAFT_CHANNEL_ID, type ResolvedRaftAccount } from "./accounts.js";
|
||||
import { raftChannelConfigSchema } from "./config-schema.js";
|
||||
import { startRaftGatewayAccount } from "./gateway.js";
|
||||
import { raftSetupPlugin } from "./setup.js";
|
||||
|
|
@ -25,30 +18,11 @@ type RaftProbe =
|
|||
|
||||
export const raftPlugin: ChannelPlugin<ResolvedRaftAccount, RaftProbe> = createChatChannelPlugin({
|
||||
base: {
|
||||
id: RAFT_CHANNEL_ID,
|
||||
meta: {
|
||||
id: RAFT_CHANNEL_ID,
|
||||
label: "Raft",
|
||||
selectionLabel: "Raft (CLI wake bridge)",
|
||||
docsPath: "/channels/raft",
|
||||
docsLabel: "raft",
|
||||
blurb: "Raft CLI wake bridge for human and agent collaboration.",
|
||||
order: 72,
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
},
|
||||
setupContract: raftSetupPlugin.setupContract,
|
||||
setupWizard: raftSetupPlugin.setupWizard,
|
||||
...raftSetupPlugin,
|
||||
reload: { configPrefixes: ["channels.raft"] },
|
||||
configSchema: raftChannelConfigSchema,
|
||||
config: {
|
||||
listAccountIds: listRaftAccountIds,
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveRaftAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultRaftAccountId,
|
||||
isConfigured: (account) => account.configured,
|
||||
isEnabled: (account) => account.enabled,
|
||||
...raftSetupPlugin.config,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
|
|
@ -103,7 +77,7 @@ export const raftPlugin: ChannelPlugin<ResolvedRaftAccount, RaftProbe> = createC
|
|||
}),
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: async (ctx) => await startRaftGatewayAccount(ctx),
|
||||
startAccount: startRaftGatewayAccount,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
createSessionShareNodeCommands,
|
||||
createSessionShareNodeInvokePolicies,
|
||||
} from "./src/node-commands.js";
|
||||
import { createSessionShareNodeCommands, SESSION_SHARE_COMMANDS } from "./src/node-commands.js";
|
||||
import { createSessionShareCatalog } from "./src/session-catalog.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
|
|
@ -14,8 +11,10 @@ export default definePluginEntry({
|
|||
for (const command of createSessionShareNodeCommands(api)) {
|
||||
api.registerNodeHostCommand(command);
|
||||
}
|
||||
for (const policy of createSessionShareNodeInvokePolicies()) {
|
||||
api.registerNodeInvokePolicy(policy);
|
||||
}
|
||||
api.registerNodeInvokePolicy({
|
||||
commands: SESSION_SHARE_COMMANDS,
|
||||
defaultPlatforms: ["macos", "linux", "windows"],
|
||||
handle: (context) => context.invokeNode(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { redactToolPayloadText } from "openclaw/plugin-sdk/logging-core";
|
|||
import type {
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isSubagentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
|
|
@ -204,13 +203,3 @@ export function createSessionShareNodeCommands(
|
|||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createSessionShareNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
|
||||
return [
|
||||
{
|
||||
commands: SESSION_SHARE_COMMANDS,
|
||||
defaultPlatforms: ["macos", "linux", "windows"],
|
||||
handle: (context) => context.invokeNode(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const nodeErrorCodes = new Set([
|
|||
"APPROVAL_AUTHORITY_CLOSED",
|
||||
]);
|
||||
|
||||
function observeCatalogPhase<T>(phase: "discovery" | "invoke", operation: () => Promise<T>) {
|
||||
async function observeCatalogPhase<T>(phase: "discovery" | "invoke", operation: () => Promise<T>) {
|
||||
if (!areDiagnosticsEnabledForProcess() || !log.isEnabled("warn")) {
|
||||
return operation();
|
||||
}
|
||||
|
|
@ -86,16 +86,9 @@ function observeCatalogPhase<T>(phase: "discovery" | "invoke", operation: () =>
|
|||
}
|
||||
};
|
||||
try {
|
||||
return operation().then(
|
||||
(value) => {
|
||||
finish("resolved");
|
||||
return value;
|
||||
},
|
||||
(error: unknown) => {
|
||||
finish("rejected", error);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
const value = await operation();
|
||||
finish("resolved");
|
||||
return value;
|
||||
} catch (error) {
|
||||
finish("rejected", error);
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { teamsMeetingsConfig } from "./config.js";
|
||||
import { testTeamsMeetingListening } from "./runtime-probes.js";
|
||||
import { teamsMeetingsProbes } from "./runtime-probes.js";
|
||||
import type { TeamsMeetingsSession } from "./transports/types.js";
|
||||
|
||||
const URL = "https://teams.microsoft.com/l/meetup-join/19%3ameeting_probe%40thread.v2/0";
|
||||
type TeamsMeetingsProbeContext = Parameters<typeof testTeamsMeetingListening>[0];
|
||||
type TeamsMeetingsProbeContext = Parameters<typeof teamsMeetingsProbes.testListening>[0];
|
||||
|
||||
describe.each(["chrome", "chrome-node"] as const)(
|
||||
"Microsoft Teams %s runtime probes",
|
||||
|
|
@ -53,7 +53,7 @@ describe.each(["chrome", "chrome-node"] as const)(
|
|||
resolveAgentId: () => "main",
|
||||
} satisfies TeamsMeetingsProbeContext;
|
||||
|
||||
const result = await testTeamsMeetingListening(context, {
|
||||
const result = await teamsMeetingsProbes.testListening(context, {
|
||||
mode: "transcribe",
|
||||
timeoutMs: 100,
|
||||
url: URL,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
TeamsMeetingsSession,
|
||||
} from "./transports/types.js";
|
||||
|
||||
const probes = MeetingPlatformAdapter.createRuntimeProbes<
|
||||
export const teamsMeetingsProbes = MeetingPlatformAdapter.createRuntimeProbes<
|
||||
TeamsMeetingsConfig,
|
||||
TeamsMeetingsMode,
|
||||
TeamsMeetingsTransport,
|
||||
|
|
@ -22,6 +22,3 @@ const probes = MeetingPlatformAdapter.createRuntimeProbes<
|
|||
shouldWaitForListening: ({ chrome }) => Boolean(chrome?.launched || chrome?.browserTab?.targetId),
|
||||
talkBackMode: MeetingPlatformAdapter.isTalkBackMode,
|
||||
});
|
||||
|
||||
export const testTeamsMeetingListening = probes.testListening;
|
||||
export const testTeamsMeetingSpeech = probes.testSpeech;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,23 @@ function runtimeWithNode(invoke: (params: Record<string, unknown>) => Promise<un
|
|||
}
|
||||
|
||||
describe("Microsoft Teams meetings runtime setup", () => {
|
||||
it("reports live-caption capture for observe-only setup without probing audio", async () => {
|
||||
const runtime = runtimeWithNode(async () => ({ ok: true }));
|
||||
const status = await getTeamsMeetingsSetupStatus({
|
||||
config: resolveTeamsMeetingsConfig({ chromeNode: { node: "teams-node" } }),
|
||||
fullConfig: {},
|
||||
runtime,
|
||||
options: { mode: "transcribe", transport: "chrome-node" },
|
||||
});
|
||||
|
||||
expect(status.checks).toContainEqual({
|
||||
id: "captions",
|
||||
message: "Teams live-caption capture is enabled and ready",
|
||||
ok: true,
|
||||
});
|
||||
expect(runtime.nodes.invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("probes remote talk-back prerequisites through the selected Chrome node", async () => {
|
||||
const runtime = runtimeWithNode(async () => ({ ok: true }));
|
||||
const config = resolveTeamsMeetingsConfig({
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
import type { TeamsMeetingsConfig, TeamsMeetingsMode } from "./config.js";
|
||||
import { assertTeamsMeetingsAudioAvailable } from "./transports/chrome.js";
|
||||
import { TEAMS_MEETINGS_BROWSER_NODE_ADAPTER } from "./transports/teams-meetings-platform-constants.js";
|
||||
import { teamsMeetingsChrome } from "./transports/chrome.js";
|
||||
import { TEAMS_MEETINGS_PLATFORM_ADAPTER } from "./transports/teams-meetings-platform-adapter.js";
|
||||
|
||||
export const getTeamsMeetingsSetupStatus = MeetingPlatformAdapter.createRuntimeSetup<
|
||||
TeamsMeetingsConfig,
|
||||
TeamsMeetingsMode
|
||||
>({
|
||||
assertAudioDeviceAvailable: assertTeamsMeetingsAudioAvailable,
|
||||
assertAudioDeviceAvailable: teamsMeetingsChrome.assertAudioDeviceAvailable,
|
||||
captionsMessage: (mode) =>
|
||||
mode === "transcribe"
|
||||
? "Teams caption scraping is disabled pending live selector validation; transcript snapshots are empty"
|
||||
? "Teams live-caption capture is enabled and ready"
|
||||
: "Caption scraping is not used by talk-back modes",
|
||||
connectedNodeMessage: (node) => `Connected Teams meeting node ready: ${node}`,
|
||||
guestJoinCheck: (config) => {
|
||||
|
|
@ -25,5 +25,5 @@ export const getTeamsMeetingsSetupStatus = MeetingPlatformAdapter.createRuntimeS
|
|||
};
|
||||
},
|
||||
missingNodeIdMessage: "Connected Microsoft Teams meetings node did not include a node id.",
|
||||
nodeAdapter: TEAMS_MEETINGS_BROWSER_NODE_ADAPTER,
|
||||
nodeAdapter: TEAMS_MEETINGS_PLATFORM_ADAPTER,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
import type { TeamsMeetingsConfig, TeamsMeetingsMode, TeamsMeetingsTransport } from "./config.js";
|
||||
import { testTeamsMeetingListening, testTeamsMeetingSpeech } from "./runtime-probes.js";
|
||||
import { teamsMeetingsProbes } from "./runtime-probes.js";
|
||||
import { getTeamsMeetingsSetupStatus } from "./runtime-setup.js";
|
||||
import {
|
||||
launchTeamsMeetingInChrome,
|
||||
launchTeamsMeetingOnNode,
|
||||
leaveTeamsMeetingInBrowser,
|
||||
readTeamsMeetingTranscript,
|
||||
recoverCurrentTeamsMeetingTab,
|
||||
} from "./transports/chrome.js";
|
||||
import { teamsMeetingsChrome } from "./transports/chrome.js";
|
||||
import { TEAMS_MEETINGS_PLATFORM_ADAPTER } from "./transports/teams-meetings-platform-adapter.js";
|
||||
import type { TeamsMeetingsChromeHealth } from "./transports/types.js";
|
||||
|
||||
|
|
@ -19,22 +13,15 @@ export const TeamsMeetingsRuntime = MeetingPlatformAdapter.createRuntimeFacade<
|
|||
TeamsMeetingsChromeHealth,
|
||||
{
|
||||
setup: Awaited<ReturnType<typeof getTeamsMeetingsSetupStatus>>;
|
||||
listening: Awaited<ReturnType<typeof testTeamsMeetingListening>>;
|
||||
speech: Awaited<ReturnType<typeof testTeamsMeetingSpeech>>;
|
||||
listening: Awaited<ReturnType<typeof teamsMeetingsProbes.testListening>>;
|
||||
speech: Awaited<ReturnType<typeof teamsMeetingsProbes.testSpeech>>;
|
||||
}
|
||||
>({
|
||||
platform: TEAMS_MEETINGS_PLATFORM_ADAPTER,
|
||||
transport: {
|
||||
launchInChrome: launchTeamsMeetingInChrome,
|
||||
launchOnNode: launchTeamsMeetingOnNode,
|
||||
leaveInBrowser: leaveTeamsMeetingInBrowser,
|
||||
readTranscript: readTeamsMeetingTranscript,
|
||||
recoverCurrentTab: recoverCurrentTeamsMeetingTab,
|
||||
},
|
||||
transport: teamsMeetingsChrome,
|
||||
probes: {
|
||||
setupStatus: getTeamsMeetingsSetupStatus,
|
||||
testListening: testTeamsMeetingListening,
|
||||
testSpeech: testTeamsMeetingSpeech,
|
||||
...teamsMeetingsProbes,
|
||||
},
|
||||
messages: {
|
||||
durableTranscripts: { providerId: "teams", providerName: "Microsoft Teams" },
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ vi.mock("openclaw/plugin-sdk/meeting-runtime", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
import { launchTeamsMeetingInChrome, launchTeamsMeetingOnNode } from "./chrome.js";
|
||||
import { teamsMeetingsChrome } from "./chrome.js";
|
||||
|
||||
const URL = "https://teams.microsoft.com/l/meetup-join/19%3ameeting_rollback%40thread.v2/0";
|
||||
|
||||
|
|
@ -47,8 +47,8 @@ describe("Microsoft Teams meeting Chrome startup cleanup", () => {
|
|||
nodeCommand: "teamsmeetings.chrome",
|
||||
preserveTrackedBrowser: false,
|
||||
resolveConfig: resolveTeamsMeetingsConfig,
|
||||
launchInChrome: launchTeamsMeetingInChrome,
|
||||
launchOnNode: launchTeamsMeetingOnNode,
|
||||
launchInChrome: teamsMeetingsChrome.launchInChrome,
|
||||
launchOnNode: teamsMeetingsChrome.launchOnNode,
|
||||
engineMocks,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
import { TEAMS_MEETINGS_PLATFORM_ADAPTER } from "./teams-meetings-platform-adapter.js";
|
||||
|
||||
const chromeTransport = MeetingPlatformAdapter.createPluginChromeTransport({
|
||||
export const teamsMeetingsChrome = MeetingPlatformAdapter.createPluginChromeTransport({
|
||||
meetingLabel: "Microsoft Teams meeting",
|
||||
platform: TEAMS_MEETINGS_PLATFORM_ADAPTER,
|
||||
preserveTrackedBrowserOnEngineFailure: false,
|
||||
runtime: MeetingPlatformAdapter.createChromeRuntimeBindings(),
|
||||
});
|
||||
|
||||
export const assertTeamsMeetingsAudioAvailable = chromeTransport.assertAudioDeviceAvailable;
|
||||
export const launchTeamsMeetingInChrome = chromeTransport.launchInChrome;
|
||||
export const launchTeamsMeetingOnNode = chromeTransport.launchOnNode;
|
||||
export const leaveTeamsMeetingInBrowser = chromeTransport.leaveInBrowser;
|
||||
export const readTeamsMeetingTranscript = chromeTransport.readTranscript;
|
||||
export const recoverCurrentTeamsMeetingTab = chromeTransport.recoverCurrentTab;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
teamsMeetingStatusScript,
|
||||
teamsMeetingTranscriptScript,
|
||||
} from "./teams-meetings-page-scripts.js";
|
||||
import { TEAMS_MEETINGS_NODE_COMMAND } from "./teams-meetings-platform-constants.js";
|
||||
import {
|
||||
isRecoverableTeamsMeetingTab,
|
||||
isSameTeamsMeetingUrl,
|
||||
|
|
@ -80,7 +79,7 @@ export const TEAMS_MEETINGS_PLATFORM_ADAPTER = MeetingPlatformAdapter.create<
|
|||
? "Microsoft Teams guest in Chrome on a paired node"
|
||||
: "Microsoft Teams guest in the OpenClaw Chrome profile",
|
||||
},
|
||||
nodeCommandName: TEAMS_MEETINGS_NODE_COMMAND,
|
||||
nodeCommandName: "teamsmeetings.chrome",
|
||||
nodeConfigPath: "plugins.entries.teams-meetings.config.chromeNode.node",
|
||||
urls: {
|
||||
validateAndNormalize: normalizeTeamsMeetingUrl,
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export const TEAMS_MEETINGS_NODE_COMMAND = "teamsmeetings.chrome";
|
||||
|
||||
export const TEAMS_MEETINGS_BROWSER_NODE_ADAPTER = {
|
||||
displayName: "Microsoft Teams meetings",
|
||||
nodeCommandName: TEAMS_MEETINGS_NODE_COMMAND,
|
||||
nodeConfigPath: "plugins.entries.teams-meetings.config.chromeNode.node",
|
||||
};
|
||||
|
|
@ -117,6 +117,11 @@ export function teamsMeetingStatusPreludeSource(params: MeetingStatusPreludePara
|
|||
}
|
||||
const microphone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
let microphoneState = identityVerified ? toggleState(microphone, "microphone") : undefined;
|
||||
const refreshMicrophoneState = async () => {
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
};
|
||||
const camera = first(selectors.camera) || findTextButton(/camera|video/i);
|
||||
let cameraState = identityVerified ? toggleState(camera, "camera") : undefined;
|
||||
let controlManualAction;
|
||||
|
|
@ -202,16 +207,12 @@ export function teamsMeetingStatusPreludeSource(params: MeetingStatusPreludePara
|
|||
if (!audioInputRouted) {
|
||||
if (canMutateSession && microphoneState === "on") {
|
||||
microphone.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
}
|
||||
controlManualAction = manualActionFor("teams-audio-choice-required", "Select the OpenClaw virtual audio device as the Teams microphone and verify it is selected before enabling talk-back.");
|
||||
} else if (canMutateSession && microphoneState === "off") {
|
||||
microphone.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
if (microphoneState === "on") {
|
||||
notes.push("Unmuted the Teams microphone after verifying the virtual audio input.");
|
||||
}
|
||||
|
|
@ -221,9 +222,7 @@ export function teamsMeetingStatusPreludeSource(params: MeetingStatusPreludePara
|
|||
}
|
||||
} else if (canMutateSession && identityVerified && !inCall && !allowMicrophone && microphoneState === "on") {
|
||||
microphone.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
if (microphoneState === "off") {
|
||||
notes.push("Muted the Teams microphone for observe-only mode.");
|
||||
}
|
||||
|
|
@ -231,21 +230,15 @@ export function teamsMeetingStatusPreludeSource(params: MeetingStatusPreludePara
|
|||
if (identityVerified && inCall && allowMicrophone) {
|
||||
if (!selectedMicrophoneLabel() && canMutateSession && microphoneState === "on") {
|
||||
microphone?.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
}
|
||||
audioInputRouted = await ensureVirtualAudioInput();
|
||||
if (audioInputRouted && canMutateSession && microphoneState === "off") {
|
||||
microphone?.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
} else if (!audioInputRouted && canMutateSession && microphoneState === "on") {
|
||||
microphone?.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
if (microphoneState === "off") {
|
||||
notes.push("Muted the Teams microphone because the virtual audio input could not be reverified.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import type { MeetingBrowserCandidateTab } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
|
||||
type TeamsMeetingIdentity = { kind: "work"; key: string } | { kind: "consumer"; key: string };
|
||||
|
||||
function parseTeamsMeetingIdentity(url: string | undefined): TeamsMeetingIdentity | undefined {
|
||||
export function normalizeTeamsMeetingUrlForReuse(url: string | undefined): string | undefined {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -21,7 +19,7 @@ function parseTeamsMeetingIdentity(url: string | undefined): TeamsMeetingIdentit
|
|||
if (!/^19:[^/]+@thread\.(?:v2|tacv2)$/i.test(threadId)) {
|
||||
return undefined;
|
||||
}
|
||||
return { kind: "work", key: threadId };
|
||||
return `teams-work:${threadId}`;
|
||||
}
|
||||
if (hostname === "teams.live.com") {
|
||||
const launcherTarget =
|
||||
|
|
@ -62,10 +60,7 @@ function parseTeamsMeetingIdentity(url: string | undefined): TeamsMeetingIdentit
|
|||
: typeof lightMeeting?.passcode === "string"
|
||||
? lightMeeting.passcode
|
||||
: parsed.searchParams.get("p");
|
||||
return {
|
||||
kind: "consumer",
|
||||
key: `${meetCode.toLowerCase()}:p:${encodeURIComponent(passcode ?? "")}`,
|
||||
};
|
||||
return `teams-consumer:${meetCode.toLowerCase()}:p:${encodeURIComponent(passcode ?? "")}`;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
|
|
@ -78,7 +73,7 @@ export function normalizeTeamsMeetingUrl(input: unknown): string {
|
|||
throw new Error("Microsoft Teams meeting URL is required");
|
||||
}
|
||||
const value = input.trim();
|
||||
if (!parseTeamsMeetingIdentity(value)) {
|
||||
if (!normalizeTeamsMeetingUrlForReuse(value)) {
|
||||
throw new Error(
|
||||
"Microsoft Teams meeting URL must use https://teams.microsoft.com/l/meetup-join/... or https://teams.live.com/meet/<id>",
|
||||
);
|
||||
|
|
@ -88,11 +83,6 @@ export function normalizeTeamsMeetingUrl(input: unknown): string {
|
|||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function normalizeTeamsMeetingUrlForReuse(url: string | undefined): string | undefined {
|
||||
const identity = parseTeamsMeetingIdentity(url);
|
||||
return identity ? `teams-${identity.kind}:${identity.key}` : undefined;
|
||||
}
|
||||
|
||||
export function isSameTeamsMeetingUrl(
|
||||
left: string | undefined,
|
||||
right: string | undefined,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ describe("twitchMessageActions", () => {
|
|||
expected: { to: "#channel", message: "hello" },
|
||||
},
|
||||
{ args: { to: "#channel", message: "---" }, expected: { to: "#channel", message: "---" } },
|
||||
{ args: { to: 123, message: false }, expected: { to: "123", message: "false" } },
|
||||
{ args: { to: "#channel", message: 0 }, expected: { to: "#channel", message: "0" } },
|
||||
{ args: { to: "#channel", message: {} }, expected: null },
|
||||
{ args: { to: "#channel", message: 1n }, expected: null },
|
||||
{ args: { message: "hello" }, expected: null },
|
||||
{ args: { to: "#channel", message: " " }, expected: null },
|
||||
])("extracts send intent from $args", ({ args, expected }) => {
|
||||
|
|
|
|||
|
|
@ -1,91 +1,22 @@
|
|||
/**
|
||||
* Twitch message actions adapter.
|
||||
*
|
||||
* Handles tool-based actions for Twitch, such as sending messages.
|
||||
*/
|
||||
|
||||
import type { ChannelMessageActionAdapter } from "./types.js";
|
||||
|
||||
/**
|
||||
* Read a string parameter from action arguments.
|
||||
*
|
||||
* @param args - Action arguments
|
||||
* @param key - Parameter key
|
||||
* @param options - Options for reading the parameter
|
||||
* @returns The parameter value or undefined if not found
|
||||
*/
|
||||
function readStringParam(
|
||||
args: Record<string, unknown>,
|
||||
key: string,
|
||||
options: { required?: boolean; trim?: boolean } = {},
|
||||
): string | undefined {
|
||||
const value = args[key];
|
||||
if (value === undefined || value === null) {
|
||||
if (options.required) {
|
||||
throw new Error(`Missing required parameter: ${key}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Convert value to string safely
|
||||
if (typeof value === "string") {
|
||||
return options.trim !== false ? value.trim() : value;
|
||||
}
|
||||
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
const str = String(value);
|
||||
return options.trim !== false ? str.trim() : str;
|
||||
}
|
||||
|
||||
throw new Error(`Parameter ${key} must be a string, number, or boolean`);
|
||||
function readSendValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
|
||||
? String(value).trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** Supported Twitch actions */
|
||||
const TWITCH_ACTIONS = new Set(["send" as const]);
|
||||
type TwitchAction = typeof TWITCH_ACTIONS extends Set<infer U> ? U : never;
|
||||
|
||||
/**
|
||||
* Twitch message actions adapter.
|
||||
*/
|
||||
export const twitchMessageActions: ChannelMessageActionAdapter = {
|
||||
/**
|
||||
* List available actions for this channel.
|
||||
*/
|
||||
describeMessageTool: () => ({ actions: [...TWITCH_ACTIONS] }),
|
||||
|
||||
/**
|
||||
* Check if an action is supported.
|
||||
*/
|
||||
supportsAction: ({ action }) => TWITCH_ACTIONS.has(action as TwitchAction),
|
||||
|
||||
/**
|
||||
* Extract tool send parameters from action arguments.
|
||||
*
|
||||
* Parses and validates the "to" and "message" parameters for sending.
|
||||
*
|
||||
* @param params - Arguments from the tool call
|
||||
* @returns Parsed send parameters or null if invalid
|
||||
*
|
||||
* @example
|
||||
* const result = twitchMessageActions.extractToolSend!({
|
||||
* args: { to: "#mychannel", message: "Hello!" }
|
||||
* });
|
||||
* // Returns: { to: "#mychannel", message: "Hello!" }
|
||||
*/
|
||||
describeMessageTool: () => ({ actions: ["send"] }),
|
||||
supportsAction: ({ action }) => action === "send",
|
||||
extractToolSend: ({ args }) => {
|
||||
try {
|
||||
const to = readStringParam(args, "to", { required: true });
|
||||
const message = readStringParam(args, "message", { required: true });
|
||||
|
||||
if (!to || !message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { to, message };
|
||||
const to = readSendValue(args.to);
|
||||
const message = readSendValue(args.message);
|
||||
return to && message ? { to, message } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Core owns send execution so receipts, queue settlement, and mirrors agree.
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,40 +1,13 @@
|
|||
/**
|
||||
* Client manager registry for Twitch plugin.
|
||||
*
|
||||
* Manages the lifecycle of TwitchClientManager instances across the plugin,
|
||||
* ensuring proper cleanup when accounts are stopped or reconfigured.
|
||||
*/
|
||||
|
||||
import { TwitchClientManager } from "./twitch-client.js";
|
||||
import type { ChannelAccountSnapshot, ChannelLogSink } from "./types.js";
|
||||
|
||||
/**
|
||||
* Registry entry tracking a client manager and its associated account.
|
||||
*/
|
||||
type RegistryEntry = {
|
||||
/** The client manager instance */
|
||||
manager: TwitchClientManager;
|
||||
/** The account ID this manager is for */
|
||||
accountId: string;
|
||||
/** Logger for this entry */
|
||||
logger: ChannelLogSink;
|
||||
/** When this entry was created */
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Global registry of client managers.
|
||||
* Keyed by account ID.
|
||||
*/
|
||||
const registry = new Map<string, RegistryEntry>();
|
||||
|
||||
/**
|
||||
* Get or create a client manager for an account.
|
||||
*
|
||||
* @param accountId - The account ID
|
||||
* @param logger - Logger instance
|
||||
* @returns The client manager
|
||||
*/
|
||||
export function getOrCreateClientManager(
|
||||
accountId: string,
|
||||
logger: ChannelLogSink,
|
||||
|
|
@ -47,33 +20,16 @@ export function getOrCreateClientManager(
|
|||
}
|
||||
|
||||
const manager = new TwitchClientManager(logger, statusSink);
|
||||
registry.set(accountId, {
|
||||
manager,
|
||||
accountId,
|
||||
logger,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
registry.set(accountId, { manager, logger });
|
||||
|
||||
logger.info(`Registered client manager for account: ${accountId}`);
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an existing client manager for an account.
|
||||
*
|
||||
* @param accountId - The account ID
|
||||
* @returns The client manager, or undefined if not registered
|
||||
*/
|
||||
export function getClientManager(accountId: string): TwitchClientManager | undefined {
|
||||
return registry.get(accountId)?.manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and remove a client manager from the registry.
|
||||
*
|
||||
* @param accountId - The account ID
|
||||
* @returns Promise that resolves when cleanup is complete
|
||||
*/
|
||||
export async function removeClientManager(accountId: string): Promise<void> {
|
||||
const entry = registry.get(accountId);
|
||||
if (!entry) {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,23 @@
|
|||
// Twitch helper module supports config schema behavior.
|
||||
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Twitch user roles that can be allowed to interact with the bot
|
||||
*/
|
||||
export const TwitchRoleSchema = z.enum(["moderator", "owner", "vip", "subscriber", "all"]);
|
||||
|
||||
const TwitchAccountShape = {
|
||||
/** Twitch username */
|
||||
username: z.string(),
|
||||
/** Twitch OAuth access token (requires chat:read and chat:write scopes) */
|
||||
accessToken: z.string(),
|
||||
/** Twitch client ID (from Twitch Developer Portal or twitchtokengenerator.com) */
|
||||
clientId: z.string().optional(),
|
||||
/** Channel name to join */
|
||||
channel: z.string().min(1),
|
||||
/** Enable this account */
|
||||
enabled: z.boolean().optional(),
|
||||
/** Allow channel-initiated configuration writes */
|
||||
configWrites: z.boolean().optional(),
|
||||
/** Allowlist of Twitch user IDs who can interact with the bot (use IDs for safety, not usernames) */
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
/** Roles allowed to interact with the bot (e.g., ["moderator", "vip", "subscriber"]) */
|
||||
allowedRoles: z.array(TwitchRoleSchema).optional(),
|
||||
/** Require @mention to trigger bot responses */
|
||||
requireMention: z.boolean().optional(),
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix: z.string().optional(),
|
||||
/** Twitch client secret (required for token refresh via RefreshingAuthProvider) */
|
||||
clientSecret: z.string().optional(),
|
||||
|
|
@ -38,14 +29,8 @@ const TwitchAccountShape = {
|
|||
obtainmentTimestamp: z.number().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Twitch account configuration schema
|
||||
*/
|
||||
export const TwitchAccountSchema = z.object(TwitchAccountShape);
|
||||
|
||||
/**
|
||||
* Base configuration properties shared by both single and multi-account modes
|
||||
*/
|
||||
const TwitchConfigBaseShape = {
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
|
|
@ -58,39 +43,20 @@ const TwitchConfigBaseShape = {
|
|||
responsePrefix: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Simplified single-account configuration schema
|
||||
*
|
||||
* Use this for single-account setups. Properties are at the top level,
|
||||
* creating an implicit "default" account.
|
||||
*/
|
||||
// Top-level credentials create the implicit default account.
|
||||
const SimplifiedSchema = z.object({
|
||||
...TwitchConfigBaseShape,
|
||||
...TwitchAccountShape,
|
||||
});
|
||||
|
||||
/**
|
||||
* Multi-account configuration schema
|
||||
*
|
||||
* Use this for multi-account setups. Each key is an account ID (e.g., "default", "secondary").
|
||||
*/
|
||||
const MultiAccountSchema = z
|
||||
.object({
|
||||
...TwitchConfigBaseShape,
|
||||
/** Per-account configuration (for multi-account setups) */
|
||||
accounts: z.record(z.string(), TwitchAccountSchema),
|
||||
})
|
||||
.refine((val) => Object.keys(val.accounts || {}).length > 0, {
|
||||
.refine((val) => Object.keys(val.accounts).length > 0, {
|
||||
message: "accounts must contain at least one entry",
|
||||
});
|
||||
|
||||
/**
|
||||
* Twitch plugin configuration schema
|
||||
*
|
||||
* Supports two mutually exclusive patterns:
|
||||
* 1. Simplified single-account: username, accessToken, clientId, channel at top level
|
||||
* 2. Multi-account: accounts object with named account configs
|
||||
*
|
||||
* The union ensures clear discrimination between the two modes.
|
||||
*/
|
||||
// Top-level credentials and named accounts remain separate config shapes.
|
||||
export const TwitchConfigSchema = z.union([SimplifiedSchema, MultiAccountSchema]);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/**
|
||||
* Twitch message monitor - processes incoming messages and routes to agents.
|
||||
*
|
||||
* This monitor connects to the Twitch client manager, processes incoming messages,
|
||||
* resolves agent routes, and handles replies.
|
||||
*/
|
||||
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChannelInboundEnvelopeBuilder } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
|
@ -28,7 +21,7 @@ type TwitchMonitorOptions = {
|
|||
account: TwitchAccountConfig;
|
||||
accountId: string;
|
||||
channelRuntime: ReturnType<typeof getTwitchRuntime>["channel"];
|
||||
config: unknown; // OpenClawConfig
|
||||
config: OpenClawConfig;
|
||||
runtime: TwitchRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
statusSink?: (patch: Omit<ChannelAccountSnapshot, "accountId">) => void;
|
||||
|
|
@ -40,14 +33,11 @@ type TwitchMonitorResult = {
|
|||
|
||||
type TwitchIngressLifecycle = Parameters<Parameters<typeof createTwitchIngress>[0]["deliver"]>[1];
|
||||
|
||||
/**
|
||||
* Process an incoming Twitch message and dispatch to agent.
|
||||
*/
|
||||
async function processTwitchMessage(params: {
|
||||
message: TwitchChatMessage;
|
||||
account: TwitchAccountConfig;
|
||||
accountId: string;
|
||||
config: unknown;
|
||||
config: OpenClawConfig;
|
||||
runtime: TwitchRuntimeEnv;
|
||||
channelRuntime: TwitchMonitorOptions["channelRuntime"];
|
||||
turnAdoptionLifecycle: TwitchIngressLifecycle;
|
||||
|
|
@ -63,7 +53,7 @@ async function processTwitchMessage(params: {
|
|||
turnAdoptionLifecycle,
|
||||
statusSink,
|
||||
} = params;
|
||||
const cfg = config as OpenClawConfig;
|
||||
const cfg = config;
|
||||
const route = channelRuntime.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "twitch",
|
||||
|
|
@ -186,15 +176,12 @@ async function processTwitchMessage(params: {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a reply to Twitch chat.
|
||||
*/
|
||||
async function deliverTwitchReply(params: {
|
||||
payload: ReplyPayload;
|
||||
channel: string;
|
||||
account: TwitchAccountConfig;
|
||||
accountId: string;
|
||||
config: unknown;
|
||||
config: OpenClawConfig;
|
||||
runtime: TwitchRuntimeEnv;
|
||||
}): Promise<{ visibleReplySent: boolean }> {
|
||||
const { payload, channel, account, accountId, config, runtime } = params;
|
||||
|
|
@ -210,7 +197,7 @@ async function deliverTwitchReply(params: {
|
|||
const result = await sendMessageTwitchInternal({
|
||||
channel,
|
||||
text: [payload.text, ...resolveOutboundMediaUrls(payload)].filter(Boolean).join(" "),
|
||||
cfg: config as OpenClawConfig,
|
||||
cfg: config,
|
||||
account,
|
||||
accountId,
|
||||
clientManager,
|
||||
|
|
@ -226,11 +213,6 @@ async function deliverTwitchReply(params: {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main monitor provider for Twitch.
|
||||
*
|
||||
* Sets up message handlers and processes incoming messages.
|
||||
*/
|
||||
export async function monitorTwitchProvider(
|
||||
options: TwitchMonitorOptions,
|
||||
): Promise<TwitchMonitorResult> {
|
||||
|
|
@ -257,11 +239,7 @@ export async function monitorTwitchProvider(
|
|||
const clientManager = getOrCreateClientManager(accountId, logger, statusSink);
|
||||
|
||||
try {
|
||||
await clientManager.getClient(
|
||||
account,
|
||||
config as Parameters<typeof clientManager.getClient>[1],
|
||||
accountId,
|
||||
);
|
||||
await clientManager.getClient(account, config, accountId);
|
||||
} catch (error) {
|
||||
const errorMsg = formatErrorMessage(error);
|
||||
runtime.error?.(`Failed to connect: ${errorMsg}`);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/**
|
||||
* Twitch outbound adapter for sending messages.
|
||||
*
|
||||
* Implements the ChannelOutboundAdapter interface for Twitch chat.
|
||||
* Supports text and media (URL) sending with markdown stripping and chunking.
|
||||
*/
|
||||
|
||||
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
|
|
@ -17,16 +10,9 @@ import type {
|
|||
ChannelOutboundContext,
|
||||
OutboundDeliveryResult,
|
||||
} from "./types.js";
|
||||
import { missingTargetError, normalizeTwitchChannel } from "./utils/twitch.js";
|
||||
import { normalizeTwitchChannel } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Twitch outbound adapter.
|
||||
*
|
||||
* Handles sending text and media to Twitch channels with automatic
|
||||
* markdown stripping and message chunking.
|
||||
*/
|
||||
export const twitchOutbound: ChannelOutboundAdapter = {
|
||||
/** Direct delivery mode - messages are sent immediately */
|
||||
export const twitchOutbound = {
|
||||
deliveryMode: "direct",
|
||||
|
||||
deliveryCapabilities: {
|
||||
|
|
@ -41,18 +27,8 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
|||
// A core chunker would reparse literal Markdown and could erase visible text.
|
||||
textChunkLimit: TWITCH_CHAT_MESSAGE_LIMIT,
|
||||
|
||||
/** Strip internal assistant tool-trace scaffolding before delivery */
|
||||
sanitizeText: ({ text }) => sanitizeAssistantVisibleText(text),
|
||||
|
||||
/**
|
||||
* Resolve target from context.
|
||||
*
|
||||
* Handles target resolution with allowlist support for implicit/heartbeat modes.
|
||||
* For explicit mode, accepts any valid channel name.
|
||||
*
|
||||
* @param params - Resolution parameters
|
||||
* @returns Resolved target or error
|
||||
*/
|
||||
resolveTarget: ({ to, allowFrom, mode }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
const allowListRaw = normalizeStringEntries(allowFrom ?? []);
|
||||
|
|
@ -62,60 +38,20 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
|||
.map((entry: string) => normalizeTwitchChannel(entry))
|
||||
.filter((entry): entry is string => entry.length > 0);
|
||||
|
||||
// If target is provided, normalize and validate it
|
||||
if (trimmed) {
|
||||
const normalizedTo = normalizeTwitchChannel(trimmed);
|
||||
if (!normalizedTo) {
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Twitch", "<channel-name>"),
|
||||
};
|
||||
}
|
||||
|
||||
// For implicit/heartbeat modes with allowList, check against allowlist
|
||||
if (mode === "implicit" || mode === "heartbeat") {
|
||||
if (hasWildcard || allowList.length === 0) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
if (allowList.includes(normalizedTo)) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Twitch", "<channel-name>"),
|
||||
};
|
||||
}
|
||||
|
||||
// For explicit mode, accept any valid channel name
|
||||
const normalizedTo = normalizeTwitchChannel(trimmed);
|
||||
const restricted = mode === "implicit" || mode === "heartbeat";
|
||||
if (
|
||||
normalizedTo &&
|
||||
(!restricted || hasWildcard || allowList.length === 0 || allowList.includes(normalizedTo))
|
||||
) {
|
||||
return { ok: true, to: normalizedTo };
|
||||
}
|
||||
|
||||
// No target provided - error
|
||||
|
||||
// No target and no allowFrom - error
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Twitch", "<channel-name>"),
|
||||
error: new Error("Delivering to Twitch requires target <channel-name>"),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a text message to a Twitch channel.
|
||||
*
|
||||
* Strips Markdown, validates account configuration,
|
||||
* and sends the message via the Twitch client.
|
||||
*
|
||||
* @param params - Send parameters including target, text, and config
|
||||
* @returns Delivery result with message ID and status
|
||||
*
|
||||
* @example
|
||||
* const result = await twitchOutbound.sendText({
|
||||
* cfg: openclawConfig,
|
||||
* to: "#mychannel",
|
||||
* text: "Hello Twitch!",
|
||||
* accountId: "default",
|
||||
* });
|
||||
*/
|
||||
sendText: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||
const { cfg, to, text, accountId } = params;
|
||||
const signal = (params as { signal?: AbortSignal }).signal;
|
||||
|
|
@ -171,24 +107,6 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
|||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Send media to a Twitch channel.
|
||||
*
|
||||
* Note: Twitch chat doesn't support direct media uploads.
|
||||
* This sends the media URL as text instead.
|
||||
*
|
||||
* @param params - Send parameters including media URL
|
||||
* @returns Delivery result with message ID and status
|
||||
*
|
||||
* @example
|
||||
* const result = await twitchOutbound.sendMedia({
|
||||
* cfg: openclawConfig,
|
||||
* to: "#mychannel",
|
||||
* text: "Check this out!",
|
||||
* mediaUrl: "https://example.com/image.png",
|
||||
* accountId: "default",
|
||||
* });
|
||||
*/
|
||||
sendMedia: async (params: ChannelOutboundContext): Promise<OutboundDeliveryResult> => {
|
||||
const { text, mediaUrl } = params;
|
||||
const signal = (params as { signal?: AbortSignal }).signal;
|
||||
|
|
@ -199,15 +117,12 @@ export const twitchOutbound: ChannelOutboundAdapter = {
|
|||
|
||||
const message = mediaUrl ? `${text || ""} ${mediaUrl}`.trim() : text;
|
||||
|
||||
if (!twitchOutbound.sendText) {
|
||||
throw new Error("sendText not implemented");
|
||||
}
|
||||
return twitchOutbound.sendText({
|
||||
...params,
|
||||
text: message,
|
||||
});
|
||||
},
|
||||
};
|
||||
} satisfies ChannelOutboundAdapter;
|
||||
|
||||
export const twitchMessageAdapter = createChannelMessageAdapterFromOutbound({
|
||||
id: "twitch",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/**
|
||||
* Twitch channel plugin for OpenClaw.
|
||||
*
|
||||
* Main plugin export combining all adapters (outbound, actions, status, gateway).
|
||||
* This is the primary entry point for the Twitch channel integration.
|
||||
*/
|
||||
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import {
|
||||
|
|
@ -64,13 +57,6 @@ function normalizeTwitchMessagingTarget(target: string): string {
|
|||
return normalizeTwitchChannel(channelTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Twitch channel plugin.
|
||||
*
|
||||
* Implements the ChannelPlugin interface to provide Twitch chat integration
|
||||
* for OpenClaw. Supports message sending, receiving, access control, and
|
||||
* status monitoring.
|
||||
*/
|
||||
export const twitchPlugin: ChannelPlugin<ResolvedTwitchAccount> =
|
||||
createChatChannelPlugin<ResolvedTwitchAccount>({
|
||||
pairing: {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
/**
|
||||
* Twitch resolver adapter for channel/user name resolution.
|
||||
*
|
||||
* This module implements the ChannelResolverAdapter interface to resolve
|
||||
* Twitch usernames to user IDs via the Twitch Helix API.
|
||||
*/
|
||||
|
||||
import {
|
||||
callTwitchApi,
|
||||
HttpStatusCodeError,
|
||||
|
|
@ -37,9 +30,6 @@ type TwitchUsersResponse = {
|
|||
data: TwitchUser[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a Twitch username - strip @ prefix and convert to lowercase
|
||||
*/
|
||||
function normalizeUsername(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.startsWith("@")) {
|
||||
|
|
@ -48,18 +38,6 @@ function normalizeUsername(input: string): string {
|
|||
return normalizeLowercaseStringOrEmpty(trimmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger that includes the Twitch prefix
|
||||
*/
|
||||
function createLogger(logger?: ChannelLogSink): ChannelLogSink {
|
||||
return {
|
||||
info: (msg: string) => logger?.info(msg),
|
||||
warn: (msg: string) => logger?.warn(msg),
|
||||
error: (msg: string) => logger?.error(msg),
|
||||
debug: (msg: string) => logger?.debug?.(msg) ?? (() => {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createHelixUserResolver(clientId: string, accessToken: string) {
|
||||
let tokenValidated = false;
|
||||
|
||||
|
|
@ -113,25 +91,14 @@ function createHelixUserResolver(clientId: string, accessToken: string) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Twitch usernames to user IDs via the Helix API
|
||||
*
|
||||
* @param inputs - Array of usernames or user IDs to resolve
|
||||
* @param account - Twitch account configuration with auth credentials
|
||||
* @param kind - Type of target to resolve ("user" or "group")
|
||||
* @param logger - Optional logger
|
||||
* @returns Promise resolving to array of ChannelResolveResult
|
||||
*/
|
||||
export async function resolveTwitchTargets(
|
||||
inputs: string[],
|
||||
account: TwitchAccountConfig,
|
||||
_kind: ChannelResolveKind,
|
||||
logger?: ChannelLogSink,
|
||||
log?: ChannelLogSink,
|
||||
): Promise<ChannelResolveResult[]> {
|
||||
const log = createLogger(logger);
|
||||
|
||||
if (!account.clientId || !account.accessToken) {
|
||||
log.error("Missing Twitch client ID or accessToken");
|
||||
log?.error("Missing Twitch client ID or accessToken");
|
||||
return inputs.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
|
|
@ -160,45 +127,34 @@ export async function resolveTwitchTargets(
|
|||
const looksLikeUserId = /^\d+$/.test(normalized);
|
||||
|
||||
try {
|
||||
if (looksLikeUserId) {
|
||||
const user = await resolveHelixUser({ id: normalized });
|
||||
|
||||
if (user) {
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
id: user.id,
|
||||
name: user.login,
|
||||
});
|
||||
log.debug?.(`Resolved user ID ${normalized} -> ${user.login}`);
|
||||
} else {
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "user ID not found",
|
||||
});
|
||||
log.warn(`User ID ${normalized} not found`);
|
||||
}
|
||||
const user = await resolveHelixUser(
|
||||
looksLikeUserId ? { id: normalized } : { login: normalized },
|
||||
);
|
||||
if (user) {
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
id: user.id,
|
||||
name: user.login,
|
||||
...(!looksLikeUserId
|
||||
? {
|
||||
note:
|
||||
user.display_name !== user.login ? `display: ${user.display_name}` : undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
log?.debug?.(
|
||||
looksLikeUserId
|
||||
? `Resolved user ID ${normalized} -> ${user.login}`
|
||||
: `Resolved username ${normalized} -> ${user.id} (${user.login})`,
|
||||
);
|
||||
} else {
|
||||
const user = await resolveHelixUser({ login: normalized });
|
||||
|
||||
if (user) {
|
||||
results.push({
|
||||
input,
|
||||
resolved: true,
|
||||
id: user.id,
|
||||
name: user.login,
|
||||
note: user.display_name !== user.login ? `display: ${user.display_name}` : undefined,
|
||||
});
|
||||
log.debug?.(`Resolved username ${normalized} -> ${user.id} (${user.login})`);
|
||||
} else {
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
note: "username not found",
|
||||
});
|
||||
log.warn(`Username ${normalized} not found`);
|
||||
}
|
||||
results.push({
|
||||
input,
|
||||
resolved: false,
|
||||
note: looksLikeUserId ? "user ID not found" : "username not found",
|
||||
});
|
||||
log?.warn(`${looksLikeUserId ? "User ID" : "Username"} ${normalized} not found`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = formatErrorMessage(error);
|
||||
|
|
@ -207,7 +163,7 @@ export async function resolveTwitchTargets(
|
|||
resolved: false,
|
||||
note: `API error: ${errorMessage}`,
|
||||
});
|
||||
log.error(`Failed to resolve ${input}: ${errorMessage}`);
|
||||
log?.error(`Failed to resolve ${input}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +1,9 @@
|
|||
/**
|
||||
* Twitch status issues collector.
|
||||
*
|
||||
* Detects and reports configuration issues for Twitch accounts.
|
||||
*/
|
||||
|
||||
import type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { getAccountConfig } from "./config.js";
|
||||
import { resolveTwitchToken } from "./token.js";
|
||||
import type { ChannelAccountSnapshot } from "./types.js";
|
||||
import { isAccountConfigured } from "./utils/twitch.js";
|
||||
|
||||
/**
|
||||
* Collect status issues for Twitch accounts.
|
||||
*
|
||||
* Analyzes account snapshots and detects configuration problems,
|
||||
* authentication issues, and other potential problems.
|
||||
*
|
||||
* @param accounts - Array of account snapshots to analyze
|
||||
* @param getCfg - Optional function to get full config for additional checks
|
||||
* @returns Array of detected status issues
|
||||
*
|
||||
* @example
|
||||
* const issues = collectTwitchStatusIssues(accountSnapshots);
|
||||
* if (issues.length > 0) {
|
||||
* console.warn("Twitch configuration issues detected:");
|
||||
* issues.forEach(issue => console.warn(`- ${issue.message}`));
|
||||
* }
|
||||
*/
|
||||
export function collectTwitchStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
getCfg?: () => unknown,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Twitch plugin module implements twitch client behavior.
|
||||
import { RefreshingAuthProvider, StaticAuthProvider } from "@twurple/auth";
|
||||
import { ChatClient, LogLevel } from "@twurple/chat";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-resolution";
|
||||
|
|
@ -19,9 +18,6 @@ import { normalizeToken } from "./utils/twitch.js";
|
|||
|
||||
const TWITCH_CHAT_AUTH_INTENTS = ["chat"];
|
||||
|
||||
/**
|
||||
* Manages Twitch chat client connections
|
||||
*/
|
||||
export class TwitchClientManager {
|
||||
private clients = new Map<string, ChatClient>();
|
||||
private pendingClients = new Map<string, ChatClient>();
|
||||
|
|
@ -48,9 +44,6 @@ export class TwitchClientManager {
|
|||
this.statusSink?.({ connected: false, lifecycle: "recovering", lastError });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an auth provider for the account.
|
||||
*/
|
||||
private async createAuthProvider(
|
||||
account: TwitchAccountConfig,
|
||||
normalizedToken: string,
|
||||
|
|
@ -107,9 +100,6 @@ export class TwitchClientManager {
|
|||
return new StaticAuthProvider(account.clientId, normalizedToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a chat client for an account
|
||||
*/
|
||||
async getClient(
|
||||
account: TwitchAccountConfig,
|
||||
cfg?: OpenClawConfig,
|
||||
|
|
@ -307,13 +297,9 @@ export class TwitchClientManager {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up message and event handlers for a client
|
||||
*/
|
||||
private setupClientHandlers(client: ChatClient, account: TwitchAccountConfig): void {
|
||||
const key = this.getAccountKey(account);
|
||||
|
||||
// Handle incoming messages
|
||||
client.onMessage((channelName, _user, messageText, msg) => {
|
||||
const handler = this.messageHandlers.get(key);
|
||||
if (handler) {
|
||||
|
|
@ -353,10 +339,6 @@ export class TwitchClientManager {
|
|||
this.logger.info(`Set up handlers for ${key}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a message handler for an account
|
||||
* @returns A function that removes the handler when called
|
||||
*/
|
||||
onMessage(
|
||||
account: TwitchAccountConfig,
|
||||
handler: (message: TwitchChatMessage) => void,
|
||||
|
|
@ -380,9 +362,6 @@ export class TwitchClientManager {
|
|||
this.messageHandlerTokens.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a client
|
||||
*/
|
||||
async disconnect(account: TwitchAccountConfig): Promise<void> {
|
||||
const key = this.getAccountKey(account);
|
||||
const client = this.clients.get(key);
|
||||
|
|
@ -405,9 +384,6 @@ export class TwitchClientManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all clients
|
||||
*/
|
||||
async disconnectAll(): Promise<void> {
|
||||
this.pendingClients.forEach((client) => client.quit());
|
||||
this.clients.forEach((client) => client.quit());
|
||||
|
|
@ -419,9 +395,6 @@ export class TwitchClientManager {
|
|||
this.logger.info(" Disconnected all clients");
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a channel
|
||||
*/
|
||||
async sendMessage(
|
||||
account: TwitchAccountConfig,
|
||||
channel: string,
|
||||
|
|
@ -432,7 +405,7 @@ export class TwitchClientManager {
|
|||
try {
|
||||
const client = await this.getClient(account, cfg, accountId);
|
||||
|
||||
// Generate a message ID (Twurple's say() doesn't return the message ID, so we generate one)
|
||||
// Twurple say() does not return a provider message ID.
|
||||
const messageId = crypto.randomUUID();
|
||||
|
||||
// Pre-chunk so Twurple's raw UTF-16 fallback cannot split surrogate pairs.
|
||||
|
|
@ -448,9 +421,6 @@ export class TwitchClientManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique key for an account
|
||||
*/
|
||||
public getAccountKey(account: TwitchAccountConfig): string {
|
||||
return `${account.username}:${account.channel}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,29 @@
|
|||
/**
|
||||
* Twitch channel plugin types.
|
||||
*
|
||||
* This file defines Twitch-specific types. Generic channel types are imported
|
||||
* from OpenClaw core.
|
||||
*/
|
||||
|
||||
import type { z } from "zod";
|
||||
import type { TwitchAccountSchema, TwitchRoleSchema } from "./config-schema.js";
|
||||
// ============================================================================
|
||||
// Twitch-Specific Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Twitch user roles that can be allowed to interact with the bot
|
||||
*/
|
||||
export type TwitchRole = z.input<typeof TwitchRoleSchema>;
|
||||
|
||||
/**
|
||||
* Account configuration for a Twitch channel
|
||||
*/
|
||||
export type TwitchAccountConfig = z.input<typeof TwitchAccountSchema>;
|
||||
|
||||
/**
|
||||
* Twitch message from chat
|
||||
*/
|
||||
export interface TwitchChatMessage {
|
||||
/** Username of sender */
|
||||
username: string;
|
||||
/** Twitch user ID of sender (unique, persistent identifier) */
|
||||
userId?: string;
|
||||
/** Message text */
|
||||
message: string;
|
||||
/** Channel name */
|
||||
channel: string;
|
||||
/** Display name (may include special characters) */
|
||||
displayName?: string;
|
||||
/** Message ID */
|
||||
id: string;
|
||||
/** Receive timestamp in milliseconds */
|
||||
timestamp?: number;
|
||||
/** Whether the sender is a moderator */
|
||||
isMod?: boolean;
|
||||
/** Whether the sender is the channel owner/broadcaster */
|
||||
isOwner?: boolean;
|
||||
/** Whether the sender is a VIP */
|
||||
isVip?: boolean;
|
||||
/** Whether the sender is a subscriber */
|
||||
isSub?: boolean;
|
||||
/** Chat type */
|
||||
chatType?: "group";
|
||||
}
|
||||
|
||||
// Re-export core types for convenience
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelLogSink,
|
||||
|
|
|
|||
|
|
@ -1,50 +1,22 @@
|
|||
// Twitch plugin module implements twitch behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
/**
|
||||
* Twitch-specific utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize Twitch channel names.
|
||||
*
|
||||
* Removes the '#' prefix if present, converts to lowercase, and trims whitespace.
|
||||
* Twitch channel names are case-insensitive and don't use the '#' prefix in the API.
|
||||
*
|
||||
* @param channel - The channel name to normalize
|
||||
* @returns Normalized channel name
|
||||
*
|
||||
* @example
|
||||
* normalizeTwitchChannel("#TwitchChannel") // "twitchchannel"
|
||||
* normalizeTwitchChannel("MyChannel") // "mychannel"
|
||||
|
||||
*/
|
||||
export function normalizeTwitchChannel(channel: string): string {
|
||||
const trimmed = normalizeLowercaseStringOrEmpty(channel);
|
||||
return trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a standardized error message for missing target.
|
||||
*
|
||||
* @param provider - The provider name (e.g., "Twitch")
|
||||
* @param hint - Optional hint for how to fix the issue
|
||||
* @returns Error object with descriptive message
|
||||
*/
|
||||
export function missingTargetError(provider: string, hint?: string): Error {
|
||||
return new Error(`Delivering to ${provider} requires target${hint ? ` ${hint}` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize OAuth token by removing the "oauth:" prefix if present.
|
||||
*
|
||||
* Twurple doesn't require the "oauth:" prefix, so we strip it for consistency.
|
||||
*
|
||||
* @param token - The OAuth token to normalize
|
||||
* @returns Normalized token without "oauth:" prefix
|
||||
*
|
||||
* @example
|
||||
* normalizeToken("oauth:abc123") // "abc123"
|
||||
* normalizeToken("abc123") // "abc123"
|
||||
|
||||
*/
|
||||
export function normalizeToken(token: string): string {
|
||||
return token.startsWith("oauth:") ? token.slice(6) : token;
|
||||
|
|
@ -52,9 +24,7 @@ export function normalizeToken(token: string): string {
|
|||
|
||||
/**
|
||||
* Check if an account is properly configured with required credentials.
|
||||
*
|
||||
* @param account - The Twitch account config to check
|
||||
* @returns true if the account has required credentials
|
||||
|
||||
*/
|
||||
export function isAccountConfigured(
|
||||
account: {
|
||||
|
|
|
|||
|
|
@ -1,88 +1 @@
|
|||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type {
|
||||
GroupPolicy,
|
||||
MarkdownTableMode,
|
||||
OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
BaseTokenResolution,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
createDedupeCache,
|
||||
formatPairingApproveHint,
|
||||
jsonResult,
|
||||
normalizeAccountId,
|
||||
readStringParam,
|
||||
resolveClientIp,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
addWildcardAllowFrom,
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
buildSingleChannelSecretPromptState,
|
||||
mergeAllowFromEntries,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
promptSingleChannelSecretInput,
|
||||
runSingleChannelSecretStep,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
export {
|
||||
buildTokenChannelStatusSummary,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
formatAllowFromLowercase,
|
||||
isNormalizedSenderAllowed,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
export {
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export {
|
||||
deliverTextOrMediaReply,
|
||||
isNumericTargetId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime";
|
||||
export {
|
||||
applyBasicWebhookRequestGuards,
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookAnomalyTracker,
|
||||
readJsonWebhookBodyOrReject,
|
||||
registerPluginHttpRoute,
|
||||
registerWebhookTarget,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
resolveWebhookPath,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export type {
|
||||
RegisterWebhookPluginRouteOptions,
|
||||
RegisterWebhookTargetOptions,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export { setZaloRuntime } from "./src/runtime.js";
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import {
|
|||
createDirectoryTestRuntime,
|
||||
expectDirectorySurface,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
describe("zalo directory", () => {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ import {
|
|||
createStartAccountContext,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setZaloRuntime, type OpenClawConfig, type PluginRuntime } from "../runtime-api.js";
|
||||
import { setZaloRuntime } from "../runtime-api.js";
|
||||
import { resolveZaloAccount } from "./accounts.js";
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
// Zalo plugin module implements channel behavior.
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { ChannelPlugin } from "../runtime-api.js";
|
||||
import { probeZalo } from "./probe.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
|
|
|
|||
|
|
@ -1,24 +1,7 @@
|
|||
// Zalo plugin module implements group access behavior.
|
||||
import type { GroupPolicy } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { resolveOpenProviderRuntimeGroupPolicy as resolveZaloRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
|
||||
const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
|
||||
|
||||
export function normalizeZaloAllowEntry(value: string): string {
|
||||
return value.trim().replace(ZALO_ALLOW_FROM_PREFIX_RE, "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveZaloRuntimeGroupPolicy(params: {
|
||||
providerConfigPresent: boolean;
|
||||
groupPolicy?: GroupPolicy;
|
||||
defaultGroupPolicy?: GroupPolicy;
|
||||
}): {
|
||||
groupPolicy: GroupPolicy;
|
||||
providerMissingFallbackApplied: boolean;
|
||||
} {
|
||||
return resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.providerConfigPresent,
|
||||
groupPolicy: params.groupPolicy,
|
||||
defaultGroupPolicy: params.defaultGroupPolicy,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,15 @@ import {
|
|||
createChannelIngressQueueForTests,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-test-runtime";
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
createRuntimeEnv,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
|
||||
const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import type { ZaloFetch } from "./api.js";
|
||||
import { setZaloRuntime } from "./runtime.js";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
// Zalo test support covers monitor.reply once.lifecycle plugin behavior.
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import {
|
||||
createLifecycleMonitorSetup,
|
||||
createTextUpdate,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
// Zalo plugin module implements monitor behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
import {
|
||||
createChannelPartialDeliveryError,
|
||||
|
|
@ -170,16 +168,11 @@ type ZaloMessagePipelineParams = ZaloProcessingContext & {
|
|||
mediaType?: string;
|
||||
authorization?: ZaloMessageAuthorizationResult;
|
||||
};
|
||||
type ZaloImageMessageParams = ZaloProcessingContext & {
|
||||
message: ZaloMessage;
|
||||
};
|
||||
type ZaloMessageAuthorizationResult = {
|
||||
channelIngress: ResolvedChannelMessageIngress;
|
||||
resolveChannelIngress: (
|
||||
contextBinding: ChannelIngressContextBinding,
|
||||
) => Promise<ResolvedChannelMessageIngress>;
|
||||
chatId: string;
|
||||
commandAuthorized: boolean | undefined;
|
||||
isGroup: boolean;
|
||||
rawBody: string;
|
||||
senderId: string;
|
||||
|
|
@ -213,45 +206,10 @@ function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: str
|
|||
}
|
||||
}
|
||||
|
||||
async function handleZaloWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const { handleZaloWebhookRequest: handleZaloWebhookRequestInternal } =
|
||||
await loadZaloWebhookModule();
|
||||
return await handleZaloWebhookRequestInternal(req, res);
|
||||
}
|
||||
|
||||
function startPollingLoop(params: ZaloPollingLoopParams) {
|
||||
const {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
mediaMaxMb,
|
||||
canHostMedia,
|
||||
webhookUrl,
|
||||
webhookPath,
|
||||
abortSignal,
|
||||
isStopped,
|
||||
statusSink,
|
||||
fetcher,
|
||||
} = params;
|
||||
const { abortSignal, isStopped, ...processingContext } = params;
|
||||
const { token, account, runtime, statusSink, fetcher } = processingContext;
|
||||
const pollTimeout = 30;
|
||||
const processingContext = {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
mediaMaxMb,
|
||||
canHostMedia,
|
||||
webhookUrl,
|
||||
webhookPath,
|
||||
statusSink,
|
||||
fetcher,
|
||||
};
|
||||
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
|
||||
|
||||
|
|
@ -298,22 +256,9 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
|
|||
}
|
||||
|
||||
async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void> {
|
||||
const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params;
|
||||
const { update, ...sharedContext } = params;
|
||||
const { account, core, runtime } = sharedContext;
|
||||
const { event_name, message } = update;
|
||||
const sharedContext = {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
mediaMaxMb,
|
||||
canHostMedia: params.canHostMedia,
|
||||
webhookUrl: params.webhookUrl,
|
||||
webhookPath: params.webhookPath,
|
||||
statusSink,
|
||||
fetcher,
|
||||
turnAdoptionLifecycle: params.turnAdoptionLifecycle,
|
||||
};
|
||||
if (!message) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -329,7 +274,6 @@ async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void>
|
|||
await handleImageMessage({
|
||||
message,
|
||||
...sharedContext,
|
||||
mediaMaxMb,
|
||||
});
|
||||
break;
|
||||
case "message.sticker.received":
|
||||
|
|
@ -357,21 +301,18 @@ async function handleTextMessage(
|
|||
await processMessageWithPipeline({
|
||||
...params,
|
||||
text,
|
||||
mediaKind: undefined,
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
|
||||
async function handleImageMessage(
|
||||
params: ZaloProcessingContext & { message: ZaloMessage },
|
||||
): Promise<void> {
|
||||
const { message, mediaMaxMb, account, core, runtime } = params;
|
||||
const { photo_url, caption } = message;
|
||||
const authorization = await authorizeZaloMessage({
|
||||
...params,
|
||||
text: caption,
|
||||
mediaKind: "image",
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
});
|
||||
if (!authorization) {
|
||||
return;
|
||||
|
|
@ -535,10 +476,8 @@ async function authorizeZaloMessage(
|
|||
}
|
||||
|
||||
return {
|
||||
channelIngress: access,
|
||||
resolveChannelIngress,
|
||||
chatId,
|
||||
commandAuthorized: access.commandAccess.requested ? access.commandAccess.authorized : undefined,
|
||||
isGroup,
|
||||
rawBody,
|
||||
senderId,
|
||||
|
|
@ -563,13 +502,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
|||
authorization: authorizationOverride,
|
||||
} = params;
|
||||
const { message_id, date } = message;
|
||||
const authorization =
|
||||
authorizationOverride ??
|
||||
(await authorizeZaloMessage({
|
||||
...params,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
}));
|
||||
const authorization = authorizationOverride ?? (await authorizeZaloMessage(params));
|
||||
if (!authorization) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -581,7 +514,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr
|
|||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? ("group" as const) : ("direct" as const),
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: chatId,
|
||||
},
|
||||
});
|
||||
|
|
@ -930,7 +863,8 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
|||
}
|
||||
|
||||
if (useWebhook) {
|
||||
const { createZaloWebhookIngress, registerZaloWebhookTarget } = await loadZaloWebhookModule();
|
||||
const { createZaloWebhookIngress, registerZaloWebhookTarget, handleZaloWebhookRequest } =
|
||||
await loadZaloWebhookModule();
|
||||
if (!effectiveWebhookUrl || !webhookSecret) {
|
||||
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
||||
}
|
||||
|
|
@ -1035,33 +969,29 @@ export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<
|
|||
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
|
||||
try {
|
||||
try {
|
||||
const currentWebhookUrl = normalizeWebhookUrl(
|
||||
(await getWebhookInfo(token, fetcher)).result?.url,
|
||||
const currentWebhookUrl = normalizeWebhookUrl(
|
||||
(await getWebhookInfo(token, fetcher)).result?.url,
|
||||
);
|
||||
if (!currentWebhookUrl) {
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
|
||||
} else {
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
|
||||
);
|
||||
if (!currentWebhookUrl) {
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
|
||||
} else {
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`,
|
||||
);
|
||||
await deleteWebhook(token, fetcher);
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ZaloApiError && err.errorCode === 404) {
|
||||
// Some Zalo environments do not expose webhook inspection for polling bots.
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
|
||||
);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
await deleteWebhook(token, fetcher);
|
||||
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
|
||||
);
|
||||
if (err instanceof ZaloApiError && err.errorCode === 404) {
|
||||
// Some Zalo environments do not expose webhook inspection for polling bots.
|
||||
runtime.log?.(
|
||||
`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`,
|
||||
);
|
||||
} else {
|
||||
runtime.error?.(
|
||||
`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
startPollingLoop({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
// Zalo tests cover monitor.webhook plugin behavior.
|
||||
import type { RequestListener } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type { ZaloRuntimeEnv } from "./monitor.types.js";
|
||||
import { zaloWebhookRuntime } from "./monitor.webhook.js";
|
||||
import type { ResolvedZaloAccount } from "./types.js";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// Zalo plugin module implements monitor.webhook behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveClientIp } from "openclaw/plugin-sdk/core";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { readWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookAnomalyTracker,
|
||||
|
|
@ -14,9 +15,8 @@ import {
|
|||
withResolvedWebhookRequestPipeline,
|
||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
resolveClientIp,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { readWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import type { ZaloRuntimeEnv } from "./monitor.types.js";
|
||||
import { ZaloWebhookPayloadError } from "./webhook-spool.js";
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import path from "node:path";
|
|||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
// Zalo tests cover outbound media plugin behavior.
|
||||
import { createPluginStateKeyedStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
|
||||
const loadWebMediaMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// Zalo plugin module implements send behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
|
|
@ -52,31 +51,6 @@ function createZaloSendReceipt(params: {
|
|||
});
|
||||
}
|
||||
|
||||
function toZaloSendResult(
|
||||
response: {
|
||||
ok?: boolean;
|
||||
result?: { message_id?: string };
|
||||
},
|
||||
params: { chatId: string; kind: MessageReceiptPartKind },
|
||||
): ZaloSendResult {
|
||||
if (response.ok && response.result) {
|
||||
return {
|
||||
ok: true,
|
||||
messageId: response.result.message_id,
|
||||
receipt: createZaloSendReceipt({
|
||||
messageId: response.result.message_id,
|
||||
chatId: params.chatId,
|
||||
kind: params.kind,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "Failed to send message",
|
||||
receipt: createZaloSendReceipt({ chatId: params.chatId, kind: params.kind }),
|
||||
};
|
||||
}
|
||||
|
||||
async function runZaloSend(
|
||||
failureMessage: string,
|
||||
params: { chatId: string; kind: MessageReceiptPartKind },
|
||||
|
|
@ -100,8 +74,12 @@ async function runZaloSend(
|
|||
}
|
||||
: undefined;
|
||||
try {
|
||||
const result = toZaloSendResult(await send(assertCurrent), params);
|
||||
return result.ok ? result : { ok: false, error: failureMessage, receipt: result.receipt };
|
||||
const response = await send(assertCurrent);
|
||||
const messageId = response.ok && response.result ? response.result.message_id : undefined;
|
||||
const receipt = createZaloSendReceipt({ ...params, messageId });
|
||||
return response.ok && response.result
|
||||
? { ok: true, messageId, receipt }
|
||||
: { ok: false, error: failureMessage, receipt };
|
||||
} catch (err) {
|
||||
if (handoffRejected && Object.is(handoffError, err)) {
|
||||
throw err;
|
||||
|
|
@ -133,53 +111,22 @@ function resolveSendContext(options: ZaloSendOptions): {
|
|||
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
function resolveValidatedSendContext(
|
||||
chatId: string,
|
||||
options: ZaloSendOptions,
|
||||
): { ok: true; chatId: string; token: string; fetcher?: ZaloFetch } | { ok: false; error: string } {
|
||||
const { token, fetcher } = resolveSendContext(options);
|
||||
if (!token) {
|
||||
return { ok: false, error: "No Zalo bot token configured" };
|
||||
}
|
||||
const trimmedChatId = normalizeZaloSendChatId(chatId);
|
||||
if (!trimmedChatId) {
|
||||
return { ok: false, error: "No chat_id provided" };
|
||||
}
|
||||
return { ok: true, chatId: trimmedChatId, token, fetcher };
|
||||
}
|
||||
|
||||
function normalizeZaloSendChatId(chatId: string): string {
|
||||
return stripTargetKindPrefix(stripChannelTargetPrefix(chatId, "zalo", "zl"));
|
||||
}
|
||||
|
||||
function resolveSendContextOrFailure(
|
||||
chatId: string,
|
||||
options: ZaloSendOptions,
|
||||
):
|
||||
| { context: { chatId: string; token: string; fetcher?: ZaloFetch } }
|
||||
| { failure: ZaloSendResult } {
|
||||
const context = resolveValidatedSendContext(chatId, options);
|
||||
return context.ok
|
||||
? { context }
|
||||
: {
|
||||
failure: {
|
||||
ok: false,
|
||||
error: context.error,
|
||||
receipt: createZaloSendReceipt({ chatId, kind: "unknown" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendMessageZalo(
|
||||
chatId: string,
|
||||
text: string,
|
||||
options: ZaloSendOptions = {},
|
||||
): Promise<ZaloSendResult> {
|
||||
const resolved = resolveSendContextOrFailure(chatId, options);
|
||||
if ("failure" in resolved) {
|
||||
return resolved.failure;
|
||||
const { token, fetcher } = resolveSendContext(options);
|
||||
const normalizedChatId = token
|
||||
? stripTargetKindPrefix(stripChannelTargetPrefix(chatId, "zalo", "zl"))
|
||||
: "";
|
||||
if (!token || !normalizedChatId) {
|
||||
return {
|
||||
ok: false,
|
||||
error: token ? "No chat_id provided" : "No Zalo bot token configured",
|
||||
receipt: createZaloSendReceipt({ chatId, kind: "unknown" }),
|
||||
};
|
||||
}
|
||||
const { context } = resolved;
|
||||
|
||||
if (options.mediaUrl && (options.mediaUrl.trim() || !text)) {
|
||||
const photoUrl = options.mediaUrl.trim();
|
||||
|
|
@ -187,23 +134,23 @@ export async function sendMessageZalo(
|
|||
return {
|
||||
ok: false,
|
||||
error: "No photo URL provided",
|
||||
receipt: createZaloSendReceipt({ chatId: context.chatId, kind: "media" }),
|
||||
receipt: createZaloSendReceipt({ chatId: normalizedChatId, kind: "media" }),
|
||||
};
|
||||
}
|
||||
const caption = text || options.caption;
|
||||
return await runZaloSend(
|
||||
"Failed to send photo",
|
||||
{ chatId: context.chatId, kind: "media" },
|
||||
{ chatId: normalizedChatId, kind: "media" },
|
||||
options.assertDirectAdapterHandoff,
|
||||
(assertCurrent) =>
|
||||
sendPhoto(
|
||||
context.token,
|
||||
token,
|
||||
{
|
||||
chat_id: context.chatId,
|
||||
chat_id: normalizedChatId,
|
||||
photo: photoUrl,
|
||||
caption,
|
||||
},
|
||||
context.fetcher,
|
||||
fetcher,
|
||||
assertCurrent,
|
||||
),
|
||||
);
|
||||
|
|
@ -211,16 +158,16 @@ export async function sendMessageZalo(
|
|||
|
||||
return await runZaloSend(
|
||||
"Failed to send message",
|
||||
{ chatId: context.chatId, kind: "text" },
|
||||
{ chatId: normalizedChatId, kind: "text" },
|
||||
options.assertDirectAdapterHandoff,
|
||||
(assertCurrent) =>
|
||||
sendMessage(
|
||||
context.token,
|
||||
token,
|
||||
{
|
||||
chat_id: context.chatId,
|
||||
chat_id: normalizedChatId,
|
||||
text: truncateUtf16Safe(text, 2000),
|
||||
},
|
||||
context.fetcher,
|
||||
fetcher,
|
||||
assertCurrent,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,10 +13,6 @@ import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
|||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
type ZaloAccountSetupConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export async function noteZaloTokenHelp(
|
||||
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
|
||||
): Promise<void> {
|
||||
|
|
@ -59,9 +55,7 @@ export async function promptZaloAllowFrom(params: {
|
|||
const normalized = entry.trim();
|
||||
const unique = mergeAllowFromEntries(existingAllowFrom, [normalized]);
|
||||
|
||||
const currentAccount = cfg.channels?.zalo?.accounts?.[accountId] as
|
||||
| ZaloAccountSetupConfig
|
||||
| undefined;
|
||||
const currentAccount = cfg.channels?.zalo?.accounts?.[accountId];
|
||||
return patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel: "zalo",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {
|
|||
createDelegatedSetupWizardProxy,
|
||||
createPatchedAccountSetupAdapter,
|
||||
createSetupInputPresenceValidator,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
patchScopedAccountConfig,
|
||||
createSetupTranslator,
|
||||
|
|
@ -68,10 +67,9 @@ export const zaloDmPolicy = createChannelDmPolicy({
|
|||
label: "Zalo",
|
||||
channel,
|
||||
resolveAccount: (cfg, accountId) => {
|
||||
const resolvedAccountId =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultZaloAccountId(cfg);
|
||||
const resolvedAccountId = accountId
|
||||
? normalizeAccountId(accountId)
|
||||
: resolveDefaultZaloAccountId(cfg);
|
||||
return resolveZaloAccount({ cfg, accountId: resolvedAccountId });
|
||||
},
|
||||
applyPatch: ({ cfg, account, patch }) =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
// Zalo tests cover setup status plugin behavior.
|
||||
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { zaloSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const zaloGetStatus = createPluginSetupWizardStatus({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Zalo tests cover setup surface plugin behavior.
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { installChannelDmPolicyContractSuite } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
|
|
@ -8,7 +9,6 @@ import {
|
|||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
||||
import { zaloDmPolicy } from "./setup-core.js";
|
||||
import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js";
|
||||
|
|
|
|||
|
|
@ -155,37 +155,16 @@ export const zaloSetupWizard: ChannelSetupWizard = {
|
|||
}
|
||||
})();
|
||||
|
||||
let webhookSecretResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "zalo-webhook",
|
||||
credentialLabel: t("wizard.zalo.webhookSecret"),
|
||||
secretInputMode: options?.secretInputMode,
|
||||
...buildSingleChannelSecretPromptState({
|
||||
accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
||||
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
||||
allowEnv: false,
|
||||
}),
|
||||
envPrompt: "",
|
||||
keepPrompt: t("wizard.zalo.webhookSecretKeep"),
|
||||
inputPrompt: t("wizard.zalo.webhookSecretInput"),
|
||||
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
||||
});
|
||||
while (
|
||||
webhookSecretResult.action === "set" &&
|
||||
typeof webhookSecretResult.value === "string" &&
|
||||
(webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
|
||||
) {
|
||||
await prompter.note(t("wizard.zalo.webhookSecretLength"), t("wizard.zalo.webhookTitle"));
|
||||
webhookSecretResult = await promptSingleChannelSecretInput({
|
||||
const promptWebhookSecret = (hasWebhookSecret: boolean) =>
|
||||
promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "zalo-webhook",
|
||||
credentialLabel: t("wizard.zalo.webhookSecret"),
|
||||
secretInputMode: options?.secretInputMode,
|
||||
...buildSingleChannelSecretPromptState({
|
||||
accountConfigured: false,
|
||||
hasConfigToken: false,
|
||||
accountConfigured: hasWebhookSecret,
|
||||
hasConfigToken: hasWebhookSecret,
|
||||
allowEnv: false,
|
||||
}),
|
||||
envPrompt: "",
|
||||
|
|
@ -193,6 +172,16 @@ export const zaloSetupWizard: ChannelSetupWizard = {
|
|||
inputPrompt: t("wizard.zalo.webhookSecretInput"),
|
||||
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
||||
});
|
||||
let webhookSecretResult = await promptWebhookSecret(
|
||||
hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
||||
);
|
||||
while (
|
||||
webhookSecretResult.action === "set" &&
|
||||
typeof webhookSecretResult.value === "string" &&
|
||||
(webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
|
||||
) {
|
||||
await prompter.note(t("wizard.zalo.webhookSecretLength"), t("wizard.zalo.webhookTitle"));
|
||||
webhookSecretResult = await promptWebhookSecret(false);
|
||||
}
|
||||
const webhookSecret =
|
||||
webhookSecretResult.action === "set"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { zoomMeetingsConfig } from "./config.js";
|
||||
import { testZoomMeetingListening } from "./runtime-probes.js";
|
||||
import { zoomMeetingsProbes } from "./runtime-probes.js";
|
||||
import type { ZoomMeetingsSession } from "./transports/types.js";
|
||||
|
||||
const URL = "https://zoom.us/j/12345678902?pwd=probe";
|
||||
type ZoomMeetingsProbeContext = Parameters<typeof testZoomMeetingListening>[0];
|
||||
type ZoomMeetingsProbeContext = Parameters<typeof zoomMeetingsProbes.testListening>[0];
|
||||
|
||||
describe("Zoom meeting runtime probes", () => {
|
||||
it.each([
|
||||
|
|
@ -41,7 +41,7 @@ describe("Zoom meeting runtime probes", () => {
|
|||
resolveAgentId: () => "main",
|
||||
} satisfies ZoomMeetingsProbeContext;
|
||||
|
||||
const result = await testZoomMeetingListening(context, {
|
||||
const result = await zoomMeetingsProbes.testListening(context, {
|
||||
mode: "transcribe",
|
||||
timeoutMs: 100,
|
||||
url: URL,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
ZoomMeetingsSession,
|
||||
} from "./transports/types.js";
|
||||
|
||||
const probes = MeetingPlatformAdapter.createRuntimeProbes<
|
||||
export const zoomMeetingsProbes = MeetingPlatformAdapter.createRuntimeProbes<
|
||||
ZoomMeetingsConfig,
|
||||
ZoomMeetingsMode,
|
||||
ZoomMeetingsTransport,
|
||||
|
|
@ -22,6 +22,3 @@ const probes = MeetingPlatformAdapter.createRuntimeProbes<
|
|||
shouldWaitForListening: (session) => Boolean(session.chrome?.browserTab?.targetId),
|
||||
talkBackMode: MeetingPlatformAdapter.isTalkBackMode,
|
||||
});
|
||||
|
||||
export const testZoomMeetingListening = probes.testListening;
|
||||
export const testZoomMeetingSpeech = probes.testSpeech;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
import type { ZoomMeetingsConfig, ZoomMeetingsMode } from "./config.js";
|
||||
import { assertZoomMeetingsAudioAvailable } from "./transports/chrome.js";
|
||||
import { ZOOM_MEETINGS_BROWSER_NODE_ADAPTER } from "./transports/zoom-meetings-platform-constants.js";
|
||||
import { zoomMeetingsChrome } from "./transports/chrome.js";
|
||||
import { ZOOM_MEETINGS_PLATFORM_ADAPTER } from "./transports/zoom-meetings-platform-adapter.js";
|
||||
|
||||
export const getZoomMeetingsSetupStatus = MeetingPlatformAdapter.createRuntimeSetup<
|
||||
ZoomMeetingsConfig,
|
||||
ZoomMeetingsMode
|
||||
>({
|
||||
assertAudioDeviceAvailable: assertZoomMeetingsAudioAvailable,
|
||||
assertAudioDeviceAvailable: zoomMeetingsChrome.assertAudioDeviceAvailable,
|
||||
captionsMessage: (mode) =>
|
||||
mode === "transcribe"
|
||||
? "Zoom live-caption capture is enabled and ready"
|
||||
|
|
@ -27,5 +27,5 @@ export const getZoomMeetingsSetupStatus = MeetingPlatformAdapter.createRuntimeSe
|
|||
};
|
||||
},
|
||||
missingNodeIdMessage: "Connected Zoom meetings node did not include a node id.",
|
||||
nodeAdapter: ZOOM_MEETINGS_BROWSER_NODE_ADAPTER,
|
||||
nodeAdapter: ZOOM_MEETINGS_PLATFORM_ADAPTER,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,14 +1,8 @@
|
|||
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
import type { ZoomMeetingsConfig, ZoomMeetingsMode, ZoomMeetingsTransport } from "./config.js";
|
||||
import { testZoomMeetingListening, testZoomMeetingSpeech } from "./runtime-probes.js";
|
||||
import { zoomMeetingsProbes } from "./runtime-probes.js";
|
||||
import { getZoomMeetingsSetupStatus } from "./runtime-setup.js";
|
||||
import {
|
||||
launchZoomMeetingInChrome,
|
||||
launchZoomMeetingOnNode,
|
||||
leaveZoomMeetingInBrowser,
|
||||
readZoomMeetingTranscript,
|
||||
recoverCurrentZoomMeetingTab,
|
||||
} from "./transports/chrome.js";
|
||||
import { zoomMeetingsChrome } from "./transports/chrome.js";
|
||||
import type { ZoomMeetingsChromeHealth } from "./transports/types.js";
|
||||
import { ZOOM_MEETINGS_PLATFORM_ADAPTER } from "./transports/zoom-meetings-platform-adapter.js";
|
||||
import { hasSameZoomMeetingJoinCredential } from "./transports/zoom-meetings-urls.js";
|
||||
|
|
@ -20,22 +14,15 @@ export const ZoomMeetingsRuntime = MeetingPlatformAdapter.createRuntimeFacade<
|
|||
ZoomMeetingsChromeHealth,
|
||||
{
|
||||
setup: Awaited<ReturnType<typeof getZoomMeetingsSetupStatus>>;
|
||||
listening: Awaited<ReturnType<typeof testZoomMeetingListening>>;
|
||||
speech: Awaited<ReturnType<typeof testZoomMeetingSpeech>>;
|
||||
listening: Awaited<ReturnType<typeof zoomMeetingsProbes.testListening>>;
|
||||
speech: Awaited<ReturnType<typeof zoomMeetingsProbes.testSpeech>>;
|
||||
}
|
||||
>({
|
||||
platform: ZOOM_MEETINGS_PLATFORM_ADAPTER,
|
||||
transport: {
|
||||
launchInChrome: launchZoomMeetingInChrome,
|
||||
launchOnNode: launchZoomMeetingOnNode,
|
||||
leaveInBrowser: leaveZoomMeetingInBrowser,
|
||||
readTranscript: readZoomMeetingTranscript,
|
||||
recoverCurrentTab: recoverCurrentZoomMeetingTab,
|
||||
},
|
||||
transport: zoomMeetingsChrome,
|
||||
probes: {
|
||||
setupStatus: getZoomMeetingsSetupStatus,
|
||||
testListening: testZoomMeetingListening,
|
||||
testSpeech: testZoomMeetingSpeech,
|
||||
...zoomMeetingsProbes,
|
||||
},
|
||||
hooks: {
|
||||
// Normalize before locking so credential reuse sees the exact launched request.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ vi.mock("openclaw/plugin-sdk/meeting-runtime", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
import { launchZoomMeetingInChrome, launchZoomMeetingOnNode } from "./chrome.js";
|
||||
import { zoomMeetingsChrome } from "./chrome.js";
|
||||
|
||||
const URL = "https://zoom.us/j/12345678905?pwd=rollback";
|
||||
|
||||
|
|
@ -47,8 +47,8 @@ describe("Zoom meeting Chrome startup cleanup", () => {
|
|||
nodeCommand: "zoommeetings.chrome",
|
||||
preserveTrackedBrowser: true,
|
||||
resolveConfig: resolveZoomMeetingsConfig,
|
||||
launchInChrome: launchZoomMeetingInChrome,
|
||||
launchOnNode: launchZoomMeetingOnNode,
|
||||
launchInChrome: zoomMeetingsChrome.launchInChrome,
|
||||
launchOnNode: zoomMeetingsChrome.launchOnNode,
|
||||
engineMocks,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
import { ZOOM_MEETINGS_PLATFORM_ADAPTER } from "./zoom-meetings-platform-adapter.js";
|
||||
|
||||
const chromeTransport = MeetingPlatformAdapter.createPluginChromeTransport({
|
||||
export const zoomMeetingsChrome = MeetingPlatformAdapter.createPluginChromeTransport({
|
||||
meetingLabel: "Zoom meeting",
|
||||
platform: ZOOM_MEETINGS_PLATFORM_ADAPTER,
|
||||
preserveTrackedBrowserOnEngineFailure: true,
|
||||
runtime: MeetingPlatformAdapter.createChromeRuntimeBindings(),
|
||||
});
|
||||
|
||||
export const assertZoomMeetingsAudioAvailable = chromeTransport.assertAudioDeviceAvailable;
|
||||
export const launchZoomMeetingInChrome = chromeTransport.launchInChrome;
|
||||
export const launchZoomMeetingOnNode = chromeTransport.launchOnNode;
|
||||
export const leaveZoomMeetingInBrowser = chromeTransport.leaveInBrowser;
|
||||
export const readZoomMeetingTranscript = chromeTransport.readTranscript;
|
||||
export const recoverCurrentZoomMeetingTab = chromeTransport.recoverCurrentTab;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import {
|
|||
zoomMeetingStatusScript,
|
||||
zoomMeetingTranscriptScript,
|
||||
} from "./zoom-meetings-page-scripts.js";
|
||||
import { ZOOM_MEETINGS_NODE_COMMAND } from "./zoom-meetings-platform-constants.js";
|
||||
import {
|
||||
isRecoverableZoomMeetingTab,
|
||||
isSameZoomMeetingUrl,
|
||||
|
|
@ -75,7 +74,7 @@ export const ZOOM_MEETINGS_PLATFORM_ADAPTER = MeetingPlatformAdapter.create<
|
|||
? "Zoom guest in Chrome on a paired node"
|
||||
: "Zoom guest in the OpenClaw Chrome profile",
|
||||
},
|
||||
nodeCommandName: ZOOM_MEETINGS_NODE_COMMAND,
|
||||
nodeCommandName: "zoommeetings.chrome",
|
||||
nodeConfigPath: "plugins.entries.zoom-meetings.config.chromeNode.node",
|
||||
urls: {
|
||||
validateAndNormalize: normalizeZoomMeetingUrl,
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
export const ZOOM_MEETINGS_NODE_COMMAND = "zoommeetings.chrome";
|
||||
|
||||
export const ZOOM_MEETINGS_BROWSER_NODE_ADAPTER = {
|
||||
displayName: "Zoom meetings",
|
||||
nodeCommandName: ZOOM_MEETINGS_NODE_COMMAND,
|
||||
nodeConfigPath: "plugins.entries.zoom-meetings.config.chromeNode.node",
|
||||
};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
export function zoomMeetingStatusAccessSource(): string {
|
||||
return ` const passcodeInput = firstRaw(selectors.passcode);
|
||||
const passcodeRequired = Boolean(passcodeInput) &&
|
||||
/meeting passcode|enter (?:the )?passcode|invalid passcode|incorrect passcode/i.test(
|
||||
pageText + " " + label(passcodeInput)
|
||||
);
|
||||
const captchaRequired = Boolean(firstRaw(selectors.captcha)) ||
|
||||
/complete (?:the )?captcha|security check|verify (?:that )?you(?:'re| are) (?:a )?human/i.test(pageTextLower);
|
||||
if (identityVerified && !inCall && passcodeRequired) {
|
||||
controlManualAction = manualActionFor("zoom-passcode-required", "Enter the Zoom meeting passcode in the OpenClaw browser profile, then retry joining.");
|
||||
} else if (identityVerified && !inCall && captchaRequired) {
|
||||
controlManualAction = manualActionFor("zoom-captcha-required", "Complete Zoom's security check in the OpenClaw browser profile, then retry joining.");
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export function zoomMeetingStatusPageSource(): string {
|
||||
return ` const pageText = text(document.body);
|
||||
const pageTextLower = pageText.toLowerCase();
|
||||
const lobbyWaiting = Boolean(first(selectors.lobby)) ||
|
||||
/host will let you in soon|waiting for the host to start|someone will let you in shortly|waiting for someone to let you in|when someone admits you|you.?re in the lobby|we.?ve let people in the meeting know you.?re waiting/i.test(pageTextLower);
|
||||
`;
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { MeetingPlatformAdapter } from "openclaw/plugin-sdk/meeting-runtime";
|
||||
import { zoomMeetingStatusAccessSource } from "./zoom-meetings-status-access-source.js";
|
||||
import { zoomMeetingStatusPageSource } from "./zoom-meetings-status-page-source.js";
|
||||
|
||||
type MeetingStatusPreludeParams = Parameters<
|
||||
typeof MeetingPlatformAdapter.createStatusPreludeSource
|
||||
|
|
@ -67,7 +65,11 @@ export function zoomMeetingStatusPreludeSource(params: MeetingStatusPreludeParam
|
|||
await waitForUi();
|
||||
}
|
||||
}
|
||||
${zoomMeetingStatusPageSource()}
|
||||
const pageText = text(document.body);
|
||||
const pageTextLower = pageText.toLowerCase();
|
||||
const lobbyWaiting = Boolean(first(selectors.lobby)) ||
|
||||
/host will let you in soon|waiting for the host to start|someone will let you in shortly|waiting for someone to let you in|when someone admits you|you.?re in the lobby|we.?ve let people in the meeting know you.?re waiting/i.test(pageTextLower);
|
||||
|
||||
const devicesDisabled = Boolean(!allowMicrophone && (dismissedDevicePrompt || (priorMeeting?.identity === expectedIdentity && (!sessionId || priorMeeting?.sessionId === sessionId) && priorMeeting?.devicesDisabled === true)));
|
||||
// Zoom replaces the meeting URL after admission; retain only an adopted in-call control.
|
||||
// Lobby ownership remains durable because host admission has no bounded wait.
|
||||
|
|
@ -155,10 +157,27 @@ export function zoomMeetingStatusPreludeSource(params: MeetingStatusPreludeParam
|
|||
}
|
||||
const microphone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
let microphoneState = identityVerified ? (toggleState(microphone, "microphone") || (devicesDisabled ? "off" : undefined)) : undefined;
|
||||
const refreshMicrophoneState = async () => {
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
};
|
||||
const camera = first(selectors.camera) || findTextButton(/camera|video/i);
|
||||
let cameraState = identityVerified ? (toggleState(camera, "camera") || (devicesDisabled ? "off" : undefined)) : undefined;
|
||||
let controlManualAction;
|
||||
${zoomMeetingStatusAccessSource()}
|
||||
const passcodeInput = firstRaw(selectors.passcode);
|
||||
const passcodeRequired = Boolean(passcodeInput) &&
|
||||
/meeting passcode|enter (?:the )?passcode|invalid passcode|incorrect passcode/i.test(
|
||||
pageText + " " + label(passcodeInput)
|
||||
);
|
||||
const captchaRequired = Boolean(firstRaw(selectors.captcha)) ||
|
||||
/complete (?:the )?captcha|security check|verify (?:that )?you(?:'re| are) (?:a )?human/i.test(pageTextLower);
|
||||
if (identityVerified && !inCall && passcodeRequired) {
|
||||
controlManualAction = manualActionFor("zoom-passcode-required", "Enter the Zoom meeting passcode in the OpenClaw browser profile, then retry joining.");
|
||||
} else if (identityVerified && !inCall && captchaRequired) {
|
||||
controlManualAction = manualActionFor("zoom-captcha-required", "Complete Zoom's security check in the OpenClaw browser profile, then retry joining.");
|
||||
}
|
||||
|
||||
if (
|
||||
canMutateSession &&
|
||||
identityVerified &&
|
||||
|
|
@ -252,25 +271,19 @@ export function zoomMeetingStatusPreludeSource(params: MeetingStatusPreludeParam
|
|||
if (!audioInputRouted) {
|
||||
if (canMutateSession && microphoneState === "on") {
|
||||
microphone.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
}
|
||||
notes.push("The virtual audio input will be selected from Zoom's in-call audio controls.");
|
||||
} else if (canMutateSession && microphoneState === "off") {
|
||||
microphone.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
if (microphoneState === "on") {
|
||||
notes.push("Unmuted the Zoom microphone after verifying the virtual audio input.");
|
||||
}
|
||||
}
|
||||
} else if (canMutateSession && identityVerified && !allowMicrophone && microphoneState === "on") {
|
||||
microphone.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
if (microphoneState === "off") {
|
||||
notes.push("Muted the Zoom microphone for observe-only mode.");
|
||||
}
|
||||
|
|
@ -278,21 +291,15 @@ export function zoomMeetingStatusPreludeSource(params: MeetingStatusPreludeParam
|
|||
if (identityVerified && inCall && allowMicrophone) {
|
||||
if (!selectedMicrophoneLabel() && canMutateSession && microphoneState === "on") {
|
||||
microphone?.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
}
|
||||
audioInputRouted = await ensureVirtualAudioInput();
|
||||
if (audioInputRouted && canMutateSession && microphoneState === "off") {
|
||||
microphone?.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
} else if (!audioInputRouted && canMutateSession && microphoneState === "on") {
|
||||
microphone?.click();
|
||||
await waitForUi();
|
||||
const currentMicrophone = first(selectors.microphone) || findTextButton(/mute|unmute|microphone/i);
|
||||
microphoneState = toggleState(currentMicrophone, "microphone");
|
||||
await refreshMicrophoneState();
|
||||
if (microphoneState === "off") {
|
||||
notes.push("Muted the Zoom microphone because the virtual audio input could not be reverified.");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue