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."); }