From eaf8d916f7fabebfff49fe7c4801ead00e580e4c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 26 Sep 2026 04:07:19 -0700 Subject: [PATCH] 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 --- config/assertion-safety-baseline.txt | 11 +- extensions/imap/src/config.ts | 14 +- extensions/imap/src/sender-gate.ts | 9 +- extensions/irc/src/client.ts | 73 ++++------ extensions/irc/src/setup-surface.ts | 75 ++++------ extensions/nextcloud-talk/src/channel.ts | 3 +- .../nextcloud-talk/src/config-schema.ts | 3 +- extensions/nextcloud-talk/src/inbound.ts | 39 ++---- .../nextcloud-talk/src/message-adapter.ts | 38 +++-- extensions/nextcloud-talk/src/normalize.ts | 33 ++--- extensions/nextcloud-talk/src/secret-input.ts | 2 - extensions/nextcloud-talk/src/send.ts | 35 +---- .../nextcloud-talk/src/session-route.ts | 27 +--- extensions/nextcloud-talk/src/setup-core.ts | 42 ++---- extensions/nextcloud-talk/src/signature.ts | 7 +- extensions/nextcloud-talk/src/types.ts | 45 ------ .../nextcloud-talk/src/webhook-spool.ts | 5 +- extensions/qa-channel/api.ts | 1 - extensions/qa-channel/channel-plugin-api.ts | 1 - extensions/qa-channel/index.ts | 1 - extensions/qa-channel/runtime-api.ts | 30 ++-- extensions/qa-channel/setup-entry.ts | 1 - extensions/qa-channel/src/accounts.ts | 1 - extensions/qa-channel/src/bus-client.ts | 80 ++++------- extensions/qa-channel/src/channel-actions.ts | 20 ++- extensions/qa-channel/src/channel-base.ts | 3 +- extensions/qa-channel/src/channel.setup.ts | 3 +- extensions/qa-channel/src/channel.test.ts | 2 +- extensions/qa-channel/src/channel.ts | 13 +- extensions/qa-channel/src/config-schema.ts | 1 - extensions/qa-channel/src/gateway.test.ts | 2 +- extensions/qa-channel/src/gateway.ts | 3 +- extensions/qa-channel/src/inbound.ts | 38 ++--- extensions/qa-channel/src/outbound.ts | 3 +- extensions/qa-channel/src/protocol.ts | 22 --- extensions/qa-channel/src/runtime-api.ts | 24 ---- extensions/qa-channel/src/runtime.ts | 3 +- extensions/qa-channel/src/setup.ts | 1 - extensions/qa-channel/src/status.ts | 5 +- extensions/qa-channel/src/types.ts | 1 - extensions/qa-channel/test-api.ts | 2 - extensions/raft/src/channel.ts | 34 +---- extensions/session-share/index.ts | 13 +- extensions/session-share/src/node-commands.ts | 11 -- .../session-share/src/session-catalog.ts | 15 +- .../teams-meetings/src/runtime-probes.test.ts | 6 +- .../teams-meetings/src/runtime-probes.ts | 5 +- .../teams-meetings/src/runtime-setup.test.ts | 17 +++ .../teams-meetings/src/runtime-setup.ts | 10 +- extensions/teams-meetings/src/runtime.ts | 25 +--- .../src/transports/chrome.test.ts | 6 +- .../teams-meetings/src/transports/chrome.ts | 9 +- .../teams-meetings-platform-adapter.ts | 3 +- .../teams-meetings-platform-constants.ts | 7 - .../teams-meetings-status-prejoin-source.ts | 29 ++-- .../src/transports/teams-meetings-urls.ts | 18 +-- extensions/twitch/src/actions.test.ts | 4 + extensions/twitch/src/actions.ts | 87 ++---------- .../twitch/src/client-manager-registry.ts | 46 +----- extensions/twitch/src/config-schema.ts | 40 +----- extensions/twitch/src/monitor.ts | 34 +---- extensions/twitch/src/outbound.ts | 105 ++------------ extensions/twitch/src/plugin.ts | 14 -- extensions/twitch/src/resolver.ts | 104 ++++---------- extensions/twitch/src/status.ts | 23 --- extensions/twitch/src/twitch-client.ts | 32 +---- extensions/twitch/src/types.ts | 28 ---- extensions/twitch/src/utils/twitch.ts | 36 +---- extensions/zalo/runtime-api.ts | 87 ------------ extensions/zalo/src/channel.directory.test.ts | 3 +- .../channel.pre-aborted.integration.test.ts | 4 +- extensions/zalo/src/channel.runtime.ts | 2 +- extensions/zalo/src/group-access.ts | 19 +-- extensions/zalo/src/monitor.lifecycle.test.ts | 3 +- ...onitor.polling.media-reply.test-support.ts | 2 +- ...nitor.reply-once.lifecycle.test-support.ts | 2 +- extensions/zalo/src/monitor.ts | 132 ++++-------------- extensions/zalo/src/monitor.webhook.test.ts | 2 +- extensions/zalo/src/monitor.webhook.ts | 8 +- extensions/zalo/src/outbound-media.test.ts | 2 +- extensions/zalo/src/send.ts | 103 ++++---------- extensions/zalo/src/setup-allow-from.ts | 8 +- extensions/zalo/src/setup-core.ts | 8 +- extensions/zalo/src/setup-status.test.ts | 2 +- extensions/zalo/src/setup-surface.test.ts | 2 +- extensions/zalo/src/setup-surface.ts | 39 ++---- .../zoom-meetings/src/runtime-probes.test.ts | 6 +- .../zoom-meetings/src/runtime-probes.ts | 5 +- extensions/zoom-meetings/src/runtime-setup.ts | 8 +- extensions/zoom-meetings/src/runtime.ts | 25 +--- .../src/transports/chrome.test.ts | 6 +- .../zoom-meetings/src/transports/chrome.ts | 9 +- .../zoom-meetings-platform-adapter.ts | 3 +- .../zoom-meetings-platform-constants.ts | 7 - .../zoom-meetings-status-access-source.ts | 15 -- .../zoom-meetings-status-page-source.ts | 7 - .../zoom-meetings-status-prejoin-source.ts | 51 ++++--- 97 files changed, 468 insertions(+), 1585 deletions(-) delete mode 100644 extensions/nextcloud-talk/src/secret-input.ts delete mode 100644 extensions/qa-channel/src/protocol.ts delete mode 100644 extensions/qa-channel/src/runtime-api.ts delete mode 100644 extensions/teams-meetings/src/transports/teams-meetings-platform-constants.ts delete mode 100644 extensions/zoom-meetings/src/transports/zoom-meetings-platform-constants.ts delete mode 100644 extensions/zoom-meetings/src/transports/zoom-meetings-status-access-source.ts delete mode 100644 extensions/zoom-meetings/src/transports/zoom-meetings-status-page-source.ts diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 46ad1abf9036..dccde3ea1412 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/extensions/imap/src/config.ts b/extensions/imap/src/config.ts index 1d51c5d30e90..8c56992775fa 100644 --- a/extensions/imap/src/config.ts +++ b/extensions/imap/src/config.ts @@ -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 }; -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) }] : []; }) : [], diff --git a/extensions/imap/src/sender-gate.ts b/extensions/imap/src/sender-gate.ts index 42d56295c898..cde5de89dafd 100644 --- a/extensions/imap/src/sender-gate.ts +++ b/extensions/imap/src/sender-gate.ts @@ -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( diff --git a/extensions/irc/src/client.ts b/extensions/irc/src/client.ts index 43dcda27cb35..d2bf843e521d 100644 --- a/extensions/irc/src/client.ts +++ b/extensions/irc/src/client.ts @@ -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 { - 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 { 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[number]; + +function ircAccountTextInput( + configKey: "host" | "nick" | "username" | "realname", + input: Pick, +): 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"), diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 0c7ce337885f..82722f9397e9 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -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 = normalizeTarget: normalizeNextcloudTalkMessagingTarget, inferTargetChatType: ({ to }) => normalizeNextcloudTalkMessagingTarget(to) ? "group" : undefined, - resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params), + resolveOutboundSessionRoute: resolveNextcloudTalkOutboundSessionRoute, targetResolver: { looksLikeId: looksLikeNextcloudTalkTargetId, hint: "", diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index 2657afae66a2..afe5f26590a8 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -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(), diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index ba10c5c6105e..d5cf5c06d40a 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -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)}`); diff --git a/extensions/nextcloud-talk/src/message-adapter.ts b/extensions/nextcloud-talk/src/message-adapter.ts index 6fbbdf041d54..b65dfb0af4d5 100644 --- a/extensions/nextcloud-talk/src/message-adapter.ts +++ b/extensions/nextcloud-talk/src/message-adapter.ts @@ -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, - }, ), }, }); diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts index b9f5faca649a..e83ee216c59a 100644 --- a/extensions/nextcloud-talk/src/normalize.ts +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -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 { diff --git a/extensions/nextcloud-talk/src/secret-input.ts b/extensions/nextcloud-talk/src/secret-input.ts deleted file mode 100644 index a0d5af9c193b..000000000000 --- a/extensions/nextcloud-talk/src/secret-input.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Nextcloud Talk plugin module implements secret input behavior. -export { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input"; diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index 24b8f7632d04..4c7134709e02 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -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, - "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; - 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({ diff --git a/extensions/nextcloud-talk/src/session-route.ts b/extensions/nextcloud-talk/src/session-route.ts index 1fcfc7a5c5c0..c5ebf81141c3 100644 --- a/extensions/nextcloud-talk/src/session-route.ts +++ b/extensions/nextcloud-talk/src/session-route.ts @@ -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}`, - }; + }); } diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index eebe4632be67..a8289f974d75 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -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 { - return await promptParsedAllowFromForAccount({ - cfg: params.cfg, - accountId: params.accountId, - defaultAccountId: params.accountId, - prompter: params.prompter, +const promptNextcloudTalkAllowFrom: ReturnType< + typeof createPromptParsedAllowFromForAccount +> = async (params) => + await createPromptParsedAllowFromForAccount({ + 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 { - 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 = { diff --git a/extensions/nextcloud-talk/src/signature.ts b/extensions/nextcloud-talk/src/signature.ts index bbe0746482e8..547ee1b1738a 100644 --- a/extensions/nextcloud-talk/src/signature.ts +++ b/extensions/nextcloud-talk/src/signature.ts @@ -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, ): 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; }; diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts index 3798338d4a64..79b175f172fb 100644 --- a/extensions/nextcloud-talk/src/types.ts +++ b/extensions/nextcloud-talk/src/types.ts @@ -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; diff --git a/extensions/nextcloud-talk/src/webhook-spool.ts b/extensions/nextcloud-talk/src/webhook-spool.ts index 4b9aedcd223e..408a0d93b99f 100644 --- a/extensions/nextcloud-talk/src/webhook-spool.ts +++ b/extensions/nextcloud-talk/src/webhook-spool.ts @@ -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 = 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"), diff --git a/extensions/qa-channel/api.ts b/extensions/qa-channel/api.ts index fbcba2fc9799..803da22b4bcd 100644 --- a/extensions/qa-channel/api.ts +++ b/extensions/qa-channel/api.ts @@ -1,4 +1,3 @@ -// Qa Channel API module exposes the plugin public contract. export { DEFAULT_ACCOUNT_ID, listEnabledQaChannelAccounts, diff --git a/extensions/qa-channel/channel-plugin-api.ts b/extensions/qa-channel/channel-plugin-api.ts index 74ab5fdfdc56..08854379e820 100644 --- a/extensions/qa-channel/channel-plugin-api.ts +++ b/extensions/qa-channel/channel-plugin-api.ts @@ -1,2 +1 @@ -// Qa Channel API module exposes the plugin public contract. export { qaChannelPlugin } from "./src/channel.js"; diff --git a/extensions/qa-channel/index.ts b/extensions/qa-channel/index.ts index 45d4bbd2667b..6c708aa866e1 100644 --- a/extensions/qa-channel/index.ts +++ b/extensions/qa-channel/index.ts @@ -1,4 +1,3 @@ -// Qa Channel plugin entrypoint registers its OpenClaw integration. import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract"; export default defineBundledChannelEntry({ diff --git a/extensions/qa-channel/runtime-api.ts b/extensions/qa-channel/runtime-api.ts index b4bcab232fe0..15eebd360b79 100644 --- a/extensions/qa-channel/runtime-api.ts +++ b/extensions/qa-channel/runtime-api.ts @@ -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"; diff --git a/extensions/qa-channel/setup-entry.ts b/extensions/qa-channel/setup-entry.ts index 6baec4d71b5b..23f42cbfac9b 100644 --- a/extensions/qa-channel/setup-entry.ts +++ b/extensions/qa-channel/setup-entry.ts @@ -1,4 +1,3 @@ -// Qa Channel plugin module implements setup entry behavior. import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract"; export default defineBundledChannelSetupEntry({ diff --git a/extensions/qa-channel/src/accounts.ts b/extensions/qa-channel/src/accounts.ts index a1a321654ce7..1d22a43bacad 100644 --- a/extensions/qa-channel/src/accounts.ts +++ b/extensions/qa-channel/src/accounts.ts @@ -1,4 +1,3 @@ -// Qa Channel plugin module implements accounts behavior. import { createAccountListHelpers, resolveChannelMediaMaxBytes, diff --git a/extensions/qa-channel/src/bus-client.ts b/extensions/qa-channel/src/bus-client.ts index 3eb6e49287de..8aa526a6dd98 100644 --- a/extensions/qa-channel/src/bus-client.ts +++ b/extensions/qa-channel/src/bus-client.ts @@ -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 = Promise; +type QaBusAccountRequest = Omit & { + 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( path: string, body: unknown, options: QaBusPostOptions = {}, -): JsonResult { +): Promise { 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) { 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) { 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) { 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) { 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) { 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) { return await postJson<{ message: QaBusMessage }>(params.baseUrl, "/v1/actions/read", params); } diff --git a/extensions/qa-channel/src/channel-actions.ts b/extensions/qa-channel/src/channel-actions.ts index f62897723a66..9315e7952e4d 100644 --- a/extensions/qa-channel/src/channel-actions.ts +++ b/extensions/qa-channel/src/channel-actions.ts @@ -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"); } } diff --git a/extensions/qa-channel/src/channel-base.ts b/extensions/qa-channel/src/channel-base.ts index 4a73964f6abc..ec928d1b01a3 100644 --- a/extensions/qa-channel/src/channel-base.ts +++ b/extensions/qa-channel/src/channel-base.ts @@ -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"; diff --git a/extensions/qa-channel/src/channel.setup.ts b/extensions/qa-channel/src/channel.setup.ts index fabaf07891b8..5125075ad60a 100644 --- a/extensions/qa-channel/src/channel.setup.ts +++ b/extensions/qa-channel/src/channel.setup.ts @@ -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 = createQaChannelPluginBase(); diff --git a/extensions/qa-channel/src/channel.test.ts b/extensions/qa-channel/src/channel.test.ts index 3dc700a1f430..b9b1234eeeff 100644 --- a/extensions/qa-channel/src/channel.test.ts +++ b/extensions/qa-channel/src/channel.test.ts @@ -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[0]; diff --git a/extensions/qa-channel/src/channel.ts b/extensions/qa-channel/src/channel.ts index 249b73a437a6..d2be961e93f1 100644 --- a/extensions/qa-channel/src/channel.ts +++ b/extensions/qa-channel/src/channel.ts @@ -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 = 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: "", }, resolveOutboundSessionRoute: ({ @@ -201,12 +199,7 @@ export const qaChannelPlugin: ChannelPlugin = createCh accountId, recipientSessionExact: true, peer: { - kind: - parsed.chatType === "direct" - ? "direct" - : parsed.chatType === "group" - ? "group" - : "channel", + kind: parsed.chatType, id: baseTarget, }, chatType: parsed.chatType, diff --git a/extensions/qa-channel/src/config-schema.ts b/extensions/qa-channel/src/config-schema.ts index fdfcb5c9256a..5357b485de40 100644 --- a/extensions/qa-channel/src/config-schema.ts +++ b/extensions/qa-channel/src/config-schema.ts @@ -1,4 +1,3 @@ -// Qa Channel helper module supports config schema behavior. import { buildChannelConfigSchema, buildGroupEntrySchema, diff --git a/extensions/qa-channel/src/gateway.test.ts b/extensions/qa-channel/src/gateway.test.ts index 2740def0af85..d582fcbf6512 100644 --- a/extensions/qa-channel/src/gateway.test.ts +++ b/extensions/qa-channel/src/gateway.test.ts @@ -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", () => ({ diff --git a/extensions/qa-channel/src/gateway.ts b/extensions/qa-channel/src/gateway.ts index 716af69af030..b104ef0dbc43 100644 --- a/extensions/qa-channel/src/gateway.ts +++ b/extensions/qa-channel/src/gateway.ts @@ -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( diff --git a/extensions/qa-channel/src/inbound.ts b/extensions/qa-channel/src/inbound.ts index bffe5886c035..16125891d3f8 100644 --- a/extensions/qa-channel/src/inbound.ts +++ b/extensions/qa-channel/src/inbound.ts @@ -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(); diff --git a/extensions/qa-channel/src/outbound.ts b/extensions/qa-channel/src/outbound.ts index d665e75cc5cf..f88a1ee4c53a 100644 --- a/extensions/qa-channel/src/outbound.ts +++ b/extensions/qa-channel/src/outbound.ts @@ -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 = { diff --git a/extensions/qa-channel/src/protocol.ts b/extensions/qa-channel/src/protocol.ts deleted file mode 100644 index 6ba87164c508..000000000000 --- a/extensions/qa-channel/src/protocol.ts +++ /dev/null @@ -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"; diff --git a/extensions/qa-channel/src/runtime-api.ts b/extensions/qa-channel/src/runtime-api.ts deleted file mode 100644 index 4d7da1368ae1..000000000000 --- a/extensions/qa-channel/src/runtime-api.ts +++ /dev/null @@ -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"; diff --git a/extensions/qa-channel/src/runtime.ts b/extensions/qa-channel/src/runtime.ts index 88b139d78b24..7c0d06d52c67 100644 --- a/extensions/qa-channel/src/runtime.ts +++ b/extensions/qa-channel/src/runtime.ts @@ -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({ diff --git a/extensions/qa-channel/src/setup.ts b/extensions/qa-channel/src/setup.ts index 52416e570ff8..dca2bada5aa5 100644 --- a/extensions/qa-channel/src/setup.ts +++ b/extensions/qa-channel/src/setup.ts @@ -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"; diff --git a/extensions/qa-channel/src/status.ts b/extensions/qa-channel/src/status.ts index f85d35458516..e822c4868532 100644 --- a/extensions/qa-channel/src/status.ts +++ b/extensions/qa-channel/src/status.ts @@ -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({ diff --git a/extensions/qa-channel/src/types.ts b/extensions/qa-channel/src/types.ts index 6b7201cb4d5e..0ff2b5befe40 100644 --- a/extensions/qa-channel/src/types.ts +++ b/extensions/qa-channel/src/types.ts @@ -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"; diff --git a/extensions/qa-channel/test-api.ts b/extensions/qa-channel/test-api.ts index 2ae97f46e973..23a05a6b74b5 100644 --- a/extensions/qa-channel/test-api.ts +++ b/extensions/qa-channel/test-api.ts @@ -1,3 +1 @@ -// Qa Channel API module exposes the plugin public contract. -export * from "./src/protocol.js"; export * from "./src/bus-client.js"; diff --git a/extensions/raft/src/channel.ts b/extensions/raft/src/channel.ts index 1a3d863bbc5a..aaac3218632b 100644 --- a/extensions/raft/src/channel.ts +++ b/extensions/raft/src/channel.ts @@ -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 = 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 = createC }), }), gateway: { - startAccount: async (ctx) => await startRaftGatewayAccount(ctx), + startAccount: startRaftGatewayAccount, }, }, }); diff --git a/extensions/session-share/index.ts b/extensions/session-share/index.ts index d4a2e521a935..01c655367ba1 100644 --- a/extensions/session-share/index.ts +++ b/extensions/session-share/index.ts @@ -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(), + }); }, }); diff --git a/extensions/session-share/src/node-commands.ts b/extensions/session-share/src/node-commands.ts index 47da4379f6d7..53648e20fe11 100644 --- a/extensions/session-share/src/node-commands.ts +++ b/extensions/session-share/src/node-commands.ts @@ -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(), - }, - ]; -} diff --git a/extensions/session-share/src/session-catalog.ts b/extensions/session-share/src/session-catalog.ts index 2cf5aad426a1..8b9a8449e6d4 100644 --- a/extensions/session-share/src/session-catalog.ts +++ b/extensions/session-share/src/session-catalog.ts @@ -48,7 +48,7 @@ const nodeErrorCodes = new Set([ "APPROVAL_AUTHORITY_CLOSED", ]); -function observeCatalogPhase(phase: "discovery" | "invoke", operation: () => Promise) { +async function observeCatalogPhase(phase: "discovery" | "invoke", operation: () => Promise) { if (!areDiagnosticsEnabledForProcess() || !log.isEnabled("warn")) { return operation(); } @@ -86,16 +86,9 @@ function observeCatalogPhase(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; diff --git a/extensions/teams-meetings/src/runtime-probes.test.ts b/extensions/teams-meetings/src/runtime-probes.test.ts index d48273bd3913..30edf7bde197 100644 --- a/extensions/teams-meetings/src/runtime-probes.test.ts +++ b/extensions/teams-meetings/src/runtime-probes.test.ts @@ -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[0]; +type TeamsMeetingsProbeContext = Parameters[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, diff --git a/extensions/teams-meetings/src/runtime-probes.ts b/extensions/teams-meetings/src/runtime-probes.ts index fa0979d2f9e3..0bc27057b961 100644 --- a/extensions/teams-meetings/src/runtime-probes.ts +++ b/extensions/teams-meetings/src/runtime-probes.ts @@ -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; diff --git a/extensions/teams-meetings/src/runtime-setup.test.ts b/extensions/teams-meetings/src/runtime-setup.test.ts index 39a47e11e8fa..58cdca7c77c9 100644 --- a/extensions/teams-meetings/src/runtime-setup.test.ts +++ b/extensions/teams-meetings/src/runtime-setup.test.ts @@ -25,6 +25,23 @@ function runtimeWithNode(invoke: (params: Record) => Promise { + 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({ diff --git a/extensions/teams-meetings/src/runtime-setup.ts b/extensions/teams-meetings/src/runtime-setup.ts index 86b20197b9c2..013466af130a 100644 --- a/extensions/teams-meetings/src/runtime-setup.ts +++ b/extensions/teams-meetings/src/runtime-setup.ts @@ -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, }); diff --git a/extensions/teams-meetings/src/runtime.ts b/extensions/teams-meetings/src/runtime.ts index 0d6b96eb2bad..b493dddb49ea 100644 --- a/extensions/teams-meetings/src/runtime.ts +++ b/extensions/teams-meetings/src/runtime.ts @@ -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>; - listening: Awaited>; - speech: Awaited>; + listening: Awaited>; + speech: Awaited>; } >({ 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" }, diff --git a/extensions/teams-meetings/src/transports/chrome.test.ts b/extensions/teams-meetings/src/transports/chrome.test.ts index 621993764c94..c1e2abbc1ec9 100644 --- a/extensions/teams-meetings/src/transports/chrome.test.ts +++ b/extensions/teams-meetings/src/transports/chrome.test.ts @@ -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, }); }); diff --git a/extensions/teams-meetings/src/transports/chrome.ts b/extensions/teams-meetings/src/transports/chrome.ts index 0623a7cf95fe..c11ea59545f8 100644 --- a/extensions/teams-meetings/src/transports/chrome.ts +++ b/extensions/teams-meetings/src/transports/chrome.ts @@ -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; diff --git a/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.ts b/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.ts index 3287d5bb4bcc..09dfcfab0ab7 100644 --- a/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.ts +++ b/extensions/teams-meetings/src/transports/teams-meetings-platform-adapter.ts @@ -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, diff --git a/extensions/teams-meetings/src/transports/teams-meetings-platform-constants.ts b/extensions/teams-meetings/src/transports/teams-meetings-platform-constants.ts deleted file mode 100644 index 58d99e988a29..000000000000 --- a/extensions/teams-meetings/src/transports/teams-meetings-platform-constants.ts +++ /dev/null @@ -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", -}; diff --git a/extensions/teams-meetings/src/transports/teams-meetings-status-prejoin-source.ts b/extensions/teams-meetings/src/transports/teams-meetings-status-prejoin-source.ts index bafee574386c..c95e317c3ebb 100644 --- a/extensions/teams-meetings/src/transports/teams-meetings-status-prejoin-source.ts +++ b/extensions/teams-meetings/src/transports/teams-meetings-status-prejoin-source.ts @@ -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."); } diff --git a/extensions/teams-meetings/src/transports/teams-meetings-urls.ts b/extensions/teams-meetings/src/transports/teams-meetings-urls.ts index 8b1065b56f73..852e89b3dfeb 100644 --- a/extensions/teams-meetings/src/transports/teams-meetings-urls.ts +++ b/extensions/teams-meetings/src/transports/teams-meetings-urls.ts @@ -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/", ); @@ -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, diff --git a/extensions/twitch/src/actions.test.ts b/extensions/twitch/src/actions.test.ts index 76e85f93b618..eb4e9bf3c4a7 100644 --- a/extensions/twitch/src/actions.test.ts +++ b/extensions/twitch/src/actions.test.ts @@ -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 }) => { diff --git a/extensions/twitch/src/actions.ts b/extensions/twitch/src/actions.ts index 08b74c3a8c13..a08e38d8d5e2 100644 --- a/extensions/twitch/src/actions.ts +++ b/extensions/twitch/src/actions.ts @@ -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, - 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 ? 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. }; diff --git a/extensions/twitch/src/client-manager-registry.ts b/extensions/twitch/src/client-manager-registry.ts index 6f0b2765284d..8b688deb2630 100644 --- a/extensions/twitch/src/client-manager-registry.ts +++ b/extensions/twitch/src/client-manager-registry.ts @@ -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(); -/** - * 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 { const entry = registry.get(accountId); if (!entry) { diff --git a/extensions/twitch/src/config-schema.ts b/extensions/twitch/src/config-schema.ts index fe4dadfe16cb..8e2f0b8d301b 100644 --- a/extensions/twitch/src/config-schema.ts +++ b/extensions/twitch/src/config-schema.ts @@ -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]); diff --git a/extensions/twitch/src/monitor.ts b/extensions/twitch/src/monitor.ts index b851710e4292..0246ca265587 100644 --- a/extensions/twitch/src/monitor.ts +++ b/extensions/twitch/src/monitor.ts @@ -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["channel"]; - config: unknown; // OpenClawConfig + config: OpenClawConfig; runtime: TwitchRuntimeEnv; abortSignal: AbortSignal; statusSink?: (patch: Omit) => void; @@ -40,14 +33,11 @@ type TwitchMonitorResult = { type TwitchIngressLifecycle = Parameters[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 { @@ -257,11 +239,7 @@ export async function monitorTwitchProvider( const clientManager = getOrCreateClientManager(accountId, logger, statusSink); try { - await clientManager.getClient( - account, - config as Parameters[1], - accountId, - ); + await clientManager.getClient(account, config, accountId); } catch (error) { const errorMsg = formatErrorMessage(error); runtime.error?.(`Failed to connect: ${errorMsg}`); diff --git a/extensions/twitch/src/outbound.ts b/extensions/twitch/src/outbound.ts index e5603c4e83f1..c5364fc3a011 100644 --- a/extensions/twitch/src/outbound.ts +++ b/extensions/twitch/src/outbound.ts @@ -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", ""), - }; - } - - // 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", ""), - }; - } - - // 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", ""), + error: new Error("Delivering to Twitch requires target "), }; }, - /** - * 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 => { 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 => { 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", diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 8044226da8b2..62d417acf850 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -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 = createChatChannelPlugin({ pairing: { diff --git a/extensions/twitch/src/resolver.ts b/extensions/twitch/src/resolver.ts index 68cda64edef4..ee654569a567 100644 --- a/extensions/twitch/src/resolver.ts +++ b/extensions/twitch/src/resolver.ts @@ -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 { - 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}`); } } diff --git a/extensions/twitch/src/status.ts b/extensions/twitch/src/status.ts index a77e4222bd7c..60d5c3ce814a 100644 --- a/extensions/twitch/src/status.ts +++ b/extensions/twitch/src/status.ts @@ -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, diff --git a/extensions/twitch/src/twitch-client.ts b/extensions/twitch/src/twitch-client.ts index 426de78e3a77..1e4ce45dd646 100644 --- a/extensions/twitch/src/twitch-client.ts +++ b/extensions/twitch/src/twitch-client.ts @@ -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(); private pendingClients = new Map(); @@ -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 { const key = this.getAccountKey(account); const client = this.clients.get(key); @@ -405,9 +384,6 @@ export class TwitchClientManager { } } - /** - * Disconnect all clients - */ async disconnectAll(): Promise { 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}`; } diff --git a/extensions/twitch/src/types.ts b/extensions/twitch/src/types.ts index 2e46e9875c1e..0085c4cb9b17 100644 --- a/extensions/twitch/src/types.ts +++ b/extensions/twitch/src/types.ts @@ -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; -/** - * Account configuration for a Twitch channel - */ export type TwitchAccountConfig = z.input; -/** - * 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, diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts index ab41755bbacf..c3cdfe730d0f 100644 --- a/extensions/twitch/src/utils/twitch.ts +++ b/extensions/twitch/src/utils/twitch.ts @@ -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: { diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts index dab7a161b366..923813ecd665 100644 --- a/extensions/zalo/runtime-api.ts +++ b/extensions/zalo/runtime-api.ts @@ -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"; diff --git a/extensions/zalo/src/channel.directory.test.ts b/extensions/zalo/src/channel.directory.test.ts index 1c9a1661e9cf..b5a5863debf4 100644 --- a/extensions/zalo/src/channel.directory.test.ts +++ b/extensions/zalo/src/channel.directory.test.ts @@ -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", () => { diff --git a/extensions/zalo/src/channel.pre-aborted.integration.test.ts b/extensions/zalo/src/channel.pre-aborted.integration.test.ts index 53f4b9922670..55ce0465cfa4 100644 --- a/extensions/zalo/src/channel.pre-aborted.integration.test.ts +++ b/extensions/zalo/src/channel.pre-aborted.integration.test.ts @@ -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"; diff --git a/extensions/zalo/src/channel.runtime.ts b/extensions/zalo/src/channel.runtime.ts index 697a2894c3ce..d968719f0099 100644 --- a/extensions/zalo/src/channel.runtime.ts +++ b/extensions/zalo/src/channel.runtime.ts @@ -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"; diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts index 0a0813c3a770..43a9624a90a6 100644 --- a/extensions/zalo/src/group-access.ts +++ b/extensions/zalo/src/group-access.ts @@ -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, - }); -} diff --git a/extensions/zalo/src/monitor.lifecycle.test.ts b/extensions/zalo/src/monitor.lifecycle.test.ts index b48ca9d8e77f..6c85943165f4 100644 --- a/extensions/zalo/src/monitor.lifecycle.test.ts +++ b/extensions/zalo/src/monitor.lifecycle.test.ts @@ -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: "" } })); diff --git a/extensions/zalo/src/monitor.polling.media-reply.test-support.ts b/extensions/zalo/src/monitor.polling.media-reply.test-support.ts index a1a2bfec67d9..068a14f34ad4 100644 --- a/extensions/zalo/src/monitor.polling.media-reply.test-support.ts +++ b/extensions/zalo/src/monitor.polling.media-reply.test-support.ts @@ -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 { diff --git a/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts b/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts index 28a6cb8aeb80..9cbb1f6ebba8 100644 --- a/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts +++ b/extensions/zalo/src/monitor.reply-once.lifecycle.test-support.ts @@ -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, diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 3c40edab8d69..4b4fa13aba92 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -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; 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 { - 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 { - 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 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 { +async function handleImageMessage( + params: ZaloProcessingContext & { message: ZaloMessage }, +): Promise { 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({ diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index 5a47dda2d312..18d3a34f3338 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -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"; diff --git a/extensions/zalo/src/monitor.webhook.ts b/extensions/zalo/src/monitor.webhook.ts index 19ddb392b9c8..6889e6425a63 100644 --- a/extensions/zalo/src/monitor.webhook.ts +++ b/extensions/zalo/src/monitor.webhook.ts @@ -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"; diff --git a/extensions/zalo/src/outbound-media.test.ts b/extensions/zalo/src/outbound-media.test.ts index e93df48af831..34a926479b45 100644 --- a/extensions/zalo/src/outbound-media.test.ts +++ b/extensions/zalo/src/outbound-media.test.ts @@ -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()); diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index 27415fd3b0fa..972da6e61c35 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -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 { - 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, ), ); diff --git a/extensions/zalo/src/setup-allow-from.ts b/extensions/zalo/src/setup-allow-from.ts index fa2702b8f3b1..7f65ce39e359 100644 --- a/extensions/zalo/src/setup-allow-from.ts +++ b/extensions/zalo/src/setup-allow-from.ts @@ -13,10 +13,6 @@ import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js"; const t = createSetupTranslator(); -type ZaloAccountSetupConfig = { - enabled?: boolean; -}; - export async function noteZaloTokenHelp( prompter: Parameters>[0]["prompter"], ): Promise { @@ -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", diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index 58b80d2cf763..fa97bde261f2 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -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 }) => diff --git a/extensions/zalo/src/setup-status.test.ts b/extensions/zalo/src/setup-status.test.ts index 577dc0c27361..ebcbef9c4bd3 100644 --- a/extensions/zalo/src/setup-status.test.ts +++ b/extensions/zalo/src/setup-status.test.ts @@ -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({ diff --git a/extensions/zalo/src/setup-surface.test.ts b/extensions/zalo/src/setup-surface.test.ts index 444a4905157c..6fd1ce9a248c 100644 --- a/extensions/zalo/src/setup-surface.test.ts +++ b/extensions/zalo/src/setup-surface.test.ts @@ -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"; diff --git a/extensions/zalo/src/setup-surface.ts b/extensions/zalo/src/setup-surface.ts index 1e7f51dc831c..43d30081107d 100644 --- a/extensions/zalo/src/setup-surface.ts +++ b/extensions/zalo/src/setup-surface.ts @@ -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" diff --git a/extensions/zoom-meetings/src/runtime-probes.test.ts b/extensions/zoom-meetings/src/runtime-probes.test.ts index a69af9187ead..aef698605b6a 100644 --- a/extensions/zoom-meetings/src/runtime-probes.test.ts +++ b/extensions/zoom-meetings/src/runtime-probes.test.ts @@ -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[0]; +type ZoomMeetingsProbeContext = Parameters[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, diff --git a/extensions/zoom-meetings/src/runtime-probes.ts b/extensions/zoom-meetings/src/runtime-probes.ts index c5a95e4c293c..e311f1b03bf9 100644 --- a/extensions/zoom-meetings/src/runtime-probes.ts +++ b/extensions/zoom-meetings/src/runtime-probes.ts @@ -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; diff --git a/extensions/zoom-meetings/src/runtime-setup.ts b/extensions/zoom-meetings/src/runtime-setup.ts index 72f344337f56..7e512728b8f6 100644 --- a/extensions/zoom-meetings/src/runtime-setup.ts +++ b/extensions/zoom-meetings/src/runtime-setup.ts @@ -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, }); diff --git a/extensions/zoom-meetings/src/runtime.ts b/extensions/zoom-meetings/src/runtime.ts index 11934f917483..48eed48c8d9e 100644 --- a/extensions/zoom-meetings/src/runtime.ts +++ b/extensions/zoom-meetings/src/runtime.ts @@ -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>; - listening: Awaited>; - speech: Awaited>; + listening: Awaited>; + speech: Awaited>; } >({ 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. diff --git a/extensions/zoom-meetings/src/transports/chrome.test.ts b/extensions/zoom-meetings/src/transports/chrome.test.ts index 4acd53b7ede9..4eae2493f91a 100644 --- a/extensions/zoom-meetings/src/transports/chrome.test.ts +++ b/extensions/zoom-meetings/src/transports/chrome.test.ts @@ -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, }); }); diff --git a/extensions/zoom-meetings/src/transports/chrome.ts b/extensions/zoom-meetings/src/transports/chrome.ts index 70ee40daab3c..638219179e51 100644 --- a/extensions/zoom-meetings/src/transports/chrome.ts +++ b/extensions/zoom-meetings/src/transports/chrome.ts @@ -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; diff --git a/extensions/zoom-meetings/src/transports/zoom-meetings-platform-adapter.ts b/extensions/zoom-meetings/src/transports/zoom-meetings-platform-adapter.ts index 9b6b4da8ec30..57da49c46c6f 100644 --- a/extensions/zoom-meetings/src/transports/zoom-meetings-platform-adapter.ts +++ b/extensions/zoom-meetings/src/transports/zoom-meetings-platform-adapter.ts @@ -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, diff --git a/extensions/zoom-meetings/src/transports/zoom-meetings-platform-constants.ts b/extensions/zoom-meetings/src/transports/zoom-meetings-platform-constants.ts deleted file mode 100644 index 016b477edc4b..000000000000 --- a/extensions/zoom-meetings/src/transports/zoom-meetings-platform-constants.ts +++ /dev/null @@ -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", -}; diff --git a/extensions/zoom-meetings/src/transports/zoom-meetings-status-access-source.ts b/extensions/zoom-meetings/src/transports/zoom-meetings-status-access-source.ts deleted file mode 100644 index b10cfa9a224d..000000000000 --- a/extensions/zoom-meetings/src/transports/zoom-meetings-status-access-source.ts +++ /dev/null @@ -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."); - } -`; -} diff --git a/extensions/zoom-meetings/src/transports/zoom-meetings-status-page-source.ts b/extensions/zoom-meetings/src/transports/zoom-meetings-status-page-source.ts deleted file mode 100644 index 5f7dbc4a8658..000000000000 --- a/extensions/zoom-meetings/src/transports/zoom-meetings-status-page-source.ts +++ /dev/null @@ -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); -`; -} diff --git a/extensions/zoom-meetings/src/transports/zoom-meetings-status-prejoin-source.ts b/extensions/zoom-meetings/src/transports/zoom-meetings-status-prejoin-source.ts index 6d65472fd41b..aff19abc4f07 100644 --- a/extensions/zoom-meetings/src/transports/zoom-meetings-status-prejoin-source.ts +++ b/extensions/zoom-meetings/src/transports/zoom-meetings-status-prejoin-source.ts @@ -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."); }