diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 9beb0abc2db..8cba89c67f4 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -43,6 +43,11 @@ import { resolveDiscordGroupRequireMention, resolveDiscordGroupToolPolicy, } from "./group-policy.js"; +import { + createThreadBindingManager, + setThreadBindingIdleTimeoutBySessionKey, + setThreadBindingMaxAgeBySessionKey, +} from "./monitor/thread-bindings.js"; import { looksLikeDiscordTargetId, normalizeDiscordMessagingTarget, @@ -63,6 +68,7 @@ import { type OpenClawConfig, } from "./runtime-api.js"; import { getDiscordRuntime } from "./runtime.js"; +import { collectDiscordSecurityAuditFindings } from "./security-audit.js"; import { fetchChannelPermissionsDiscord, sendMessageDiscord, sendPollDiscord } from "./send.js"; import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js"; import { discordSetupAdapter } from "./setup-core.js"; @@ -353,6 +359,42 @@ function resolveDiscordCommandConversation(params: { return conversationId ? { conversationId } : null; } +function resolveDiscordInboundConversation(params: { + from?: string; + to?: string; + conversationId?: string; + isGroup: boolean; +}) { + const rawSender = params.from?.trim() || ""; + if (!params.isGroup && rawSender) { + const senderTarget = parseDiscordTarget(rawSender, { defaultKind: "user" }); + if (senderTarget?.kind === "user") { + return { conversationId: `user:${senderTarget.id}` }; + } + } + const rawTarget = params.to?.trim() || params.conversationId?.trim() || ""; + if (!rawTarget) { + return null; + } + const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" }); + return target ? { conversationId: `${target.kind}:${target.id}` } : null; +} + +function toConversationLifecycleBinding(binding: { + boundAt: number; + lastActivityAt?: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}) { + return { + boundAt: binding.boundAt, + lastActivityAt: + typeof binding.lastActivityAt === "number" ? binding.lastActivityAt : binding.boundAt, + idleTimeoutMs: typeof binding.idleTimeoutMs === "number" ? binding.idleTimeoutMs : undefined, + maxAgeMs: typeof binding.maxAgeMs === "number" ? binding.maxAgeMs : undefined, + }; +} + function parseDiscordExplicitTarget(raw: string) { try { const target = parseDiscordTarget(raw, { defaultKind: "channel" }); @@ -401,6 +443,8 @@ export const discordPlugin: ChannelPlugin }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, + resolveInboundConversation: ({ from, to, conversationId, isGroup }) => + resolveDiscordInboundConversation({ from, to, conversationId, isGroup }), normalizeExplicitSessionKey: ({ sessionKey, ctx }) => normalizeExplicitDiscordSessionKey(sessionKey, ctx), resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`), @@ -491,6 +535,29 @@ export const discordPlugin: ChannelPlugin fallbackTo, }), }, + conversationBindings: { + supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "child", + createManager: ({ cfg, accountId }) => + createThreadBindingManager({ + cfg, + accountId: accountId ?? undefined, + persist: false, + enableSweeper: false, + }), + setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) => + setThreadBindingIdleTimeoutBySessionKey({ + targetSessionKey, + accountId: accountId ?? undefined, + idleTimeoutMs, + }).map(toConversationLifecycleBinding), + setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) => + setThreadBindingMaxAgeBySessionKey({ + targetSessionKey, + accountId: accountId ?? undefined, + maxAgeMs, + }).map(toConversationLifecycleBinding), + }, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { connected: false, @@ -718,6 +785,7 @@ export const discordPlugin: ChannelPlugin security: { resolveDmPolicy: resolveDiscordDmPolicy, collectWarnings: collectDiscordSecurityWarnings, + collectAuditFindings: collectDiscordSecurityAuditFindings, }, threading: { scopedAccountReplyToMode: { diff --git a/extensions/discord/src/doctor.test.ts b/extensions/discord/src/doctor.test.ts new file mode 100644 index 00000000000..1cafcdcb582 --- /dev/null +++ b/extensions/discord/src/doctor.test.ts @@ -0,0 +1,69 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { describe, expect, it } from "vitest"; +import { + collectDiscordNumericIdWarnings, + maybeRepairDiscordNumericIds, + scanDiscordNumericIdEntries, +} from "./doctor.js"; + +describe("discord doctor", () => { + it("finds numeric id entries across discord scopes", () => { + const cfg = { + channels: { + discord: { + allowFrom: [123], + dm: { allowFrom: ["ok"], groupChannels: [456] }, + execApprovals: { approvers: [789] }, + guilds: { + main: { + users: [111], + roles: [222], + channels: { general: { users: [333], roles: [444] } }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + + const hits = scanDiscordNumericIdEntries(cfg); + expect(hits.map((hit) => hit.path)).toEqual([ + "channels.discord.allowFrom[0]", + "channels.discord.dm.groupChannels[0]", + "channels.discord.execApprovals.approvers[0]", + "channels.discord.guilds.main.users[0]", + "channels.discord.guilds.main.roles[0]", + "channels.discord.guilds.main.channels.general.users[0]", + "channels.discord.guilds.main.channels.general.roles[0]", + ]); + }); + + it("repairs safe numeric ids into strings and warns for unsafe lists", () => { + const cfg = { + channels: { + discord: { + allowFrom: [123], + dm: { allowFrom: [99] }, + guilds: { main: { users: [111], roles: [222] } }, + }, + }, + } as unknown as OpenClawConfig; + + const result = maybeRepairDiscordNumericIds(cfg, "openclaw doctor --fix"); + expect(result.config.channels?.discord?.allowFrom).toEqual(["123"]); + expect(result.config.channels?.discord?.dm?.allowFrom).toEqual(["99"]); + expect(result.config.channels?.discord?.guilds?.main?.users).toEqual(["111"]); + expect(result.config.channels?.discord?.guilds?.main?.roles).toEqual(["222"]); + expect(result.changes).not.toHaveLength(0); + expect(result.warnings).toEqual([]); + }); + + it("formats repair guidance for unsafe numeric ids", () => { + const warnings = collectDiscordNumericIdWarnings({ + hits: [{ path: "channels.discord.allowFrom[0]", entry: 106232522769186816, safe: false }], + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(warnings[0]).toContain("cannot be auto-repaired"); + expect(warnings[1]).toContain("openclaw doctor --fix"); + }); +}); diff --git a/extensions/discord/src/doctor.ts b/extensions/discord/src/doctor.ts new file mode 100644 index 00000000000..a037c649d78 --- /dev/null +++ b/extensions/discord/src/doctor.ts @@ -0,0 +1,533 @@ +import { + type ChannelDoctorAdapter, + type ChannelDoctorConfigMutation, +} from "openclaw/plugin-sdk/channel-contract"; +import { + resolveDiscordPreviewStreamMode, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import { + collectProviderDangerousNameMatchingScopes, + isDiscordMutableAllowEntry, +} from "openclaw/plugin-sdk/runtime"; + +type DiscordNumericIdHit = { path: string; entry: number; safe: boolean }; + +type DiscordIdListRef = { + pathLabel: string; + holder: Record; + key: string; +}; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function sanitizeForLog(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); +} + +function normalizeDiscordDmAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let changed = false; + let updated: Record = params.entry; + const rawDm = updated.dm; + const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; + let dmChanged = false; + + const allowFromEqual = (a: unknown, b: unknown): boolean => { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + const na = a.map((v) => String(v).trim()).filter(Boolean); + const nb = b.map((v) => String(v).trim()).filter(Boolean); + if (na.length !== nb.length) { + return false; + } + return na.every((v, i) => v === nb[i]); + }; + + const topDmPolicy = updated.dmPolicy; + const legacyDmPolicy = dm?.policy; + if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { + updated = { ...updated, dmPolicy: legacyDmPolicy }; + changed = true; + if (dm) { + delete dm.policy; + dmChanged = true; + } + params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); + } else if ( + topDmPolicy !== undefined && + legacyDmPolicy !== undefined && + topDmPolicy === legacyDmPolicy + ) { + if (dm) { + delete dm.policy; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); + } + } + + const topAllowFrom = updated.allowFrom; + const legacyAllowFrom = dm?.allowFrom; + if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { + updated = { ...updated, allowFrom: legacyAllowFrom }; + changed = true; + if (dm) { + delete dm.allowFrom; + dmChanged = true; + } + params.changes.push( + `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, + ); + } else if ( + topAllowFrom !== undefined && + legacyAllowFrom !== undefined && + allowFromEqual(topAllowFrom, legacyAllowFrom) + ) { + if (dm) { + delete dm.allowFrom; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); + } + } + + if (dm && asObjectRecord(rawDm) && dmChanged) { + const keys = Object.keys(dm); + if (keys.length === 0) { + if (updated.dm !== undefined) { + const { dm: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); + } + } else { + updated = { ...updated, dm }; + changed = true; + } + } + + return { entry: updated, changed }; +} + +function normalizeDiscordStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let updated = params.entry; + const hadLegacyStreamMode = updated.streamMode !== undefined; + const beforeStreaming = updated.streaming; + const resolved = resolveDiscordPreviewStreamMode(updated); + const shouldNormalize = + hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + (typeof beforeStreaming === "string" && beforeStreaming !== resolved); + if (!shouldNormalize) { + return { entry: updated, changed: false }; + } + + let changed = false; + if (beforeStreaming !== resolved) { + updated = { ...updated, streaming: resolved }; + changed = true; + } + if (hadLegacyStreamMode) { + const { streamMode: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push( + `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`, + ); + } + if (typeof beforeStreaming === "boolean") { + params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`); + } else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) { + params.changes.push( + `Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`, + ); + } + if ( + params.pathPrefix.startsWith("channels.discord") && + resolved === "off" && + hadLegacyStreamMode + ) { + params.changes.push( + `${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`, + ); + } + return { entry: updated, changed }; +} + +function normalizeDiscordCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.discord); + if (!rawEntry) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updated = rawEntry; + let changed = false; + + const base = normalizeDiscordDmAliases({ + entry: rawEntry, + pathPrefix: "channels.discord", + changes, + }); + updated = base.entry; + changed = base.changed; + + const streaming = normalizeDiscordStreamingAliases({ + entry: updated, + pathPrefix: "channels.discord", + changes, + }); + updated = streaming.entry; + changed = changed || streaming.changed; + + const rawAccounts = asObjectRecord(updated.accounts); + if (rawAccounts) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + const account = asObjectRecord(rawAccount); + if (!account) { + continue; + } + let accountEntry = account; + let accountChanged = false; + const dm = normalizeDiscordDmAliases({ + entry: account, + pathPrefix: `channels.discord.accounts.${accountId}`, + changes, + }); + accountEntry = dm.entry; + accountChanged = dm.changed; + const accountStreaming = normalizeDiscordStreamingAliases({ + entry: accountEntry, + pathPrefix: `channels.discord.accounts.${accountId}`, + changes, + }); + accountEntry = accountStreaming.entry; + accountChanged = accountChanged || accountStreaming.changed; + if (accountChanged) { + accounts[accountId] = accountEntry; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + discord: updated, + } as OpenClawConfig["channels"], + }, + changes, + }; +} + +function collectDiscordAccountScopes( + cfg: OpenClawConfig, +): Array<{ prefix: string; account: Record }> { + const scopes: Array<{ prefix: string; account: Record }> = []; + const discord = asObjectRecord(cfg.channels?.discord); + if (!discord) { + return scopes; + } + + scopes.push({ prefix: "channels.discord", account: discord }); + const accounts = asObjectRecord(discord.accounts); + if (!accounts) { + return scopes; + } + for (const key of Object.keys(accounts)) { + const account = asObjectRecord(accounts[key]); + if (account) { + scopes.push({ prefix: `channels.discord.accounts.${key}`, account }); + } + } + return scopes; +} + +function collectDiscordIdLists( + prefix: string, + account: Record, +): DiscordIdListRef[] { + const refs: DiscordIdListRef[] = [ + { pathLabel: `${prefix}.allowFrom`, holder: account, key: "allowFrom" }, + ]; + const dm = asObjectRecord(account.dm); + if (dm) { + refs.push({ pathLabel: `${prefix}.dm.allowFrom`, holder: dm, key: "allowFrom" }); + refs.push({ pathLabel: `${prefix}.dm.groupChannels`, holder: dm, key: "groupChannels" }); + } + const execApprovals = asObjectRecord(account.execApprovals); + if (execApprovals) { + refs.push({ + pathLabel: `${prefix}.execApprovals.approvers`, + holder: execApprovals, + key: "approvers", + }); + } + const guilds = asObjectRecord(account.guilds); + if (!guilds) { + return refs; + } + for (const guildId of Object.keys(guilds)) { + const guild = asObjectRecord(guilds[guildId]); + if (!guild) { + continue; + } + refs.push({ pathLabel: `${prefix}.guilds.${guildId}.users`, holder: guild, key: "users" }); + refs.push({ pathLabel: `${prefix}.guilds.${guildId}.roles`, holder: guild, key: "roles" }); + const channels = asObjectRecord(guild.channels); + if (!channels) { + continue; + } + for (const channelId of Object.keys(channels)) { + const channel = asObjectRecord(channels[channelId]); + if (!channel) { + continue; + } + refs.push({ + pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.users`, + holder: channel, + key: "users", + }); + refs.push({ + pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.roles`, + holder: channel, + key: "roles", + }); + } + } + return refs; +} + +export function scanDiscordNumericIdEntries(cfg: OpenClawConfig): DiscordNumericIdHit[] { + const hits: DiscordNumericIdHit[] = []; + const scanList = (pathLabel: string, list: unknown) => { + if (!Array.isArray(list)) { + return; + } + for (const [index, entry] of list.entries()) { + if (typeof entry !== "number") { + continue; + } + hits.push({ + path: `${pathLabel}[${index}]`, + entry, + safe: Number.isSafeInteger(entry) && entry >= 0, + }); + } + }; + + for (const scope of collectDiscordAccountScopes(cfg)) { + for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) { + scanList(ref.pathLabel, ref.holder[ref.key]); + } + } + return hits; +} + +export function collectDiscordNumericIdWarnings(params: { + hits: DiscordNumericIdHit[]; + doctorFixCommand: string; +}): string[] { + if (params.hits.length === 0) { + return []; + } + const hitsByListPath = new Map(); + for (const hit of params.hits) { + const listPath = hit.path.replace(/\[\d+\]$/, ""); + const existing = hitsByListPath.get(listPath); + if (existing) { + existing.push(hit); + } else { + hitsByListPath.set(listPath, [hit]); + } + } + + const repairableHits: DiscordNumericIdHit[] = []; + const blockedHits: DiscordNumericIdHit[] = []; + for (const hits of hitsByListPath.values()) { + if (hits.some((hit) => !hit.safe)) { + blockedHits.push(...hits); + } else { + repairableHits.push(...hits); + } + } + + const lines: string[] = []; + if (repairableHits.length > 0) { + const sample = repairableHits[0]!; + lines.push( + `- Discord allowlists contain ${repairableHits.length} numeric ${repairableHits.length === 1 ? "entry" : "entries"} (e.g. ${sanitizeForLog(sample.path)}=${sanitizeForLog(String(sample.entry))}).`, + `- Discord IDs must be strings; run "${params.doctorFixCommand}" to convert numeric IDs to quoted strings.`, + ); + } + if (blockedHits.length > 0) { + const sample = blockedHits[0]!; + lines.push( + `- Discord allowlists contain ${blockedHits.length} numeric ${blockedHits.length === 1 ? "entry" : "entries"} in lists that cannot be auto-repaired (e.g. ${sanitizeForLog(sample.path)}).`, + `- These lists include invalid or precision-losing numeric IDs; manually quote the original values in your config file, then rerun "${params.doctorFixCommand}".`, + ); + } + return lines; +} + +export function maybeRepairDiscordNumericIds( + cfg: OpenClawConfig, + doctorFixCommand: string, +): { config: OpenClawConfig; changes: string[]; warnings?: string[] } { + const hits = scanDiscordNumericIdEntries(cfg); + if (hits.length === 0) { + return { config: cfg, changes: [] }; + } + + const next = structuredClone(cfg); + const changes: string[] = []; + + const repairList = (pathLabel: string, holder: Record, key: string) => { + const raw = holder[key]; + if (!Array.isArray(raw)) { + return; + } + const hasUnsafe = raw.some( + (entry) => typeof entry === "number" && (!Number.isSafeInteger(entry) || entry < 0), + ); + if (hasUnsafe) { + return; + } + let converted = 0; + holder[key] = raw.map((entry) => { + if (typeof entry === "number") { + converted += 1; + return String(entry); + } + return entry; + }); + if (converted > 0) { + changes.push( + `- ${sanitizeForLog(pathLabel)}: converted ${converted} numeric ${converted === 1 ? "ID" : "IDs"} to strings`, + ); + } + }; + + for (const scope of collectDiscordAccountScopes(next)) { + for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) { + repairList(ref.pathLabel, ref.holder, ref.key); + } + } + + if (changes.length === 0) { + return { + config: cfg, + changes: [], + warnings: collectDiscordNumericIdWarnings({ hits, doctorFixCommand }), + }; + } + return { + config: next, + changes, + warnings: collectDiscordNumericIdWarnings({ + hits: hits.filter((hit) => !hit.safe), + doctorFixCommand, + }), + }; +} + +function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] { + const hits: Array<{ path: string; entry: string }> = []; + const addHits = (pathLabel: string, list: unknown) => { + if (!Array.isArray(list)) { + return; + } + for (const entry of list) { + const text = String(entry).trim(); + if (!text || text === "*" || !isDiscordMutableAllowEntry(text)) { + continue; + } + hits.push({ path: pathLabel, entry: text }); + } + }; + + for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "discord")) { + if (scope.dangerousNameMatchingEnabled) { + continue; + } + addHits(`${scope.prefix}.allowFrom`, scope.account.allowFrom); + const dm = asObjectRecord(scope.account.dm); + if (dm) { + addHits(`${scope.prefix}.dm.allowFrom`, dm.allowFrom); + } + const guilds = asObjectRecord(scope.account.guilds); + if (!guilds) { + continue; + } + for (const [guildId, guildRaw] of Object.entries(guilds)) { + const guild = asObjectRecord(guildRaw); + if (!guild) { + continue; + } + addHits(`${scope.prefix}.guilds.${guildId}.users`, guild.users); + const channels = asObjectRecord(guild.channels); + if (!channels) { + continue; + } + for (const [channelId, channelRaw] of Object.entries(channels)) { + const channel = asObjectRecord(channelRaw); + if (channel) { + addHits(`${scope.prefix}.guilds.${guildId}.channels.${channelId}.users`, channel.users); + } + } + } + } + + if (hits.length === 0) { + return []; + } + const exampleLines = hits + .slice(0, 8) + .map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`); + const remaining = + hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null; + return [ + `- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across discord while name matching is disabled by default.`, + ...exampleLines, + ...(remaining ? [remaining] : []), + `- Option A (break-glass): enable channels.discord.dangerousNameMatching=true for the affected scope.`, + `- Option B (recommended): resolve names to stable Discord IDs and rewrite the allowlist entries.`, + ]; +} + +export const discordDoctor: ChannelDoctorAdapter = { + dmAllowFromMode: "topOrNested", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg), + collectPreviewWarnings: ({ cfg, doctorFixCommand }) => + collectDiscordNumericIdWarnings({ + hits: scanDiscordNumericIdEntries(cfg), + doctorFixCommand, + }), + collectMutableAllowlistWarnings: ({ cfg }) => collectDiscordMutableAllowlistWarnings(cfg), + repairConfig: ({ cfg, doctorFixCommand }) => maybeRepairDiscordNumericIds(cfg, doctorFixCommand), +}; diff --git a/extensions/discord/src/interactive-dispatch.ts b/extensions/discord/src/interactive-dispatch.ts new file mode 100644 index 00000000000..8c74b6f20c7 --- /dev/null +++ b/extensions/discord/src/interactive-dispatch.ts @@ -0,0 +1,104 @@ +import type { ChannelStructuredComponents } from "openclaw/plugin-sdk/channel-contract"; +import { + createInteractiveConversationBindingHelpers, + dispatchPluginInteractiveHandler, + type PluginConversationBinding, + type PluginConversationBindingRequestParams, + type PluginConversationBindingRequestResult, + type PluginInteractiveRegistration, +} from "openclaw/plugin-sdk/plugin-runtime"; + +export type DiscordInteractiveHandlerContext = { + channel: "discord"; + accountId: string; + interactionId: string; + conversationId: string; + parentConversationId?: string; + guildId?: string; + senderId?: string; + senderUsername?: string; + auth: { + isAuthorizedSender: boolean; + }; + interaction: { + kind: "button" | "select" | "modal"; + data: string; + namespace: string; + payload: string; + messageId?: string; + values?: string[]; + fields?: Array<{ id: string; name: string; values: string[] }>; + }; + respond: { + acknowledge: () => Promise; + reply: (params: { text: string; ephemeral?: boolean }) => Promise; + followUp: (params: { text: string; ephemeral?: boolean }) => Promise; + editMessage: (params: { + text?: string; + components?: ChannelStructuredComponents; + }) => Promise; + clearComponents: (params?: { text?: string }) => Promise; + }; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; +}; + +export type DiscordInteractiveHandlerRegistration = PluginInteractiveRegistration< + DiscordInteractiveHandlerContext, + "discord" +>; + +export type DiscordInteractiveDispatchContext = Omit< + DiscordInteractiveHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + DiscordInteractiveHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +export async function dispatchDiscordPluginInteractiveHandler(params: { + data: string; + interactionId: string; + ctx: DiscordInteractiveDispatchContext; + respond: DiscordInteractiveHandlerContext["respond"]; + onMatched?: () => Promise | void; +}) { + return await dispatchPluginInteractiveHandler({ + channel: "discord", + data: params.data, + dedupeId: params.interactionId, + onMatched: params.onMatched, + invoke: ({ registration, namespace, payload }) => + registration.handler({ + ...params.ctx, + channel: "discord", + interaction: { + ...params.ctx.interaction, + data: params.data, + namespace, + payload, + }, + respond: params.respond, + ...createInteractiveConversationBindingHelpers({ + registration, + senderId: params.ctx.senderId, + conversation: { + channel: "discord", + accountId: params.ctx.accountId, + conversationId: params.ctx.conversationId, + parentConversationId: params.ctx.parentConversationId, + }, + }), + }), + }); +} diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 31258a4d581..aefa938f91f 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -40,6 +40,8 @@ import { } from "../component-custom-id.js"; import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js"; import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js"; +import { type DiscordInteractiveHandlerContext } from "../interactive-dispatch.js"; +import { dispatchDiscordPluginInteractiveHandler } from "../interactive-dispatch.js"; import { editDiscordComponentMessage } from "../send.components.js"; import { AGENT_BUTTON_KEY, @@ -91,6 +93,8 @@ import { deliverDiscordReply } from "./reply-delivery.js"; let conversationRuntimePromise: Promise | undefined; let componentsRuntimePromise: Promise | undefined; +let pluginRuntimePromise: Promise | undefined; +let replyRuntimePromise: Promise | undefined; let replyPipelineRuntimePromise: | Promise | undefined; @@ -106,6 +110,15 @@ async function loadComponentsRuntime() { return await componentsRuntimePromise; } +async function loadPluginRuntime() { + pluginRuntimePromise ??= import("openclaw/plugin-sdk/plugin-runtime"); + return await pluginRuntimePromise; +} + +async function loadReplyRuntime() { + replyRuntimePromise ??= import("openclaw/plugin-sdk/reply-runtime"); + return await replyRuntimePromise; +} async function loadReplyPipelineRuntime() { replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-reply-pipeline"); return await replyPipelineRuntimePromise; @@ -191,7 +204,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: { } await params.interaction.update(payload); }; - const respond: PluginInteractiveDiscordHandlerContext["respond"] = { + const respond: DiscordInteractiveHandlerContext["respond"] = { acknowledge: async () => { if (responded) { return; @@ -279,9 +292,17 @@ async function dispatchPluginDiscordInteractiveEvent(params: { } return "handled"; } +<<<<<<< HEAD const { dispatchPluginInteractiveHandler } = await loadConversationRuntime(); const dispatched = await dispatchPluginInteractiveHandler({ channel: "discord", +||||||| parent of 75768e4d13 (refactor(plugins): move channel behavior into plugins) + const { dispatchPluginInteractiveHandler } = await loadPluginRuntime(); + const dispatched = await dispatchPluginInteractiveHandler({ + channel: "discord", +======= + const dispatched = await dispatchDiscordPluginInteractiveHandler({ +>>>>>>> 75768e4d13 (refactor(plugins): move channel behavior into plugins) data: params.data, interactionId: resolveDiscordInteractionId(params.interaction), ctx: { diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 4da01d29664..81dea458331 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -10,6 +10,7 @@ import { type ResolvedDiscordAccount, } from "./accounts.js"; import { DiscordChannelConfigSchema } from "./config-schema.js"; +import { discordDoctor } from "./doctor.js"; import { createScopedChannelConfigAdapter, getChatChannelMeta, @@ -47,6 +48,8 @@ export function createDiscordPluginBase(params: { | "meta" | "setupWizard" | "capabilities" + | "commands" + | "doctor" | "streaming" | "reload" | "configSchema" @@ -65,6 +68,13 @@ export function createDiscordPluginBase(params: { media: true, nativeCommands: true, }, + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + resolveNativeCommandName: ({ commandKey, defaultName }) => + commandKey === "tts" ? "voice" : defaultName, + }, + doctor: discordDoctor, streaming: { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, @@ -89,6 +99,8 @@ export function createDiscordPluginBase(params: { | "meta" | "setupWizard" | "capabilities" + | "commands" + | "doctor" | "streaming" | "reload" | "configSchema" diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 7bf1383e593..388501cbb82 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -565,6 +565,9 @@ export const feishuPlugin: ChannelPlugin ['[^<]*'], }, diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index be656c1ce7c..68c4db4e014 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -18,6 +18,7 @@ import { } from "openclaw/plugin-sdk/directory-runtime"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, @@ -212,6 +213,12 @@ export const googlechatPlugin = createChatChannelPlugin({ }, }, actions: googlechatActions, + doctor: { + dmAllowFromMode: "nestedOnly", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + }, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), collectStatusIssues: (accounts): ChannelStatusIssue[] => @@ -355,6 +362,7 @@ export const googlechatPlugin = createChatChannelPlugin({ chunker: chunkTextForOutbound, chunkerMode: "markdown", textChunkLimit: 4000, + sanitizeText: ({ text }) => sanitizeForPlainText(text), resolveTarget: ({ to }) => { const trimmed = to?.trim() ?? ""; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 89726ae9e8b..fd177b260f5 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -2,6 +2,7 @@ import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowli import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; +import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime"; import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { @@ -240,6 +241,7 @@ export const imessagePlugin: ChannelPlugin sanitizeForPlainText(text), }, attachedResults: { channel: "imessage", diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 2179a6a6446..08b18d0f771 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -16,6 +16,7 @@ import { createResolvedDirectoryEntriesLister, } from "openclaw/plugin-sdk/directory-runtime"; import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared"; +import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, @@ -337,6 +338,7 @@ export const ircPlugin: ChannelPlugin = createChat chunker: chunkTextForOutbound, chunkerMode: "markdown", textChunkLimit: 350, + sanitizeText: ({ text }) => sanitizeForPlainText(text), }, attachedResults: { channel: "irc", diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index fd5b063cc11..203f16303d2 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -35,6 +35,12 @@ function resolveLineCommandConversation(params: { return conversationId ? { conversationId } : null; } +function resolveLineInboundConversation(params: { to?: string; conversationId?: string }) { + const conversationId = + normalizeLineConversationId(params.conversationId) ?? normalizeLineConversationId(params.to); + return conversationId ? { conversationId } : null; +} + const lineSecurityAdapter = createRestrictSendersChannelSecurity({ channelKey: "line", resolveDmPolicy: (account) => account.config.dmPolicy, @@ -66,6 +72,8 @@ export const linePlugin: ChannelPlugin = createChatChannelP } return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, ""); }, + resolveInboundConversation: ({ to, conversationId }) => + resolveLineInboundConversation({ to, conversationId }), targetResolver: { looksLikeId: (id) => { const trimmed = id?.trim(); @@ -103,6 +111,9 @@ export const linePlugin: ChannelPlugin = createChatChannelP fallbackTo, }), }, + conversationBindings: { + defaultTopLevelPlacement: "current", + }, agentPrompt: { messageToolHints: () => [ "", diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 3c4cb78b644..b0bba9fd66c 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -40,7 +40,8 @@ "install": { "npmSpec": "@openclaw/matrix", "defaultChoice": "npm", - "minHostVersion": ">=2026.4.1" + "minHostVersion": ">=2026.4.1", + "allowInvalidConfigRecovery": true }, "releaseChecks": { "rootDependencyMirrorAllowlist": [ diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 6f2ed210912..f5c3af310d4 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -37,6 +37,7 @@ import { import { matrixMessageActions } from "./actions.js"; import { matrixApprovalCapability } from "./approval-native.js"; import { MatrixConfigSchema } from "./config-schema.js"; +import { matrixDoctor } from "./doctor.js"; import { shouldSuppressLocalMatrixExecApprovalPrompt } from "./exec-approvals.js"; import { resolveMatrixGroupRequireMention, @@ -64,6 +65,7 @@ import { getMatrixRuntime } from "./runtime.js"; import { resolveMatrixOutboundSessionRoute } from "./session-route.js"; import { matrixSetupAdapter } from "./setup-core.js"; import { matrixSetupWizard } from "./setup-surface.js"; +import { runMatrixStartupMaintenance } from "./startup-maintenance.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) @@ -286,6 +288,46 @@ function resolveMatrixCommandConversation(params: { return parentConversationId ? { conversationId: parentConversationId } : null; } +function resolveMatrixInboundConversation(params: { + to?: string; + conversationId?: string; + threadId?: string | number; +}) { + const rawTarget = params.to?.trim() || params.conversationId?.trim() || ""; + const target = rawTarget ? resolveMatrixTargetIdentity(rawTarget) : null; + const parentConversationId = target?.kind === "room" ? target.id : undefined; + const threadId = + params.threadId != null ? String(params.threadId).trim() || undefined : undefined; + if (threadId) { + return { + conversationId: threadId, + ...(parentConversationId ? { parentConversationId } : {}), + }; + } + return parentConversationId ? { conversationId: parentConversationId } : null; +} + +function resolveMatrixDeliveryTarget(params: { + conversationId: string; + parentConversationId?: string; +}) { + const parentConversationId = params.parentConversationId?.trim(); + if (parentConversationId && parentConversationId !== params.conversationId.trim()) { + const parentTarget = resolveMatrixTargetIdentity(parentConversationId); + if (parentTarget?.kind === "room") { + return { + to: `room:${parentTarget.id}`, + threadId: params.conversationId.trim(), + }; + } + } + const conversationTarget = resolveMatrixTargetIdentity(params.conversationId); + if (conversationTarget?.kind === "room") { + return { to: `room:${conversationTarget.id}` }; + } + return null; +} + export const matrixPlugin: ChannelPlugin = createChatChannelPlugin({ base: { @@ -320,6 +362,7 @@ export const matrixPlugin: ChannelPlugin = }, conversationBindings: { supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "child", setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) => setMatrixThreadBindingIdleTimeoutBySessionKey({ targetSessionKey, @@ -363,6 +406,10 @@ export const matrixPlugin: ChannelPlugin = }, messaging: { normalizeTarget: normalizeMatrixMessagingTarget, + resolveInboundConversation: ({ to, conversationId, threadId }) => + resolveMatrixInboundConversation({ to, conversationId, threadId }), + resolveDeliveryTarget: ({ conversationId, parentConversationId }) => + resolveMatrixDeliveryTarget({ conversationId, parentConversationId }), resolveOutboundSessionRoute: (params) => resolveMatrixOutboundSessionRoute(params), targetResolver: { looksLikeId: (raw) => { @@ -511,6 +558,10 @@ export const matrixPlugin: ChannelPlugin = }); }, }, + doctor: matrixDoctor, + lifecycle: { + runStartupMaintenance: runMatrixStartupMaintenance, + }, }, security: { resolveDmPolicy: resolveMatrixDmPolicy, diff --git a/extensions/matrix/src/doctor.test.ts b/extensions/matrix/src/doctor.test.ts new file mode 100644 index 00000000000..0bb2a3bfcec --- /dev/null +++ b/extensions/matrix/src/doctor.test.ts @@ -0,0 +1,120 @@ +import fs from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + applyMatrixDoctorRepair, + cleanStaleMatrixPluginConfig, + collectMatrixInstallPathWarnings, + formatMatrixLegacyCryptoPreview, + formatMatrixLegacyStatePreview, + runMatrixDoctorSequence, +} from "./doctor.js"; + +vi.mock("./runtime-api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasActionableMatrixMigration: vi.fn(() => false), + hasPendingMatrixMigration: vi.fn(() => false), + maybeCreateMatrixMigrationSnapshot: vi.fn(), + autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })), + autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })), + }; +}); + +describe("matrix doctor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("formats state and crypto previews", () => { + expect( + formatMatrixLegacyStatePreview({ + accountId: "default", + legacyStoragePath: "/tmp/legacy-sync.json", + targetStoragePath: "/tmp/new-sync.json", + legacyCryptoPath: "/tmp/legacy-crypto.json", + targetCryptoPath: "/tmp/new-crypto.json", + selectionNote: "Picked the newest account.", + targetRootDir: "/tmp/account-root", + }), + ).toContain("Matrix plugin upgraded in place."); + + const previews = formatMatrixLegacyCryptoPreview({ + warnings: ["matrix warning"], + plans: [ + { + accountId: "default", + rootDir: "/tmp/account-root", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + deviceId: "DEVICE123", + legacyCryptoPath: "/tmp/legacy-crypto.json", + recoveryKeyPath: "/tmp/recovery-key.txt", + statePath: "/tmp/state.json", + }, + ], + }); + expect(previews[0]).toBe("- matrix warning"); + expect(previews[1]).toContain("/tmp/recovery-key.txt"); + }); + + it("warns on stale custom Matrix plugin paths and cleans them", async () => { + const missingPath = path.join(tmpdir(), `openclaw-matrix-missing-${Date.now()}`); + await fs.rm(missingPath, { recursive: true, force: true }); + + const warnings = await collectMatrixInstallPathWarnings({ + plugins: { + installs: { + matrix: { source: "path", sourcePath: missingPath, installPath: missingPath }, + }, + }, + }); + expect(warnings[0]).toContain("custom path that no longer exists"); + + const cleaned = await cleanStaleMatrixPluginConfig({ + plugins: { + installs: { + matrix: { source: "path", sourcePath: missingPath, installPath: missingPath }, + }, + load: { paths: [missingPath, "/other/path"] }, + allow: ["matrix", "other-plugin"], + }, + }); + expect(cleaned.changes[0]).toContain("Removed stale Matrix plugin references"); + expect(cleaned.config.plugins?.load?.paths).toEqual(["/other/path"]); + expect(cleaned.config.plugins?.allow).toEqual(["other-plugin"]); + }); + + it("surfaces matrix sequence warnings and repair changes", async () => { + const runtimeApi = await import("./runtime-api.js"); + vi.mocked(runtimeApi.hasActionableMatrixMigration).mockReturnValue(true); + vi.mocked(runtimeApi.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({ + archivePath: "/tmp/matrix-backup.tgz", + created: true, + markerPath: "/tmp/marker.json", + }); + vi.mocked(runtimeApi.autoMigrateLegacyMatrixState).mockResolvedValue({ + migrated: true, + changes: ["Migrated legacy sync state"], + warnings: [], + }); + vi.mocked(runtimeApi.autoPrepareLegacyMatrixCrypto).mockResolvedValue({ + migrated: true, + changes: ["Prepared recovery key export"], + warnings: [], + }); + + const repair = await applyMatrixDoctorRepair({ cfg: {}, env: process.env }); + expect(repair.changes.join("\n")).toContain("Matrix migration snapshot"); + + const sequence = await runMatrixDoctorSequence({ + cfg: {}, + env: process.env, + shouldRepair: true, + }); + expect(sequence.changeNotes.join("\n")).toContain("Matrix migration snapshot"); + }); +}); diff --git a/src/commands/doctor/providers/matrix.ts b/extensions/matrix/src/doctor.ts similarity index 74% rename from src/commands/doctor/providers/matrix.ts rename to extensions/matrix/src/doctor.ts index 6acf01c5e12..30997a9aac1 100644 --- a/src/commands/doctor/providers/matrix.ts +++ b/extensions/matrix/src/doctor.ts @@ -1,26 +1,51 @@ -import { formatCliCommand } from "../../../cli/command-format.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { - autoPrepareLegacyMatrixCrypto, - detectLegacyMatrixCrypto, -} from "../../../infra/matrix-legacy-crypto.js"; -import { - autoMigrateLegacyMatrixState, - detectLegacyMatrixState, -} from "../../../infra/matrix-legacy-state.js"; -import { - hasActionableMatrixMigration, - hasPendingMatrixMigration, - maybeCreateMatrixMigrationSnapshot, -} from "../../../infra/matrix-migration-snapshot.js"; +import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { detectPluginInstallPathIssue, formatPluginInstallPathIssue, -} from "../../../infra/plugin-install-path-warnings.js"; -import { resolveBundledPluginInstallCommandHint } from "../../../plugins/bundled-sources.js"; -import { removePluginFromConfig } from "../../../plugins/uninstall.js"; -import { isRecord } from "../../../utils.js"; -import type { DoctorConfigMutationResult } from "../shared/config-mutation-state.js"; + removePluginFromConfig, +} from "openclaw/plugin-sdk/runtime"; +import { + autoMigrateLegacyMatrixState, + autoPrepareLegacyMatrixCrypto, + detectLegacyMatrixCrypto, + detectLegacyMatrixState, + hasActionableMatrixMigration, + hasPendingMatrixMigration, + maybeCreateMatrixMigrationSnapshot, +} from "./runtime-api.js"; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean { + const channels = cfg.channels as Record | undefined; + return isRecord(channels?.matrix); +} + +function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean { + return Boolean( + cfg.plugins?.installs?.matrix || + cfg.plugins?.entries?.matrix || + cfg.plugins?.allow?.includes("matrix") || + cfg.plugins?.deny?.includes("matrix"), + ); +} + +function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean { + return Object.entries(env).some( + ([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(), + ); +} + +function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + return ( + hasConfiguredMatrixChannel(cfg) || + hasConfiguredMatrixPluginSurface(cfg) || + hasConfiguredMatrixEnv(env) + ); +} export function formatMatrixLegacyStatePreview( detection: Exclude, null | { warning: string }>, @@ -67,51 +92,10 @@ export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Pro issue, pluginLabel: "Matrix", defaultInstallCommand: "openclaw plugins install @openclaw/matrix", - repoInstallCommand: resolveBundledPluginInstallCommandHint({ - pluginId: "matrix", - workspaceDir: process.cwd(), - }), - formatCommand: formatCliCommand, }).map((entry) => `- ${entry}`); } -function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean { - const channels = cfg.channels as Record | undefined; - return isRecord(channels?.matrix); -} - -function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean { - return Boolean( - cfg.plugins?.installs?.matrix || - cfg.plugins?.entries?.matrix || - cfg.plugins?.allow?.includes("matrix") || - cfg.plugins?.deny?.includes("matrix"), - ); -} - -function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean { - return Object.entries(env).some( - ([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(), - ); -} - -function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { - return ( - hasConfiguredMatrixChannel(cfg) || - hasConfiguredMatrixPluginSurface(cfg) || - hasConfiguredMatrixEnv(env) - ); -} - -/** - * Produces a config mutation that removes stale Matrix plugin install/load-path - * references left behind by the old bundled-plugin layout. When the install - * record points to a path that no longer exists on disk the config entry blocks - * validation, so removing it lets reinstall proceed cleanly. - */ -export async function cleanStaleMatrixPluginConfig( - cfg: OpenClawConfig, -): Promise { +export async function cleanStaleMatrixPluginConfig(cfg: OpenClawConfig) { const issue = await detectPluginInstallPathIssue({ pluginId: "matrix", install: cfg.plugins?.installs?.matrix, @@ -139,8 +123,7 @@ export async function cleanStaleMatrixPluginConfig( return { config, changes: [ - `Removed stale Matrix plugin references (${removed.join(", ")}). ` + - `The previous install path no longer exists: ${issue.path}`, + `Removed stale Matrix plugin references (${removed.join(", ")}). The previous install path no longer exists: ${issue.path}`, ], }; } @@ -170,9 +153,11 @@ export async function applyMatrixDoctorRepair(params: { changes.push( `Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`, ); - } catch (err) { + } catch (error) { matrixSnapshotReady = false; - warnings.push(`- Failed creating a Matrix migration snapshot before repair: ${String(err)}`); + warnings.push( + `- Failed creating a Matrix migration snapshot before repair: ${String(error)}`, + ); warnings.push( '- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".', ); @@ -230,44 +215,51 @@ export async function runMatrixDoctorSequence(params: { }): Promise<{ changeNotes: string[]; warningNotes: string[] }> { const warningNotes: string[] = []; const changeNotes: string[] = []; - const matrixInstallWarnings = await collectMatrixInstallPathWarnings(params.cfg); - if (matrixInstallWarnings.length > 0) { - warningNotes.push(matrixInstallWarnings.join("\n")); + const installWarnings = await collectMatrixInstallPathWarnings(params.cfg); + if (installWarnings.length > 0) { + warningNotes.push(installWarnings.join("\n")); } if (!configMayNeedMatrixDoctorSequence(params.cfg, params.env)) { return { changeNotes, warningNotes }; } - const matrixLegacyState = detectLegacyMatrixState({ + const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env: params.env, }); - const matrixLegacyCrypto = detectLegacyMatrixCrypto({ + const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env: params.env, }); if (params.shouldRepair) { - const matrixRepair = await applyMatrixDoctorRepair({ + const repair = await applyMatrixDoctorRepair({ cfg: params.cfg, env: params.env, }); - changeNotes.push(...matrixRepair.changes); - warningNotes.push(...matrixRepair.warnings); - } else if (matrixLegacyState) { - if ("warning" in matrixLegacyState) { - warningNotes.push(`- ${matrixLegacyState.warning}`); + changeNotes.push(...repair.changes); + warningNotes.push(...repair.warnings); + } else if (legacyState) { + if ("warning" in legacyState) { + warningNotes.push(`- ${legacyState.warning}`); } else { - warningNotes.push(formatMatrixLegacyStatePreview(matrixLegacyState)); + warningNotes.push(formatMatrixLegacyStatePreview(legacyState)); } } - if ( - !params.shouldRepair && - (matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0) - ) { - warningNotes.push(...formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)); + if (!params.shouldRepair && (legacyCrypto.warnings.length > 0 || legacyCrypto.plans.length > 0)) { + warningNotes.push(...formatMatrixLegacyCryptoPreview(legacyCrypto)); } return { changeNotes, warningNotes }; } + +export const matrixDoctor: ChannelDoctorAdapter = { + dmAllowFromMode: "nestedOnly", + groupModel: "sender", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: true, + runConfigSequence: async ({ cfg, env, shouldRepair }) => + await runMatrixDoctorSequence({ cfg, env, shouldRepair }), + cleanStaleConfig: async ({ cfg }) => await cleanStaleMatrixPluginConfig(cfg), +}; diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index 566759899a3..be7d644b146 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -27,8 +27,14 @@ export { type SsrFPolicy, } from "openclaw/plugin-sdk/ssrf-runtime"; export { + autoMigrateLegacyMatrixState, + autoPrepareLegacyMatrixCrypto, + detectLegacyMatrixCrypto, + detectLegacyMatrixState, dispatchReplyFromConfigWithSettledDispatcher, ensureConfiguredAcpBindingReady, + hasActionableMatrixMigration, + hasPendingMatrixMigration, maybeCreateMatrixMigrationSnapshot, resolveConfiguredAcpBindingRecord, } from "openclaw/plugin-sdk/matrix-runtime-heavy"; diff --git a/src/gateway/server-startup-matrix-migration.test.ts b/extensions/matrix/src/startup-maintenance.test.ts similarity index 93% rename from src/gateway/server-startup-matrix-migration.test.ts rename to extensions/matrix/src/startup-maintenance.test.ts index da941f89043..1dab534a7f4 100644 --- a/src/gateway/server-startup-matrix-migration.test.ts +++ b/extensions/matrix/src/startup-maintenance.test.ts @@ -1,8 +1,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { withTempHome } from "../../test/helpers/temp-home.js"; -import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js"; +import { withTempHome } from "../../../test/helpers/temp-home.js"; +import { runMatrixStartupMaintenance } from "./startup-maintenance.js"; async function seedLegacyMatrixState(home: string) { const stateDir = path.join(home, ".openclaw"); @@ -23,7 +23,7 @@ function makeMatrixStartupConfig(includeCredentials = true) { homeserver: "https://matrix.example.org", }, }, - } as never; + } as const; } function createSuccessfulMatrixMigrationDeps() { @@ -41,7 +41,7 @@ function createSuccessfulMatrixMigrationDeps() { }; } -describe("runStartupMatrixMigration", () => { +describe("runMatrixStartupMaintenance", () => { it("creates a snapshot before actionable startup migration", async () => { await withTempHome(async (home) => { await seedLegacyMatrixState(home); @@ -52,7 +52,7 @@ describe("runStartupMatrixMigration", () => { warnings: [], })); - await runStartupMatrixMigration({ + await runMatrixStartupMaintenance({ cfg: makeMatrixStartupConfig(), env: process.env, deps: { @@ -79,7 +79,7 @@ describe("runStartupMatrixMigration", () => { const autoPrepareLegacyMatrixCryptoMock = vi.fn(); const info = vi.fn(); - await runStartupMatrixMigration({ + await runMatrixStartupMaintenance({ cfg: makeMatrixStartupConfig(false), env: process.env, deps: { @@ -109,7 +109,7 @@ describe("runStartupMatrixMigration", () => { const autoPrepareLegacyMatrixCryptoMock = vi.fn(); const warn = vi.fn(); - await runStartupMatrixMigration({ + await runMatrixStartupMaintenance({ cfg: makeMatrixStartupConfig(), env: process.env, deps: { @@ -138,7 +138,7 @@ describe("runStartupMatrixMigration", () => { const warn = vi.fn(); await expect( - runStartupMatrixMigration({ + runMatrixStartupMaintenance({ cfg: makeMatrixStartupConfig(), env: process.env, deps: { diff --git a/src/gateway/server-startup-matrix-migration.ts b/extensions/matrix/src/startup-maintenance.ts similarity index 85% rename from src/gateway/server-startup-matrix-migration.ts rename to extensions/matrix/src/startup-maintenance.ts index 0db6bc5be59..cb52b5a01a8 100644 --- a/src/gateway/server-startup-matrix-migration.ts +++ b/extensions/matrix/src/startup-maintenance.ts @@ -1,20 +1,20 @@ -import type { OpenClawConfig } from "../config/config.js"; -import { autoPrepareLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js"; -import { autoMigrateLegacyMatrixState } from "../infra/matrix-legacy-state.js"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { + autoMigrateLegacyMatrixState, + autoPrepareLegacyMatrixCrypto, hasActionableMatrixMigration, hasPendingMatrixMigration, maybeCreateMatrixMigrationSnapshot, -} from "../infra/matrix-migration-snapshot.js"; +} from "openclaw/plugin-sdk/matrix-runtime-heavy"; -type MatrixMigrationLogger = { +type MatrixStartupLogger = { info?: (message: string) => void; warn?: (message: string) => void; }; async function runBestEffortMatrixMigrationStep(params: { label: string; - log: MatrixMigrationLogger; + log: MatrixStartupLogger; logPrefix?: string; run: () => Promise; }): Promise { @@ -27,10 +27,10 @@ async function runBestEffortMatrixMigrationStep(params: { } } -export async function runStartupMatrixMigration(params: { +export async function runMatrixStartupMaintenance(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; - log: MatrixMigrationLogger; + log: MatrixStartupLogger; trigger?: string; logPrefix?: string; deps?: { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 3b79012feca..25e8e360431 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -383,6 +383,12 @@ export const msteamsPlugin: ChannelPlugin = crea }); }, }, + mentions: { + stripPatterns: () => ["<@[^>\\s]+>"], + }, }, pairing: { text: { @@ -488,6 +492,7 @@ export const slackPlugin: ChannelPlugin = crea security: { resolveDmPolicy: resolveSlackDmPolicy, collectWarnings: collectSlackSecurityWarnings, + collectAuditFindings: collectSlackSecurityAuditFindings, }, threading: { scopedAccountReplyToMode: { diff --git a/extensions/slack/src/doctor.ts b/extensions/slack/src/doctor.ts new file mode 100644 index 00000000000..fd917df40b5 --- /dev/null +++ b/extensions/slack/src/doctor.ts @@ -0,0 +1,304 @@ +import { + type ChannelDoctorAdapter, + type ChannelDoctorConfigMutation, +} from "openclaw/plugin-sdk/channel-contract"; +import { + formatSlackStreamingBooleanMigrationMessage, + formatSlackStreamModeMigrationMessage, + resolveSlackNativeStreaming, + resolveSlackStreamingMode, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import { + collectProviderDangerousNameMatchingScopes, + isSlackMutableAllowEntry, +} from "openclaw/plugin-sdk/runtime"; + +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function sanitizeForLog(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); +} + +function normalizeSlackDmAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let changed = false; + let updated: Record = params.entry; + const rawDm = updated.dm; + const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record) : null; + let dmChanged = false; + + const allowFromEqual = (a: unknown, b: unknown): boolean => { + if (!Array.isArray(a) || !Array.isArray(b)) { + return false; + } + const na = a.map((v) => String(v).trim()).filter(Boolean); + const nb = b.map((v) => String(v).trim()).filter(Boolean); + if (na.length !== nb.length) { + return false; + } + return na.every((v, i) => v === nb[i]); + }; + + const topDmPolicy = updated.dmPolicy; + const legacyDmPolicy = dm?.policy; + if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { + updated = { ...updated, dmPolicy: legacyDmPolicy }; + changed = true; + if (dm) { + delete dm.policy; + dmChanged = true; + } + params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); + } else if ( + topDmPolicy !== undefined && + legacyDmPolicy !== undefined && + topDmPolicy === legacyDmPolicy + ) { + if (dm) { + delete dm.policy; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); + } + } + + const topAllowFrom = updated.allowFrom; + const legacyAllowFrom = dm?.allowFrom; + if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { + updated = { ...updated, allowFrom: legacyAllowFrom }; + changed = true; + if (dm) { + delete dm.allowFrom; + dmChanged = true; + } + params.changes.push( + `Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`, + ); + } else if ( + topAllowFrom !== undefined && + legacyAllowFrom !== undefined && + allowFromEqual(topAllowFrom, legacyAllowFrom) + ) { + if (dm) { + delete dm.allowFrom; + dmChanged = true; + params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); + } + } + + if (dm && asObjectRecord(rawDm) && dmChanged) { + const keys = Object.keys(dm); + if (keys.length === 0) { + if (updated.dm !== undefined) { + const { dm: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); + } + } else { + updated = { ...updated, dm }; + changed = true; + } + } + + return { entry: updated, changed }; +} + +function normalizeSlackStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let updated = params.entry; + const hadLegacyStreamMode = updated.streamMode !== undefined; + const legacyStreaming = updated.streaming; + const beforeStreaming = updated.streaming; + const beforeNativeStreaming = updated.nativeStreaming; + const resolvedStreaming = resolveSlackStreamingMode(updated); + const resolvedNativeStreaming = resolveSlackNativeStreaming(updated); + const shouldNormalize = + hadLegacyStreamMode || + typeof legacyStreaming === "boolean" || + (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming); + if (!shouldNormalize) { + return { entry: updated, changed: false }; + } + + let changed = false; + if (beforeStreaming !== resolvedStreaming) { + updated = { ...updated, streaming: resolvedStreaming }; + changed = true; + } + if ( + typeof beforeNativeStreaming !== "boolean" || + beforeNativeStreaming !== resolvedNativeStreaming + ) { + updated = { ...updated, nativeStreaming: resolvedNativeStreaming }; + changed = true; + } + if (hadLegacyStreamMode) { + const { streamMode: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push( + formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming), + ); + } + if (typeof legacyStreaming === "boolean") { + params.changes.push( + formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming), + ); + } else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) { + params.changes.push( + `Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`, + ); + } + + return { entry: updated, changed }; +} + +function normalizeSlackCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.slack); + if (!rawEntry) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updated = rawEntry; + let changed = false; + + const base = normalizeSlackDmAliases({ + entry: rawEntry, + pathPrefix: "channels.slack", + changes, + }); + updated = base.entry; + changed = base.changed; + + const baseStreaming = normalizeSlackStreamingAliases({ + entry: updated, + pathPrefix: "channels.slack", + changes, + }); + updated = baseStreaming.entry; + changed = changed || baseStreaming.changed; + + const rawAccounts = asObjectRecord(updated.accounts); + if (rawAccounts) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + const account = asObjectRecord(rawAccount); + if (!account) { + continue; + } + let accountEntry = account; + let accountChanged = false; + const dm = normalizeSlackDmAliases({ + entry: account, + pathPrefix: `channels.slack.accounts.${accountId}`, + changes, + }); + accountEntry = dm.entry; + accountChanged = dm.changed; + const streaming = normalizeSlackStreamingAliases({ + entry: accountEntry, + pathPrefix: `channels.slack.accounts.${accountId}`, + changes, + }); + accountEntry = streaming.entry; + accountChanged = accountChanged || streaming.changed; + if (accountChanged) { + accounts[accountId] = accountEntry; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + slack: updated as unknown as NonNullable["slack"], + } as OpenClawConfig["channels"], + }, + changes, + }; +} + +export function collectSlackMutableAllowlistWarnings(cfg: OpenClawConfig): string[] { + const hits: Array<{ path: string; entry: string }> = []; + const addHits = (pathLabel: string, list: unknown) => { + if (!Array.isArray(list)) { + return; + } + for (const entry of list) { + const text = String(entry).trim(); + if (!text || text === "*" || !isSlackMutableAllowEntry(text)) { + continue; + } + hits.push({ path: pathLabel, entry: text }); + } + }; + + for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "slack")) { + if (scope.dangerousNameMatchingEnabled) { + continue; + } + addHits(`${scope.prefix}.allowFrom`, scope.account.allowFrom); + const dm = asObjectRecord(scope.account.dm); + if (dm) { + addHits(`${scope.prefix}.dm.allowFrom`, dm.allowFrom); + } + const channels = asObjectRecord(scope.account.channels); + if (!channels) { + continue; + } + for (const [channelKey, channelRaw] of Object.entries(channels)) { + const channel = asObjectRecord(channelRaw); + if (channel) { + addHits(`${scope.prefix}.channels.${channelKey}.users`, channel.users); + } + } + } + + if (hits.length === 0) { + return []; + } + const exampleLines = hits + .slice(0, 8) + .map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`); + const remaining = + hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null; + return [ + `- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across slack while name matching is disabled by default.`, + ...exampleLines, + ...(remaining ? [remaining] : []), + "- Option A (break-glass): enable channels.slack.dangerousNameMatching=true for the affected scope.", + "- Option B (recommended): resolve names to stable Slack IDs and rewrite the allowlist entries.", + ]; +} + +export const slackDoctor: ChannelDoctorAdapter = { + dmAllowFromMode: "topOrNested", + groupModel: "route", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + normalizeCompatibilityConfig: ({ cfg }) => normalizeSlackCompatibilityConfig(cfg), + collectMutableAllowlistWarnings: ({ cfg }) => collectSlackMutableAllowlistWarnings(cfg), +}; diff --git a/extensions/slack/src/interactive-dispatch.ts b/extensions/slack/src/interactive-dispatch.ts new file mode 100644 index 00000000000..cfdc228167d --- /dev/null +++ b/extensions/slack/src/interactive-dispatch.ts @@ -0,0 +1,109 @@ +import { + createInteractiveConversationBindingHelpers, + dispatchPluginInteractiveHandler, + type PluginConversationBinding, + type PluginConversationBindingRequestParams, + type PluginConversationBindingRequestResult, + type PluginInteractiveRegistration, +} from "openclaw/plugin-sdk/plugin-runtime"; + +export type SlackInteractiveHandlerContext = { + channel: "slack"; + accountId: string; + interactionId: string; + conversationId: string; + parentConversationId?: string; + senderId?: string; + senderUsername?: string; + threadId?: string; + auth: { + isAuthorizedSender: boolean; + }; + interaction: { + kind: "button" | "select"; + data: string; + namespace: string; + payload: string; + actionId: string; + blockId?: string; + messageTs?: string; + threadTs?: string; + value?: string; + selectedValues?: string[]; + selectedLabels?: string[]; + triggerId?: string; + responseUrl?: string; + }; + respond: { + acknowledge: () => Promise; + reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise; + followUp: (params: { + text: string; + responseType?: "ephemeral" | "in_channel"; + }) => Promise; + editMessage: (params: { text?: string; blocks?: unknown[] }) => Promise; + }; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; +}; + +export type SlackInteractiveHandlerRegistration = PluginInteractiveRegistration< + SlackInteractiveHandlerContext, + "slack" +>; + +export type SlackInteractiveDispatchContext = Omit< + SlackInteractiveHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + interaction: Omit< + SlackInteractiveHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; +}; + +export async function dispatchSlackPluginInteractiveHandler(params: { + data: string; + interactionId: string; + ctx: SlackInteractiveDispatchContext; + respond: SlackInteractiveHandlerContext["respond"]; + onMatched?: () => Promise | void; +}) { + return await dispatchPluginInteractiveHandler({ + channel: "slack", + data: params.data, + dedupeId: params.interactionId, + onMatched: params.onMatched, + invoke: ({ registration, namespace, payload }) => + registration.handler({ + ...params.ctx, + channel: "slack", + interaction: { + ...params.ctx.interaction, + data: params.data, + namespace, + payload, + }, + respond: params.respond, + ...createInteractiveConversationBindingHelpers({ + registration, + senderId: params.ctx.senderId, + conversation: { + channel: "slack", + accountId: params.ctx.accountId, + conversationId: params.ctx.conversationId, + parentConversationId: params.ctx.parentConversationId, + threadId: params.ctx.threadId, + }, + }), + }), + }); +} diff --git a/extensions/slack/src/interactive-replies.test.ts b/extensions/slack/src/interactive-replies.test.ts index 4a4b75fb954..10b97f6a560 100644 --- a/extensions/slack/src/interactive-replies.test.ts +++ b/extensions/slack/src/interactive-replies.test.ts @@ -1,39 +1,96 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { describe, expect, it } from "vitest"; -import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; +import { compileSlackInteractiveReplies } from "./interactive-replies.js"; -describe("isSlackInteractiveRepliesEnabled", () => { - it("uses the configured default account when accountId is unknown and multiple accounts exist", () => { - const cfg = { - channels: { - slack: { - defaultAccount: "one", - accounts: { - one: { - capabilities: { interactiveReplies: true }, - }, - two: {}, - }, +describe("compileSlackInteractiveReplies", () => { + it("compiles inline Slack button directives into shared interactive blocks", () => { + const result = compileSlackInteractiveReplies({ + text: "[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]", + }); + + expect(result.text).toBe("[bot] hello"); + expect(result.interactive).toEqual({ + blocks: [ + { + type: "text", + text: "[bot] hello", }, - }, - } as OpenClawConfig; - - expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(true); + { + type: "buttons", + buttons: [ + { + label: "Retry", + value: "retry", + }, + { + label: "Ignore", + value: "ignore", + }, + ], + }, + ], + }); }); - it("uses the only configured account when accountId is unknown", () => { - const cfg = { - channels: { - slack: { - accounts: { - only: { - capabilities: { interactiveReplies: true }, - }, - }, - }, - }, - } as OpenClawConfig; + it("compiles simple trailing Options lines into Slack buttons", () => { + const result = compileSlackInteractiveReplies({ + text: "Current verbose level: off.\nOptions: on, full, off.", + }); - expect(isSlackInteractiveRepliesEnabled({ cfg, accountId: undefined })).toBe(true); + expect(result.text).toBe("Current verbose level: off."); + expect(result.interactive).toEqual({ + blocks: [ + { + type: "text", + text: "Current verbose level: off.", + }, + { + type: "buttons", + buttons: [ + { label: "on", value: "on" }, + { label: "full", value: "full" }, + { label: "off", value: "off" }, + ], + }, + ], + }); + }); + + it("uses a Slack select when Options lines exceed button capacity", () => { + const result = compileSlackInteractiveReplies({ + text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", + }); + + expect(result.text).toBe("Choose a reasoning level."); + expect(result.interactive).toEqual({ + blocks: [ + { + type: "text", + text: "Choose a reasoning level.", + }, + { + type: "select", + placeholder: "Choose an option", + options: [ + { label: "off", value: "off" }, + { label: "minimal", value: "minimal" }, + { label: "low", value: "low" }, + { label: "medium", value: "medium" }, + { label: "high", value: "high" }, + { label: "adaptive", value: "adaptive" }, + ], + }, + ], + }); + }); + + it("leaves complex Options lines as plain text", () => { + const result = compileSlackInteractiveReplies({ + text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", + }); + + expect(result.text).toBe( + "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", + ); + expect(result.interactive).toBeUndefined(); }); }); diff --git a/extensions/slack/src/interactive-replies.ts b/extensions/slack/src/interactive-replies.ts index 03d57904e40..e1f4a312505 100644 --- a/extensions/slack/src/interactive-replies.ts +++ b/extensions/slack/src/interactive-replies.ts @@ -1,6 +1,153 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { resolveDefaultSlackAccountId, resolveSlackAccount } from "./accounts.js"; +const SLACK_BUTTON_MAX_ITEMS = 5; +const SLACK_SELECT_MAX_ITEMS = 100; +const SLACK_DIRECTIVE_RE = /\[\[(slack_buttons|slack_select):\s*([^\]]+)\]\]/gi; +const SLACK_OPTIONS_LINE_RE = /^\s*Options:\s*(.+?)\s*\.?\s*$/i; +const SLACK_AUTO_SELECT_MAX_ITEMS = 12; +const SLACK_SIMPLE_OPTION_RE = /^[a-z0-9][a-z0-9 _+/-]{0,31}$/i; + +type SlackChoice = { + label: string; + value: string; + style?: "primary" | "secondary" | "success" | "danger"; +}; + +function parseChoice(raw: string, options?: { allowStyle?: boolean }): SlackChoice | null { + const trimmed = raw.trim(); + if (!trimmed) { + return null; + } + const delimiter = trimmed.indexOf(":"); + if (delimiter === -1) { + return { + label: trimmed, + value: trimmed, + }; + } + const label = trimmed.slice(0, delimiter).trim(); + let value = trimmed.slice(delimiter + 1).trim(); + if (!label || !value) { + return null; + } + let style: SlackChoice["style"]; + if (options?.allowStyle) { + const styleDelimiter = value.lastIndexOf(":"); + if (styleDelimiter !== -1) { + const maybeStyle = value + .slice(styleDelimiter + 1) + .trim() + .toLowerCase(); + if ( + maybeStyle === "primary" || + maybeStyle === "secondary" || + maybeStyle === "success" || + maybeStyle === "danger" + ) { + const unstyledValue = value.slice(0, styleDelimiter).trim(); + if (unstyledValue) { + value = unstyledValue; + style = maybeStyle; + } + } + } + } + return style ? { label, value, style } : { label, value }; +} + +function parseChoices( + raw: string, + maxItems: number, + options?: { allowStyle?: boolean }, +): SlackChoice[] { + return raw + .split(",") + .map((entry) => parseChoice(entry, options)) + .filter((entry): entry is SlackChoice => Boolean(entry)) + .slice(0, maxItems); +} + +function buildTextBlock( + text: string, +): NonNullable["blocks"][number] | null { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + return { type: "text", text: trimmed }; +} + +function buildButtonsBlock( + raw: string, +): NonNullable["blocks"][number] | null { + const choices = parseChoices(raw, SLACK_BUTTON_MAX_ITEMS, { allowStyle: true }); + if (choices.length === 0) { + return null; + } + return { + type: "buttons", + buttons: choices.map((choice) => ({ + label: choice.label, + value: choice.value, + ...(choice.style ? { style: choice.style } : {}), + })), + }; +} + +function buildSelectBlock( + raw: string, +): NonNullable["blocks"][number] | null { + const parts = raw + .split("|") + .map((entry) => entry.trim()) + .filter(Boolean); + if (parts.length === 0) { + return null; + } + const [first, second] = parts; + const placeholder = parts.length >= 2 ? first : "Choose an option"; + const choices = parseChoices(parts.length >= 2 ? second : first, SLACK_SELECT_MAX_ITEMS); + if (choices.length === 0) { + return null; + } + return { + type: "select", + placeholder, + options: choices, + }; +} + +function hasSlackBlocks(payload: ReplyPayload): boolean { + const blocks = (payload.channelData?.slack as { blocks?: unknown } | undefined)?.blocks; + if (typeof blocks === "string") { + return blocks.trim().length > 0; + } + return Array.isArray(blocks) && blocks.length > 0; +} + +function parseSimpleSlackOptions(raw: string): SlackChoice[] | null { + const entries = raw + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + if (entries.length < 2 || entries.length > SLACK_AUTO_SELECT_MAX_ITEMS) { + return null; + } + if (!entries.every((entry) => SLACK_SIMPLE_OPTION_RE.test(entry))) { + return null; + } + const deduped = new Set(entries.map((entry) => entry.toLowerCase())); + if (deduped.size !== entries.length) { + return null; + } + return entries.map((entry) => ({ + label: entry, + value: entry, + })); +} + function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean { if (!capabilities) { return false; @@ -26,3 +173,114 @@ export function isSlackInteractiveRepliesEnabled(params: { }); return resolveInteractiveRepliesFromCapabilities(account.config.capabilities); } + +export function compileSlackInteractiveReplies(payload: ReplyPayload): ReplyPayload { + const text = payload.text; + if (!text) { + return payload; + } + + const generatedBlocks: NonNullable["blocks"] = []; + const visibleTextParts: string[] = []; + let cursor = 0; + let matchedDirective = false; + let generatedInteractiveBlock = false; + SLACK_DIRECTIVE_RE.lastIndex = 0; + + for (const match of text.matchAll(SLACK_DIRECTIVE_RE)) { + matchedDirective = true; + const matchText = match[0]; + const directiveType = match[1]; + const body = match[2]; + const index = match.index ?? 0; + const precedingText = text.slice(cursor, index); + visibleTextParts.push(precedingText); + const section = buildTextBlock(precedingText); + if (section) { + generatedBlocks.push(section); + } + const block = + directiveType.toLowerCase() === "slack_buttons" + ? buildButtonsBlock(body) + : buildSelectBlock(body); + if (block) { + generatedInteractiveBlock = true; + generatedBlocks.push(block); + } + cursor = index + matchText.length; + } + + const trailingText = text.slice(cursor); + visibleTextParts.push(trailingText); + const trailingSection = buildTextBlock(trailingText); + if (trailingSection) { + generatedBlocks.push(trailingSection); + } + const cleanedText = visibleTextParts.join(""); + + if (!matchedDirective || !generatedInteractiveBlock) { + return parseSlackOptionsLine(payload); + } + + return { + ...payload, + text: cleanedText.trim() || undefined, + interactive: { + blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks], + }, + }; +} + +export function parseSlackOptionsLine(payload: ReplyPayload): ReplyPayload { + const text = payload.text; + if (!text || payload.interactive?.blocks?.length || hasSlackBlocks(payload)) { + return payload; + } + + const lines = text.split("\n"); + const lastNonEmptyIndex = [...lines.keys()].toReversed().find((index) => lines[index]?.trim()); + if (lastNonEmptyIndex == null) { + return payload; + } + + const optionsLine = lines[lastNonEmptyIndex] ?? ""; + const match = optionsLine.match(SLACK_OPTIONS_LINE_RE); + if (!match) { + return payload; + } + + const choices = parseSimpleSlackOptions(match[1] ?? ""); + if (!choices) { + return payload; + } + + const bodyText = lines + .filter((_, index) => index !== lastNonEmptyIndex) + .join("\n") + .trim(); + const generatedBlocks: NonNullable["blocks"] = []; + const bodyBlock = buildTextBlock(bodyText); + if (bodyBlock) { + generatedBlocks.push(bodyBlock); + } + generatedBlocks.push( + choices.length <= SLACK_BUTTON_MAX_ITEMS + ? { + type: "buttons", + buttons: choices, + } + : { + type: "select", + placeholder: "Choose an option", + options: choices, + }, + ); + + return { + ...payload, + text: bodyText || undefined, + interactive: { + blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks], + }, + }; +} diff --git a/extensions/slack/src/monitor/events/interactions.block-actions.ts b/extensions/slack/src/monitor/events/interactions.block-actions.ts index 65dc49aa3a5..3b67265ecde 100644 --- a/extensions/slack/src/monitor/events/interactions.block-actions.ts +++ b/extensions/slack/src/monitor/events/interactions.block-actions.ts @@ -1,8 +1,8 @@ import type { SlackActionMiddlewareArgs } from "@slack/bolt"; import type { Block, KnownBlock } from "@slack/web-api"; import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime"; -import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js"; +import { dispatchSlackPluginInteractiveHandler } from "../../interactive-dispatch.js"; import { authorizeSlackSystemEventSender } from "../auth.js"; import type { SlackMonitorContext } from "../context.js"; import { @@ -536,8 +536,7 @@ async function dispatchSlackPluginInteraction(params: { ) { return true; } - const pluginResult = await dispatchPluginInteractiveHandler({ - channel: "slack", + const pluginResult = await dispatchSlackPluginInteractiveHandler({ data: params.pluginInteractionData, interactionId: pluginInteractionId, ctx: { diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index f27519b8c25..49336b4ca0c 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -19,6 +19,7 @@ import { } from "openclaw/plugin-sdk/reply-payload"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js"; +import { compileSlackInteractiveReplies } from "./interactive-replies.js"; import { SLACK_TEXT_LIMIT } from "./limits.js"; import { sendMessageSlack, type SlackSendIdentity } from "./send.js"; @@ -161,6 +162,7 @@ export const slackOutbound: ChannelOutboundAdapter = { deliveryMode: "direct", chunker: null, textChunkLimit: SLACK_TEXT_LIMIT, + normalizePayload: ({ payload }) => compileSlackInteractiveReplies(payload), sendPayload: async (ctx) => { const payload = { ...ctx.payload, diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 29d3eb9bfb5..eba6bc69bb7 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -18,6 +18,7 @@ import { type ResolvedSlackAccount, } from "./accounts.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; +import { slackDoctor } from "./doctor.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { getChatChannelMeta, type ChannelPlugin, type OpenClawConfig } from "./runtime-api.js"; @@ -168,6 +169,8 @@ export function createSlackPluginBase(params: { | "meta" | "setupWizard" | "capabilities" + | "commands" + | "doctor" | "agentPrompt" | "streaming" | "reload" @@ -189,7 +192,24 @@ export function createSlackPluginBase(params: { media: true, nativeCommands: true, }, + commands: { + nativeCommandsAutoEnabled: false, + nativeSkillsAutoEnabled: false, + resolveNativeCommandName: ({ commandKey, defaultName }) => + commandKey === "status" ? "agentstatus" : defaultName, + }, + doctor: slackDoctor, agentPrompt: { + inboundFormattingHints: () => ({ + text_markup: "slack_mrkdwn", + rules: [ + "Use Slack mrkdwn, not standard Markdown.", + "Bold uses *single asterisks*.", + "Links use .", + "Code blocks use triple backticks without a language identifier.", + "Do not use markdown headings or pipe tables.", + ], + }), messageToolHints: ({ cfg, accountId }) => isSlackInteractiveRepliesEnabled({ cfg, accountId }) ? [ @@ -226,6 +246,8 @@ export function createSlackPluginBase(params: { | "meta" | "setupWizard" | "capabilities" + | "commands" + | "doctor" | "agentPrompt" | "streaming" | "reload" diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 24788fd39b3..f1c5f7476fa 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -29,6 +29,7 @@ import { registerSynologyWebhookRoute, validateSynologyGatewayAccountStartup, } from "./gateway-runtime.js"; +import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js"; import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; @@ -318,6 +319,7 @@ export function createSynologyChatPlugin(): SynologyChatPlugin { ), collectSynologyChatRoutingWarnings, ), + collectAuditFindings: collectSynologyChatSecurityAuditFindings, }, outbound: { deliveryMode: "gateway" as const, diff --git a/extensions/telegram/src/auto-topic-label.test.ts b/extensions/telegram/src/auto-topic-label.test.ts new file mode 100644 index 00000000000..0ff80793dbd --- /dev/null +++ b/extensions/telegram/src/auto-topic-label.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +const generateConversationLabel = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({ + generateConversationLabel, +})); + +import { + AUTO_TOPIC_LABEL_DEFAULT_PROMPT, + generateTelegramTopicLabel, + resolveAutoTopicLabelConfig, +} from "./auto-topic-label.js"; + +describe("resolveAutoTopicLabelConfig", () => { + it("returns enabled with default prompt when configs are undefined", () => { + const result = resolveAutoTopicLabelConfig(undefined, undefined); + expect(result).toEqual({ enabled: true, prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT }); + }); + + it("prefers direct config over account config", () => { + expect(resolveAutoTopicLabelConfig(false, true)).toBeNull(); + expect( + resolveAutoTopicLabelConfig({ prompt: "DM prompt" }, { prompt: "Account prompt" }), + ).toEqual({ + enabled: true, + prompt: "DM prompt", + }); + }); + + it("falls back to default prompt for empty object prompt", () => { + expect(resolveAutoTopicLabelConfig({ enabled: true, prompt: " " }, undefined)).toEqual({ + enabled: true, + prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT, + }); + }); +}); + +describe("generateTelegramTopicLabel", () => { + it("delegates to the generic conversation label helper with telegram max length", async () => { + generateConversationLabel.mockResolvedValue("Billing"); + + await expect( + generateTelegramTopicLabel({ + userMessage: "Need help with invoices", + prompt: "prompt", + cfg: {}, + agentId: "billing", + }), + ).resolves.toBe("Billing"); + + expect(generateConversationLabel).toHaveBeenCalledWith({ + userMessage: "Need help with invoices", + prompt: "prompt", + cfg: {}, + agentId: "billing", + maxLength: 128, + }); + }); +}); diff --git a/src/auto-reply/reply/auto-topic-label-config.ts b/extensions/telegram/src/auto-topic-label.ts similarity index 51% rename from src/auto-reply/reply/auto-topic-label-config.ts rename to extensions/telegram/src/auto-topic-label.ts index d199d3b7e62..004509b2b47 100644 --- a/src/auto-reply/reply/auto-topic-label-config.ts +++ b/extensions/telegram/src/auto-topic-label.ts @@ -1,32 +1,22 @@ -/** - * Config resolution for auto-topic-label feature. - * Kept separate from LLM logic to avoid heavy transitive dependencies in tests. - */ -import type { AutoTopicLabelConfig } from "../../config/types.telegram.js"; +import type { + OpenClawConfig, + TelegramAccountConfig, + TelegramDirectConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import { generateConversationLabel } from "openclaw/plugin-sdk/reply-runtime"; export const AUTO_TOPIC_LABEL_DEFAULT_PROMPT = "Generate a very short topic label (2-4 words, max 25 chars) for a chat conversation based on the user's first message below. No emoji. Use the same language as the message. Be concise and descriptive. Return ONLY the topic name, nothing else."; -/** - * Resolve whether auto topic labeling is enabled and get the prompt. - * Returns null if disabled. - */ export function resolveAutoTopicLabelConfig( - directConfig?: AutoTopicLabelConfig, - accountConfig?: AutoTopicLabelConfig, + directConfig?: TelegramDirectConfig["autoTopicLabel"], + accountConfig?: TelegramAccountConfig["autoTopicLabel"], ): { enabled: true; prompt: string } | null { - // Per-DM config takes priority over account-level config. const config = directConfig ?? accountConfig; - - // Default: enabled (when config is undefined, treat as true). if (config === undefined || config === true) { return { enabled: true, prompt: AUTO_TOPIC_LABEL_DEFAULT_PROMPT }; } - if (config === false) { - return null; - } - // Object form. - if (config.enabled === false) { + if (config === false || config.enabled === false) { return null; } return { @@ -34,3 +24,16 @@ export function resolveAutoTopicLabelConfig( prompt: config.prompt?.trim() || AUTO_TOPIC_LABEL_DEFAULT_PROMPT, }; } + +export async function generateTelegramTopicLabel(params: { + userMessage: string; + prompt: string; + cfg: OpenClawConfig; + agentId?: string; + agentDir?: string; +}): Promise { + return await generateConversationLabel({ + ...params, + maxLength: 128, + }); +} diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts index c22426e2cfa..17bdc5390ca 100644 --- a/extensions/telegram/src/bot-handlers.runtime.ts +++ b/extensions/telegram/src/bot-handlers.runtime.ts @@ -7,7 +7,6 @@ import { } from "openclaw/plugin-sdk/channel-inbound"; import { buildCommandsMessagePaginated, - buildCommandsPaginationKeyboard, resolveStoredModelOverride, } from "openclaw/plugin-sdk/command-auth"; import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime"; @@ -30,7 +29,6 @@ import { } from "openclaw/plugin-sdk/conversation-runtime"; import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/infra-runtime"; import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime"; -import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing"; import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env"; @@ -75,6 +73,7 @@ import { withResolvedTelegramForumFlag, } from "./bot/helpers.js"; import type { TelegramContext, TelegramGetChat } from "./bot/types.js"; +import { buildCommandsPaginationKeyboard } from "./command-ui.js"; import { resolveTelegramConversationBaseSessionKey, resolveTelegramConversationRoute, @@ -92,6 +91,7 @@ import { } from "./group-access.js"; import { migrateTelegramGroupConfig } from "./group-migration.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; +import { dispatchTelegramPluginInteractiveHandler } from "./interactive-dispatch.js"; import { buildModelsKeyboard, buildProviderKeyboard, @@ -1280,8 +1280,7 @@ export const registerTelegramHandlers = ({ await replyToCallbackChat(buildPluginBindingResolvedText(resolved)); return; } - const pluginCallback = await dispatchPluginInteractiveHandler({ - channel: "telegram", + const pluginCallback = await dispatchTelegramPluginInteractiveHandler({ data, callbackId: callback.id, ctx: { diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index 5e66c82b0ea..c3ddbda274f 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -50,7 +50,6 @@ const listSkillCommandsForAgents = vi.hoisted(() => vi.fn(() => [])); const createChannelReplyPipeline = vi.hoisted(() => vi.fn(() => ({ responsePrefix: undefined, - enableSlackInteractiveReplies: undefined, responsePrefixContextProvider: () => ({ identityName: undefined }), onModelSelected: () => undefined, })), diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index b79349e25f9..e2056ea1a31 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -16,6 +16,7 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { generateTelegramTopicLabel, resolveAutoTopicLabelConfig } from "./auto-topic-label.js"; import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js"; import type { TelegramMessageContext } from "./bot-message-context.js"; import { @@ -942,7 +943,7 @@ export const dispatchTelegramMessage = async ({ const topicThreadId = threadSpec.id!; void (async () => { try { - const label = await generateTopicLabel({ + const label = await generateTelegramTopicLabel({ userMessage, prompt: autoTopicConfig.prompt, cfg, diff --git a/extensions/telegram/src/bot-native-commands.registry.test.ts b/extensions/telegram/src/bot-native-commands.registry.test.ts index 837cb5cc8d5..66e93f0edb7 100644 --- a/extensions/telegram/src/bot-native-commands.registry.test.ts +++ b/extensions/telegram/src/bot-native-commands.registry.test.ts @@ -14,14 +14,14 @@ let waitForRegisteredCommands: typeof import("./bot-native-commands.menu-test-su function registerPairPluginCommand(params?: { nativeNames?: { telegram?: string; discord?: string }; - telegramNativeProgressMessage?: string; + nativeProgressMessages?: { telegram?: string; default?: string }; }) { expect( registerPluginCommand("demo-plugin", { name: "pair", ...(params?.nativeNames ? { nativeNames: params.nativeNames } : {}), - ...(params?.telegramNativeProgressMessage - ? { telegramNativeProgressMessage: params.telegramNativeProgressMessage } + ...(params?.nativeProgressMessages + ? { nativeProgressMessages: params.nativeProgressMessages } : {}), description: "Pair device", acceptsArgs: true, @@ -35,12 +35,12 @@ async function registerPairMenu(params: { bot: ReturnType["bot"]; setMyCommands: ReturnType["setMyCommands"]; nativeNames?: { telegram?: string; discord?: string }; - telegramNativeProgressMessage?: string; + nativeProgressMessages?: { telegram?: string; default?: string }; }) { registerPairPluginCommand({ ...(params.nativeNames ? { nativeNames: params.nativeNames } : {}), - ...(params.telegramNativeProgressMessage - ? { telegramNativeProgressMessage: params.telegramNativeProgressMessage } + ...(params.nativeProgressMessages + ? { nativeProgressMessages: params.nativeProgressMessages } : {}), }); @@ -104,8 +104,10 @@ describe("registerTelegramNativeCommands real plugin registry", () => { await registerPairMenu({ bot, setMyCommands, - telegramNativeProgressMessage: - "Running pair now...\n\nI'll edit this message with the final result when it's ready.", + nativeProgressMessages: { + telegram: + "Running pair now...\n\nI'll edit this message with the final result when it's ready.", + }, }); const handler = commandHandlers.get("pair"); diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts index 182075c5271..f4147209478 100644 --- a/extensions/telegram/src/bot-native-commands.test.ts +++ b/extensions/telegram/src/bot-native-commands.test.ts @@ -256,8 +256,10 @@ describe("registerTelegramNativeCommands", () => { command: { key: "plug", requireAuth: false, - telegramNativeProgressMessage: - "Running this command now...\n\nI'll edit this message with the final result when it's ready.", + nativeProgressMessages: { + telegram: + "Running this command now...\n\nI'll edit this message with the final result when it's ready.", + }, }, args: "now", } as never); @@ -315,7 +317,7 @@ describe("registerTelegramNativeCommands", () => { command: { key: "plug", requireAuth: false, - telegramNativeProgressMessage: "Working on it...", + nativeProgressMessages: { telegram: "Working on it..." }, }, args: "now", } as never); @@ -362,7 +364,7 @@ describe("registerTelegramNativeCommands", () => { command: { key: "plug", requireAuth: false, - telegramNativeProgressMessage: "Working on it...", + nativeProgressMessages: { telegram: "Working on it..." }, }, args: "now", } as never); @@ -406,7 +408,7 @@ describe("registerTelegramNativeCommands", () => { command: { key: "plug", requireAuth: false, - telegramNativeProgressMessage: "Working on it...", + nativeProgressMessages: { telegram: "Working on it..." }, }, args: "now", } as never); @@ -446,7 +448,7 @@ describe("registerTelegramNativeCommands", () => { command: { key: "plug", requireAuth: false, - telegramNativeProgressMessage: "Working on it...", + nativeProgressMessages: { telegram: "Working on it..." }, }, args: "now", } as never); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 575f8e2f158..7e58eda9100 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -121,9 +121,11 @@ async function loadTelegramNativeCommandRuntime() { } function resolveTelegramProgressPlaceholder(command: { - telegramNativeProgressMessage?: string; + nativeProgressMessages?: Partial> & { default?: string }; }): string | null { - const text = command.telegramNativeProgressMessage?.trim(); + const text = + command.nativeProgressMessages?.telegram?.trim() ?? + command.nativeProgressMessages?.default?.trim(); return text ? text : null; } diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index c6b11f5d2da..bcd8a8eeb1c 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -137,7 +137,6 @@ async function dispatchHarnessReplies( await params.dispatcherOptions.deliver?.(payload, info); }, responsePrefix: params.dispatcherOptions.responsePrefix, - enableSlackInteractiveReplies: params.dispatcherOptions.enableSlackInteractiveReplies, responsePrefixContextProvider: params.dispatcherOptions.responsePrefixContextProvider, responsePrefixContext: params.dispatcherOptions.responsePrefixContext, onHeartbeatStrip: params.dispatcherOptions.onHeartbeatStrip, diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index e480b4dede8..f60fe80c966 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -1,10 +1,12 @@ import { rm } from "node:fs/promises"; -import type { PluginInteractiveTelegramHandlerContext } from "openclaw/plugin-sdk/core"; import { clearPluginInteractiveHandlers, registerPluginInteractiveHandler, } from "openclaw/plugin-sdk/plugin-runtime"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js"; +import type { TelegramInteractiveHandlerContext } from "./interactive-dispatch.js"; +import { expectChannelInboundContextContract as expectInboundContextContract } from "./test-support/inbound-context-contract.js"; const { answerCallbackQuerySpy, commandSpy, @@ -1816,12 +1818,12 @@ describe("createTelegramBot", () => { registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", - handler: async ({ respond, callback }: PluginInteractiveTelegramHandlerContext) => { + handler: (async ({ respond, callback }: TelegramInteractiveHandlerContext) => { await respond.editMessage({ text: `Handled ${callback.payload}`, }); return { handled: true }; - }, + }) as never, }); createTelegramBot({ @@ -1863,7 +1865,7 @@ describe("createTelegramBot", () => { onSpy.mockClear(); getChatSpy.mockResolvedValue({ id: -100123456789, type: "supergroup", is_forum: true }); const handler = vi.fn( - async ({ respond, conversationId, threadId }: PluginInteractiveTelegramHandlerContext) => { + async ({ respond, conversationId, threadId }: TelegramInteractiveHandlerContext) => { expect(conversationId).toBe("-100123456789:topic:1"); expect(threadId).toBe(1); await respond.editMessage({ @@ -1875,7 +1877,7 @@ describe("createTelegramBot", () => { registerPluginInteractiveHandler("codex-plugin", { channel: "telegram", namespace: "codexapp", - handler, + handler: handler as never, }); createTelegramBot({ diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index d22a8e784fe..8504e7e75fe 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -168,6 +168,19 @@ function describeTelegramMessageTool({ export const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: describeTelegramMessageTool, + resolveCliActionRequest: ({ action, args }) => { + if (action !== "thread-create") { + return { action, args }; + } + const { threadName, ...rest } = args; + return { + action: "topic-create", + args: { + ...rest, + name: typeof threadName === "string" ? threadName : undefined, + }, + }; + }, extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 7908f33fc97..08b6ce82eea 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -6,10 +6,21 @@ import { import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; -import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; +import { + PAIRING_APPROVED_MESSAGE, + buildTokenChannelStatusSummary, + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "openclaw/plugin-sdk/channel-status"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; +import { clearAccountEntryFields } from "openclaw/plugin-sdk/core"; import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; +import { + resolveOutboundSendDep, + type OutboundSendDeps, +} from "openclaw/plugin-sdk/outbound-runtime"; import { buildOutboundBaseSessionKey, normalizeMessageChannel, @@ -17,6 +28,10 @@ import { resolveThreadSessionKeys, type RoutePeer, } from "openclaw/plugin-sdk/routing"; +import { + createComputedAccountStatusAdapter, + createDefaultChannelRuntimeState, +} from "openclaw/plugin-sdk/status-helpers"; import { listTelegramAccountIds, resolveTelegramAccount, @@ -25,13 +40,9 @@ import { import { resolveTelegramAutoThreadId } from "./action-threading.js"; import { lookupTelegramChatId } from "./api-fetch.js"; import { telegramApprovalCapability } from "./approval-native.js"; +import * as auditModule from "./audit.js"; import { buildTelegramGroupPeerId } from "./bot/helpers.js"; import { telegramMessageActions as telegramMessageActionsImpl } from "./channel-actions.js"; -import { - matchTelegramAcpConversation, - normalizeTelegramAcpConversationId, - resolveTelegramCommandConversation, -} from "./channel-bindings.js"; import { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, @@ -50,53 +61,64 @@ import { resolveTelegramGroupToolPolicy, } from "./group-policy.js"; import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js"; +import * as monitorModule from "./monitor.js"; import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; -import { telegramChannelOutbound } from "./outbound-base.js"; -import { parseTelegramThreadId } from "./outbound-params.js"; -import { telegramPairingText } from "./pairing-text.js"; +import { sendTelegramPayloadMessages } from "./outbound-adapter.js"; +import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js"; +import * as probeModule from "./probe.js"; import type { TelegramProbe } from "./probe.js"; import { resolveTelegramReactionLevel } from "./reaction-level.js"; import { getTelegramRuntime } from "./runtime.js"; +import { collectTelegramSecurityAuditFindings } from "./security-audit.js"; +import { sendMessageTelegram, sendPollTelegram, sendTypingTelegram } from "./send.js"; import { resolveTelegramSessionConversation } from "./session-conversation.js"; import { telegramSetupAdapter } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; -import { createTelegramPluginBase, telegramConfigAdapter } from "./shared.js"; +import { + createTelegramPluginBase, + findTelegramTokenOwnerAccountId, + formatDuplicateTelegramTokenReason, + telegramConfigAdapter, +} from "./shared.js"; import { collectTelegramStatusIssues } from "./status-issues.js"; import { parseTelegramTarget } from "./targets.js"; -import { telegramGateway } from "./telegram-gateway.js"; -import { telegramStatus } from "./telegram-status.js"; -import { telegramThreading } from "./telegram-threading.js"; import { createTelegramThreadBindingManager, setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, } from "./thread-bindings.js"; +import { buildTelegramThreadingToolContext } from "./threading-tool-context.js"; +import { resolveTelegramToken } from "./token.js"; import { parseTelegramTopicConversation } from "./topic-conversation.js"; -let telegramAuditModulePromise: Promise | null = null; -let telegramMonitorModulePromise: Promise | null = null; -let telegramProbeModulePromise: Promise | null = null; -async function loadTelegramAuditModule() { - telegramAuditModulePromise ??= import("./audit.js"); - return await telegramAuditModulePromise; -} +type TelegramSendFn = typeof sendMessageTelegram; -async function loadTelegramProbeModule() { - telegramProbeModulePromise ??= import("./probe.js"); - return await telegramProbeModulePromise; -} +type TelegramSendOptions = NonNullable[2]>; -async function resolveTelegramProbe() { +function resolveTelegramProbe() { return ( - getOptionalTelegramRuntime()?.channel?.telegram?.probeTelegram ?? - (await loadTelegramProbeModule()).probeTelegram + getOptionalTelegramRuntime()?.channel?.telegram?.probeTelegram ?? probeModule.probeTelegram ); } -async function resolveTelegramAuditCollector() { +function resolveTelegramAuditCollector() { return ( getOptionalTelegramRuntime()?.channel?.telegram?.collectTelegramUnmentionedGroupIds ?? - (await loadTelegramAuditModule()).collectTelegramUnmentionedGroupIds + auditModule.collectTelegramUnmentionedGroupIds + ); +} + +function resolveTelegramAuditMembership() { + return ( + getOptionalTelegramRuntime()?.channel?.telegram?.auditTelegramGroupMembership ?? + auditModule.auditTelegramGroupMembership + ); +} + +function resolveTelegramMonitor() { + return ( + getOptionalTelegramRuntime()?.channel?.telegram?.monitorTelegramProvider ?? + monitorModule.monitorTelegramProvider ); } @@ -108,6 +130,77 @@ function getOptionalTelegramRuntime() { } } +function resolveTelegramSend(deps?: OutboundSendDeps): TelegramSendFn { + return ( + resolveOutboundSendDep(deps, "telegram") ?? + getOptionalTelegramRuntime()?.channel?.telegram?.sendMessageTelegram ?? + sendMessageTelegram + ); +} + +function resolveTelegramTokenHelper() { + return ( + getOptionalTelegramRuntime()?.channel?.telegram?.resolveTelegramToken ?? resolveTelegramToken + ); +} + +function buildTelegramSendOptions(params: { + cfg: OpenClawConfig; + mediaUrl?: string | null; + mediaLocalRoots?: readonly string[] | null; + accountId?: string | null; + replyToId?: string | null; + threadId?: string | number | null; + silent?: boolean | null; + forceDocument?: boolean | null; + gatewayClientScopes?: readonly string[] | null; +}): TelegramSendOptions { + return { + verbose: false, + cfg: params.cfg, + ...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}), + ...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}), + messageThreadId: parseTelegramThreadId(params.threadId), + replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), + accountId: params.accountId ?? undefined, + silent: params.silent ?? undefined, + forceDocument: params.forceDocument ?? undefined, + ...(Array.isArray(params.gatewayClientScopes) + ? { gatewayClientScopes: [...params.gatewayClientScopes] } + : {}), + }; +} + +async function sendTelegramOutbound(params: { + cfg: OpenClawConfig; + to: string; + text: string; + mediaUrl?: string | null; + mediaLocalRoots?: readonly string[] | null; + accountId?: string | null; + deps?: OutboundSendDeps; + replyToId?: string | null; + threadId?: string | number | null; + silent?: boolean | null; + gatewayClientScopes?: readonly string[] | null; +}) { + const send = resolveTelegramSend(params.deps); + return await send( + params.to, + params.text, + buildTelegramSendOptions({ + cfg: params.cfg, + mediaUrl: params.mediaUrl, + mediaLocalRoots: params.mediaLocalRoots, + accountId: params.accountId, + replyToId: params.replyToId, + threadId: params.threadId, + silent: params.silent, + gatewayClientScopes: params.gatewayClientScopes, + }), + ); +} + const telegramMessageActions: ChannelMessageActionAdapter = { describeMessageTool: (ctx) => getOptionalTelegramRuntime()?.channel?.telegram?.messageActions?.describeMessageTool?.(ctx) ?? @@ -130,6 +223,173 @@ const telegramMessageActions: ChannelMessageActionAdapter = { }, }; +function normalizeTelegramAcpConversationId(conversationId: string) { + const parsed = parseTelegramTopicConversation({ conversationId }); + if (!parsed || !parsed.chatId.startsWith("-")) { + return null; + } + return { + conversationId: parsed.canonicalConversationId, + parentConversationId: parsed.chatId, + }; +} + +function matchTelegramAcpConversation(params: { + bindingConversationId: string; + conversationId: string; + parentConversationId?: string; +}) { + const binding = normalizeTelegramAcpConversationId(params.bindingConversationId); + if (!binding) { + return null; + } + const incoming = parseTelegramTopicConversation({ + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if (!incoming || !incoming.chatId.startsWith("-")) { + return null; + } + if (binding.conversationId !== incoming.canonicalConversationId) { + return null; + } + return { + conversationId: incoming.canonicalConversationId, + parentConversationId: incoming.chatId, + matchPriority: 2, + }; +} + +function shouldTreatTelegramRoutedTextAsVisible(params: { + kind: "tool" | "block" | "final"; + text?: string; +}): boolean { + void params.text; + return params.kind !== "final"; +} + +function targetsMatchTelegramReplySuppression(params: { + originTarget: string; + targetKey: string; + targetThreadId?: string; +}): boolean { + const origin = parseTelegramTarget(params.originTarget); + const target = parseTelegramTarget(params.targetKey); + const originThreadId = + origin.messageThreadId != null && String(origin.messageThreadId).trim() + ? String(origin.messageThreadId).trim() + : undefined; + const targetThreadId = + params.targetThreadId?.trim() || + (target.messageThreadId != null && String(target.messageThreadId).trim() + ? String(target.messageThreadId).trim() + : undefined); + if (origin.chatId.trim().toLowerCase() !== target.chatId.trim().toLowerCase()) { + return false; + } + if (originThreadId && targetThreadId) { + return originThreadId === targetThreadId; + } + return originThreadId == null && targetThreadId == null; +} + +function resolveTelegramCommandConversation(params: { + threadId?: string; + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; +}) { + const chatId = [params.originatingTo, params.commandTo, params.fallbackTo] + .map((candidate) => { + const trimmed = candidate?.trim(); + return trimmed ? parseTelegramTarget(trimmed).chatId.trim() : ""; + }) + .find((candidate) => candidate.length > 0); + if (!chatId) { + return null; + } + if (params.threadId) { + return { + conversationId: `${chatId}:topic:${params.threadId}`, + parentConversationId: chatId, + }; + } + if (chatId.startsWith("-")) { + return null; + } + return { + conversationId: chatId, + parentConversationId: chatId, + }; +} + +function resolveTelegramInboundConversation(params: { + to?: string; + conversationId?: string; + threadId?: string | number; +}) { + const rawTarget = params.to?.trim() || params.conversationId?.trim() || ""; + if (!rawTarget) { + return null; + } + const parsedTarget = parseTelegramTarget(rawTarget); + const chatId = parsedTarget.chatId.trim(); + if (!chatId) { + return null; + } + const threadId = + parsedTarget.messageThreadId != null + ? String(parsedTarget.messageThreadId) + : params.threadId != null + ? String(params.threadId).trim() || undefined + : undefined; + if (threadId) { + const parsedTopic = parseTelegramTopicConversation({ + conversationId: threadId, + parentConversationId: chatId, + }); + if (!parsedTopic) { + return null; + } + return { + conversationId: parsedTopic.canonicalConversationId, + parentConversationId: parsedTopic.chatId, + }; + } + return { + conversationId: chatId, + parentConversationId: chatId, + }; +} + +function resolveTelegramDeliveryTarget(params: { + conversationId: string; + parentConversationId?: string; +}) { + const parsedTopic = parseTelegramTopicConversation({ + conversationId: params.conversationId, + parentConversationId: params.parentConversationId, + }); + if (parsedTopic) { + return { + to: parsedTopic.chatId, + threadId: parsedTopic.topicId, + }; + } + const parsedTarget = parseTelegramTarget( + params.parentConversationId?.trim() || params.conversationId, + ); + if (!parsedTarget.chatId.trim()) { + return null; + } + return { + to: parsedTarget.chatId, + ...(parsedTarget.messageThreadId != null + ? { threadId: String(parsedTarget.messageThreadId) } + : {}), + }; +} + function parseTelegramExplicitTarget(raw: string) { const target = parseTelegramTarget(raw); return { @@ -139,6 +399,41 @@ function parseTelegramExplicitTarget(raw: string) { }; } +function shouldStripTelegramThreadFromAnnounceOrigin(params: { + requester: { + channel?: string; + to?: string; + threadId?: string | number; + }; + entry: { + channel?: string; + to?: string; + threadId?: string | number; + }; +}): boolean { + const requesterChannel = params.requester.channel?.trim().toLowerCase(); + if (requesterChannel && requesterChannel !== "telegram") { + return true; + } + const requesterTo = params.requester.to?.trim(); + if (!requesterTo) { + return false; + } + if (!requesterChannel && !requesterTo.startsWith("telegram:")) { + return true; + } + const requesterTarget = parseTelegramExplicitTarget(requesterTo); + if (requesterTarget.chatType !== "group") { + return true; + } + const entryTo = params.entry.to?.trim(); + if (!entryTo) { + return false; + } + const entryTarget = parseTelegramExplicitTarget(entryTo); + return entryTarget.to !== requesterTarget.to; +} + function buildTelegramBaseSessionKey(params: { cfg: OpenClawConfig; agentId: string; @@ -313,6 +608,7 @@ export const telegramPlugin = createChatChannelPlugin({ resolveGroupOverrides: resolveTelegramAllowlistGroupOverrides, }), bindings: { + selfParentConversationByDefault: true, compileConfiguredBinding: ({ conversationId }) => normalizeTelegramAcpConversationId(conversationId), matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => @@ -331,6 +627,25 @@ export const telegramPlugin = createChatChannelPlugin({ }, conversationBindings: { supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "current", + resolveConversationRef: ({ + accountId: _accountId, + conversationId, + parentConversationId, + threadId, + }) => + resolveTelegramInboundConversation({ + to: parentConversationId ?? conversationId, + conversationId, + threadId: threadId ?? undefined, + }), + buildBoundReplyChannelData: ({ operation, conversation }) => { + if (operation !== "acp-spawn") { + return null; + } + return conversation.conversationId.includes(":topic:") ? { telegram: { pin: true } } : null; + }, + shouldStripThreadFromAnnounceOrigin: shouldStripTelegramThreadFromAnnounceOrigin, createManager: ({ accountId }) => createTelegramThreadBindingManager({ accountId: accountId ?? undefined, @@ -372,6 +687,10 @@ export const telegramPlugin = createChatChannelPlugin({ }, messaging: { normalizeTarget: normalizeTelegramMessagingTarget, + resolveInboundConversation: ({ to, conversationId, threadId }) => + resolveTelegramInboundConversation({ to, conversationId, threadId }), + resolveDeliveryTarget: ({ conversationId, parentConversationId }) => + resolveTelegramDeliveryTarget({ conversationId, parentConversationId }), resolveSessionConversation: ({ kind, rawId }) => resolveTelegramSessionConversation({ kind, rawId }), parseExplicitTarget: ({ raw }) => parseTelegramExplicitTarget(raw), @@ -432,11 +751,226 @@ export const telegramPlugin = createChatChannelPlugin({ listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), }), actions: telegramMessageActions, - status: telegramStatus, - gateway: telegramGateway, + status: createComputedAccountStatusAdapter({ + defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), + skipStaleSocketHealthCheck: true, + collectStatusIssues: collectTelegramStatusIssues, + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), + probeAccount: async ({ account, timeoutMs }) => + resolveTelegramProbe()(account.token, timeoutMs, { + accountId: account.accountId, + proxyUrl: account.config.proxy, + network: account.config.network, + apiRoot: account.config.apiRoot, + }), + formatCapabilitiesProbe: ({ probe }) => { + const lines = []; + if (probe?.bot?.username) { + const botId = probe.bot.id ? ` (${probe.bot.id})` : ""; + lines.push({ text: `Bot: @${probe.bot.username}${botId}` }); + } + const flags: string[] = []; + if (typeof probe?.bot?.canJoinGroups === "boolean") { + flags.push(`joinGroups=${probe.bot.canJoinGroups}`); + } + if (typeof probe?.bot?.canReadAllGroupMessages === "boolean") { + flags.push(`readAllGroupMessages=${probe.bot.canReadAllGroupMessages}`); + } + if (typeof probe?.bot?.supportsInlineQueries === "boolean") { + flags.push(`inlineQueries=${probe.bot.supportsInlineQueries}`); + } + if (flags.length > 0) { + lines.push({ text: `Flags: ${flags.join(" ")}` }); + } + if (probe?.webhook?.url !== undefined) { + lines.push({ text: `Webhook: ${probe.webhook.url || "none"}` }); + } + return lines; + }, + auditAccount: async ({ account, timeoutMs, probe, cfg }) => { + const groups = + cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? + cfg.channels?.telegram?.groups; + const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } = + resolveTelegramAuditCollector()(groups); + if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) { + return undefined; + } + const botId = probe?.ok && probe.bot?.id != null ? probe.bot.id : null; + if (!botId) { + return { + ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups, + checkedGroups: 0, + unresolvedGroups, + hasWildcardUnmentionedGroups, + groups: [], + elapsedMs: 0, + }; + } + const audit = await resolveTelegramAuditMembership()({ + token: account.token, + botId, + groupIds, + proxyUrl: account.config.proxy, + network: account.config.network, + apiRoot: account.config.apiRoot, + timeoutMs, + }); + return { ...audit, unresolvedGroups, hasWildcardUnmentionedGroups }; + }, + resolveAccountSnapshot: ({ account, cfg, runtime, audit }) => { + const configuredFromStatus = resolveConfiguredFromCredentialStatuses(account); + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg, + accountId: account.accountId, + }); + const duplicateTokenReason = ownerAccountId + ? formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }) + : null; + const configured = + (configuredFromStatus ?? Boolean(account.token?.trim())) && !ownerAccountId; + const groups = + cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ?? + cfg.channels?.telegram?.groups; + const allowUnmentionedGroups = + groups?.["*"]?.requireMention === false || + Object.entries(groups ?? {}).some( + ([key, value]) => key !== "*" && value?.requireMention === false, + ); + return { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + extra: { + ...projectCredentialSnapshotFields(account), + lastError: runtime?.lastError ?? duplicateTokenReason, + mode: runtime?.mode ?? (account.config.webhookUrl ? "webhook" : "polling"), + audit, + allowUnmentionedGroups, + }, + }; + }, + }), + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + const ownerAccountId = findTelegramTokenOwnerAccountId({ + cfg: ctx.cfg, + accountId: account.accountId, + }); + if (ownerAccountId) { + const reason = formatDuplicateTelegramTokenReason({ + accountId: account.accountId, + ownerAccountId, + }); + ctx.log?.error?.(`[${account.accountId}] ${reason}`); + throw new Error(reason); + } + const token = (account.token ?? "").trim(); + let telegramBotLabel = ""; + try { + const probe = await resolveTelegramProbe()(token, 2500, { + accountId: account.accountId, + proxyUrl: account.config.proxy, + network: account.config.network, + apiRoot: account.config.apiRoot, + }); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) { + telegramBotLabel = ` (@${username})`; + } + } catch (err) { + if (getTelegramRuntime().logging.shouldLogVerbose()) { + ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); + } + } + ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`); + return resolveTelegramMonitor()({ + token, + accountId: account.accountId, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + useWebhook: Boolean(account.config.webhookUrl), + webhookUrl: account.config.webhookUrl, + webhookSecret: account.config.webhookSecret, + webhookPath: account.config.webhookPath, + webhookHost: account.config.webhookHost, + webhookPort: account.config.webhookPort, + webhookCertPath: account.config.webhookCertPath, + }); + }, + logoutAccount: async ({ accountId, cfg }) => { + const envToken = process.env.TELEGRAM_BOT_TOKEN?.trim() ?? ""; + const nextCfg = { ...cfg } as OpenClawConfig; + const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : undefined; + let cleared = false; + let changed = false; + if (nextTelegram) { + if (accountId === DEFAULT_ACCOUNT_ID && nextTelegram.botToken) { + delete nextTelegram.botToken; + cleared = true; + changed = true; + } + const accountCleanup = clearAccountEntryFields({ + accounts: nextTelegram.accounts, + accountId, + fields: ["botToken"], + }); + if (accountCleanup.changed) { + changed = true; + if (accountCleanup.cleared) { + cleared = true; + } + if (accountCleanup.nextAccounts) { + nextTelegram.accounts = accountCleanup.nextAccounts; + } else { + delete nextTelegram.accounts; + } + } + } + if (changed) { + if (nextTelegram && Object.keys(nextTelegram).length > 0) { + nextCfg.channels = { ...nextCfg.channels, telegram: nextTelegram }; + } else { + const nextChannels = { ...nextCfg.channels }; + delete nextChannels.telegram; + if (Object.keys(nextChannels).length > 0) { + nextCfg.channels = nextChannels; + } else { + delete nextCfg.channels; + } + } + } + const resolved = resolveTelegramAccount({ + cfg: changed ? nextCfg : cfg, + accountId, + }); + const loggedOut = resolved.tokenSource === "none"; + if (changed) { + await getTelegramRuntime().config.writeConfigFile(nextCfg); + } + return { cleared, envToken: Boolean(envToken), loggedOut }; + }, + }, }, pairing: { - text: telegramPairingText, + text: { + idLabel: "telegramUserId", + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i), + notify: async ({ cfg, id, message, accountId }) => { + const { token } = resolveTelegramTokenHelper()(cfg, { accountId }); + if (!token) { + throw new Error("telegram token not configured"); + } + await resolveTelegramSend()(id, message, { token, accountId }); + }, + }, }, security: { dm: { @@ -447,7 +981,149 @@ export const telegramPlugin = createChatChannelPlugin({ normalizeEntry: (raw) => raw.replace(/^(telegram|tg):/i, ""), }, collectWarnings: collectTelegramSecurityWarnings, + collectAuditFindings: collectTelegramSecurityAuditFindings, + }, + threading: { + topLevelReplyToMode: "telegram", + buildToolContext: (params) => buildTelegramThreadingToolContext(params), + resolveAutoThreadId: ({ to, toolContext }) => resolveTelegramAutoThreadId({ to, toolContext }), + }, + outbound: { + base: { + deliveryMode: "direct", + chunker: (text, limit) => getTelegramRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + textChunkLimit: 4000, + pollMaxOptions: 10, + shouldSuppressLocalPayloadPrompt: ({ cfg, accountId, payload }) => + shouldSuppressLocalTelegramExecApprovalPrompt({ + cfg, + accountId, + payload, + }), + beforeDeliverPayload: async ({ cfg, target, hint }) => { + if (hint?.kind !== "approval-pending" || hint.approvalKind !== "exec") { + return; + } + const threadId = + typeof target.threadId === "number" + ? target.threadId + : typeof target.threadId === "string" + ? Number.parseInt(target.threadId, 10) + : undefined; + await sendTypingTelegram(target.to, { + cfg, + accountId: target.accountId ?? undefined, + ...(Number.isFinite(threadId) ? { messageThreadId: threadId } : {}), + }).catch(() => {}); + }, + shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), + shouldTreatRoutedTextAsVisible: shouldTreatTelegramRoutedTextAsVisible, + targetsMatchForReplySuppression: targetsMatchTelegramReplySuppression, + resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => + typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, + supportsPollDurationSeconds: true, + supportsAnonymousPolls: true, + sendPayload: async ({ + cfg, + to, + payload, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + forceDocument, + gatewayClientScopes, + }) => { + const send = resolveTelegramSend(deps); + const result = await sendTelegramPayloadMessages({ + send, + to, + payload, + baseOpts: buildTelegramSendOptions({ + cfg, + mediaLocalRoots, + accountId, + replyToId, + threadId, + silent, + forceDocument, + gatewayClientScopes, + }), + }); + return attachChannelToResult("telegram", result); + }, + }, + attachedResults: { + channel: "telegram", + sendText: async ({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + silent, + gatewayClientScopes, + }) => + await sendTelegramOutbound({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + silent, + gatewayClientScopes, + }), + sendMedia: async ({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + gatewayClientScopes, + }) => + await sendTelegramOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + gatewayClientScopes, + }), + sendPoll: async ({ + cfg, + to, + poll, + accountId, + threadId, + silent, + isAnonymous, + gatewayClientScopes, + }) => + await sendPollTelegram(to, poll, { + cfg, + accountId: accountId ?? undefined, + messageThreadId: parseTelegramThreadId(threadId), + silent: silent ?? undefined, + isAnonymous: isAnonymous ?? undefined, + gatewayClientScopes, + }), + }, }, - threading: telegramThreading, - outbound: telegramChannelOutbound, }); diff --git a/extensions/telegram/src/command-ui.test.ts b/extensions/telegram/src/command-ui.test.ts new file mode 100644 index 00000000000..a53d247f983 --- /dev/null +++ b/extensions/telegram/src/command-ui.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { buildCommandsPaginationKeyboard } from "./command-ui.js"; + +describe("telegram command ui", () => { + it("adds agent id to command pagination callback data when provided", () => { + const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); + expect(keyboard[0]).toEqual([ + { text: "◀ Prev", callback_data: "commands_page_1:agent-main" }, + { text: "2/3", callback_data: "commands_page_noop:agent-main" }, + { text: "Next ▶", callback_data: "commands_page_3:agent-main" }, + ]); + }); +}); diff --git a/extensions/telegram/src/command-ui.ts b/extensions/telegram/src/command-ui.ts new file mode 100644 index 00000000000..6e1bad9029e --- /dev/null +++ b/extensions/telegram/src/command-ui.ts @@ -0,0 +1,93 @@ +import type { ReplyPayload } from "openclaw/plugin-sdk"; +import { + buildBrowseProvidersButton, + buildModelsKeyboard, + buildProviderKeyboard, + type ProviderInfo, +} from "./model-buttons.js"; + +export function buildCommandsPaginationKeyboard( + currentPage: number, + totalPages: number, + agentId?: string, +): Array> { + const buttons: Array<{ text: string; callback_data: string }> = []; + const suffix = agentId ? `:${agentId}` : ""; + + if (currentPage > 1) { + buttons.push({ + text: "◀ Prev", + callback_data: `commands_page_${currentPage - 1}${suffix}`, + }); + } + + buttons.push({ + text: `${currentPage}/${totalPages}`, + callback_data: `commands_page_noop${suffix}`, + }); + + if (currentPage < totalPages) { + buttons.push({ + text: "Next ▶", + callback_data: `commands_page_${currentPage + 1}${suffix}`, + }); + } + + return [buttons]; +} + +export function buildTelegramCommandsListChannelData(params: { + currentPage: number; + totalPages: number; + agentId?: string; +}): ReplyPayload["channelData"] | null { + if (params.totalPages <= 1) { + return null; + } + return { + telegram: { + buttons: buildCommandsPaginationKeyboard( + params.currentPage, + params.totalPages, + params.agentId, + ), + }, + }; +} + +export function buildTelegramModelsProviderChannelData(params: { + providers: ProviderInfo[]; +}): ReplyPayload["channelData"] | null { + if (params.providers.length === 0) { + return null; + } + return { + telegram: { + buttons: buildProviderKeyboard(params.providers), + }, + }; +} + +export function buildTelegramModelsListChannelData(params: { + provider: string; + models: readonly string[]; + currentModel?: string; + currentPage: number; + totalPages: number; + pageSize?: number; + modelNames?: ReadonlyMap; +}): ReplyPayload["channelData"] | null { + return { + telegram: { + buttons: buildModelsKeyboard(params), + }, + }; +} + +export function buildTelegramModelBrowseChannelData(): ReplyPayload["channelData"] { + return { + telegram: { + buttons: buildBrowseProvidersButton(), + }, + }; +} diff --git a/extensions/telegram/src/doctor.test.ts b/extensions/telegram/src/doctor.test.ts new file mode 100644 index 00000000000..7a69a97d0ff --- /dev/null +++ b/extensions/telegram/src/doctor.test.ts @@ -0,0 +1,140 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + collectTelegramAllowFromUsernameWarnings, + collectTelegramEmptyAllowlistExtraWarnings, + collectTelegramGroupPolicyWarnings, + maybeRepairTelegramAllowFromUsernames, + scanTelegramAllowFromUsernameEntries, +} from "./doctor.js"; + +const resolveCommandSecretRefsViaGatewayMock = vi.hoisted(() => vi.fn()); +const listTelegramAccountIdsMock = vi.hoisted(() => vi.fn()); +const inspectTelegramAccountMock = vi.hoisted(() => vi.fn()); +const lookupTelegramChatIdMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveCommandSecretRefsViaGateway: resolveCommandSecretRefsViaGatewayMock, + }; +}); + +vi.mock("./accounts.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listTelegramAccountIds: listTelegramAccountIdsMock, + }; +}); + +vi.mock("./account-inspect.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + inspectTelegramAccount: inspectTelegramAccountMock, + }; +}); + +vi.mock("./api-fetch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + lookupTelegramChatId: lookupTelegramChatIdMock, + }; +}); + +describe("telegram doctor", () => { + beforeEach(() => { + resolveCommandSecretRefsViaGatewayMock.mockReset().mockImplementation(async ({ config }) => ({ + resolvedConfig: config, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + })); + listTelegramAccountIdsMock.mockReset().mockReturnValue(["default"]); + inspectTelegramAccountMock.mockReset().mockReturnValue({ + enabled: true, + token: "tok", + tokenSource: "config", + tokenStatus: "configured", + }); + lookupTelegramChatIdMock.mockReset(); + }); + + it("finds username allowFrom entries across scopes", () => { + const hits = scanTelegramAllowFromUsernameEntries({ + channels: { + telegram: { + allowFrom: ["@top"], + accounts: { + work: { + allowFrom: ["tg:@work"], + groups: { "-100123": { topics: { "99": { allowFrom: ["@topic"] } } } }, + }, + }, + }, + }, + } as unknown as OpenClawConfig); + + expect(hits).toEqual([ + { path: "channels.telegram.allowFrom", entry: "@top" }, + { path: "channels.telegram.accounts.work.allowFrom", entry: "tg:@work" }, + { + path: "channels.telegram.accounts.work.groups.-100123.topics.99.allowFrom", + entry: "@topic", + }, + ]); + }); + + it("formats group-policy and empty-allowlist warnings", () => { + const warnings = collectTelegramGroupPolicyWarnings({ + account: { + botToken: "123:abc", + groupPolicy: "allowlist", + groups: { ops: { allow: true } }, + }, + prefix: "channels.telegram", + }); + expect(warnings[0]).toContain('groupPolicy is "allowlist"'); + + expect( + collectTelegramEmptyAllowlistExtraWarnings({ + account: { + botToken: "123:abc", + groupPolicy: "allowlist", + groups: { ops: { allow: true } }, + }, + channelName: "telegram", + prefix: "channels.telegram", + }), + ).toHaveLength(1); + }); + + it("repairs @username entries to numeric ids", async () => { + lookupTelegramChatIdMock.mockResolvedValue("111"); + + const result = await maybeRepairTelegramAllowFromUsernames({ + channels: { + telegram: { + botToken: "123:abc", + allowFrom: ["@testuser"], + }, + }, + } as unknown as OpenClawConfig); + + expect(result.config.channels?.telegram?.allowFrom).toEqual(["111"]); + expect(result.changes[0]).toContain("@testuser"); + }); + + it("formats username repair warnings", () => { + const warnings = collectTelegramAllowFromUsernameWarnings({ + hits: [{ path: "channels.telegram.allowFrom", entry: "@top" }], + doctorFixCommand: "openclaw doctor --fix", + }); + + expect(warnings[0]).toContain("non-numeric entries"); + expect(warnings[1]).toContain("openclaw doctor --fix"); + }); +}); diff --git a/src/commands/doctor/providers/telegram.ts b/extensions/telegram/src/doctor.ts similarity index 53% rename from src/commands/doctor/providers/telegram.ts rename to extensions/telegram/src/doctor.ts index 924583823aa..b998008687c 100644 --- a/src/commands/doctor/providers/telegram.ts +++ b/extensions/telegram/src/doctor.ts @@ -1,22 +1,24 @@ -import { getChannelPlugin } from "../../../channels/plugins/registry.js"; import { - inspectTelegramAccount, - isNumericTelegramUserId, - listTelegramAccountIds, - normalizeTelegramAllowFromEntry, -} from "../../../channels/read-only-account-inspect.telegram.js"; -import { resolveCommandSecretRefsViaGateway } from "../../../cli/command-secret-gateway.js"; -import { getChannelsCommandSecretTargetIds } from "../../../cli/command-secret-targets.js"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { createNonExitingRuntime } from "../../../runtime.js"; -import { describeUnknownError } from "../../../secrets/shared.js"; -import { sanitizeForLog } from "../../../terminal/ansi.js"; -import { hasAllowFromEntries } from "../shared/allowlist.js"; -import type { EmptyAllowlistAccountScanParams } from "../shared/empty-allowlist-scan.js"; -import { asObjectRecord } from "../shared/object.js"; -import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js"; + type ChannelDoctorAdapter, + type ChannelDoctorConfigMutation, + type ChannelDoctorEmptyAllowlistAccountContext, +} from "openclaw/plugin-sdk/channel-contract"; +import { + resolveTelegramPreviewStreamMode, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; +import { + getChannelsCommandSecretTargetIds, + resolveCommandSecretRefsViaGateway, +} from "openclaw/plugin-sdk/runtime"; +import { inspectTelegramAccount } from "./account-inspect.js"; +import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js"; +import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; +import { lookupTelegramChatId } from "./api-fetch.js"; type TelegramAllowFromUsernameHit = { path: string; entry: string }; +type DoctorAllowFromList = Array; +type DoctorAccountRecord = Record; type TelegramAllowFromListRef = { pathLabel: string; @@ -24,15 +26,130 @@ type TelegramAllowFromListRef = { key: "allowFrom" | "groupAllowFrom"; }; -export function collectTelegramAccountScopes( +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function sanitizeForLog(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); +} + +function describeUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function normalizeTelegramStreamingAliases(params: { + entry: Record; + pathPrefix: string; + changes: string[]; +}): { entry: Record; changed: boolean } { + let updated = params.entry; + const hadLegacyStreamMode = updated.streamMode !== undefined; + const beforeStreaming = updated.streaming; + const resolved = resolveTelegramPreviewStreamMode(updated); + const shouldNormalize = + hadLegacyStreamMode || + typeof beforeStreaming === "boolean" || + (typeof beforeStreaming === "string" && beforeStreaming !== resolved); + if (!shouldNormalize) { + return { entry: updated, changed: false }; + } + + let changed = false; + if (beforeStreaming !== resolved) { + updated = { ...updated, streaming: resolved }; + changed = true; + } + if (hadLegacyStreamMode) { + const { streamMode: _ignored, ...rest } = updated; + updated = rest; + changed = true; + params.changes.push( + `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`, + ); + } + if (typeof beforeStreaming === "boolean") { + params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`); + } else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) { + params.changes.push( + `Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`, + ); + } + return { entry: updated, changed }; +} + +function normalizeTelegramCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const rawEntry = asObjectRecord((cfg.channels as Record | undefined)?.telegram); + if (!rawEntry) { + return { config: cfg, changes: [] }; + } + + const changes: string[] = []; + let updated = rawEntry; + let changed = false; + + const base = normalizeTelegramStreamingAliases({ + entry: rawEntry, + pathPrefix: "channels.telegram", + changes, + }); + updated = base.entry; + changed = base.changed; + + const rawAccounts = asObjectRecord(updated.accounts); + if (rawAccounts) { + let accountsChanged = false; + const accounts = { ...rawAccounts }; + for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { + const account = asObjectRecord(rawAccount); + if (!account) { + continue; + } + const accountStreaming = normalizeTelegramStreamingAliases({ + entry: account, + pathPrefix: `channels.telegram.accounts.${accountId}`, + changes, + }); + if (accountStreaming.changed) { + accounts[accountId] = accountStreaming.entry; + accountsChanged = true; + } + } + if (accountsChanged) { + updated = { ...updated, accounts }; + changed = true; + } + } + + if (!changed) { + return { config: cfg, changes: [] }; + } + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + telegram: updated as unknown as NonNullable["telegram"], + } as OpenClawConfig["channels"], + }, + changes, + }; +} + +function hasAllowFromEntries(values?: DoctorAllowFromList): boolean { + return Array.isArray(values) && values.some((entry) => String(entry).trim()); +} + +function collectTelegramAccountScopes( cfg: OpenClawConfig, ): Array<{ prefix: string; account: Record }> { const scopes: Array<{ prefix: string; account: Record }> = []; - const telegram = asObjectRecord(cfg.channels?.telegram); + const telegram = asObjectRecord((cfg.channels as Record | undefined)?.telegram); if (!telegram) { return scopes; } - scopes.push({ prefix: "channels.telegram", account: telegram }); const accounts = asObjectRecord(telegram.accounts); if (!accounts) { @@ -40,16 +157,14 @@ export function collectTelegramAccountScopes( } for (const key of Object.keys(accounts)) { const account = asObjectRecord(accounts[key]); - if (!account) { - continue; + if (account) { + scopes.push({ prefix: `channels.telegram.accounts.${key}`, account }); } - scopes.push({ prefix: `channels.telegram.accounts.${key}`, account }); } - return scopes; } -export function collectTelegramAllowFromLists( +function collectTelegramAllowFromLists( prefix: string, account: Record, ): TelegramAllowFromListRef[] { @@ -61,7 +176,6 @@ export function collectTelegramAllowFromLists( if (!groups) { return refs; } - for (const groupId of Object.keys(groups)) { const group = asObjectRecord(groups[groupId]); if (!group) { @@ -95,17 +209,13 @@ export function scanTelegramAllowFromUsernameEntries( cfg: OpenClawConfig, ): TelegramAllowFromUsernameHit[] { const hits: TelegramAllowFromUsernameHit[] = []; - const scanList = (pathLabel: string, list: unknown) => { if (!Array.isArray(list)) { return; } for (const entry of list) { const normalized = normalizeTelegramAllowFromEntry(entry); - if (!normalized || normalized === "*") { - continue; - } - if (isNumericTelegramUserId(normalized)) { + if (!normalized || normalized === "*" || isNumericTelegramUserId(normalized)) { continue; } hits.push({ path: pathLabel, entry: String(entry).trim() }); @@ -117,7 +227,6 @@ export function scanTelegramAllowFromUsernameEntries( scanList(ref.pathLabel, ref.holder[ref.key]); } } - return hits; } @@ -150,10 +259,7 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig) targetIds: getChannelsCommandSecretTargetIds(), mode: "read_only_status", }); - const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => { - const inspected = inspectTelegramAccount({ cfg, accountId }); - return inspected.tokenStatus === "configured_unavailable"; - }); + const tokenResolutionWarnings: string[] = []; const resolverAccountIds: string[] = []; for (const accountId of listTelegramAccountIds(resolvedConfig)) { @@ -172,65 +278,58 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig) ); } const token = inspected.tokenSource === "none" ? "" : inspected.token.trim(); - if (!token) { - continue; + if (token) { + resolverAccountIds.push(accountId); } - resolverAccountIds.push(accountId); } - const telegramResolver = getChannelPlugin("telegram")?.resolver?.resolveTargets; - if (resolverAccountIds.length === 0 || !telegramResolver) { + if (resolverAccountIds.length === 0) { return { config: cfg, changes: [ ...tokenResolutionWarnings, - hasConfiguredUnavailableToken - ? `- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path; cannot auto-resolve (start the gateway or make the secret source available, then rerun doctor --fix).` - : !telegramResolver - ? `- Telegram allowFrom contains @username entries, but the Telegram channel resolver is unavailable; cannot auto-resolve in this command path.` - : `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run setup or replace with numeric sender IDs).`, + "- Telegram allowFrom contains @username entries, but no Telegram bot token is available in this command path; cannot auto-resolve.", ], }; } - - const resolverRuntime = createNonExitingRuntime(); const resolveUserId = async (raw: string): Promise => { const trimmed = raw.trim(); if (!trimmed) { return null; } - const stripped = normalizeTelegramAllowFromEntry(trimmed); - if (!stripped || stripped === "*") { + const normalized = normalizeTelegramAllowFromEntry(trimmed); + if (!normalized || normalized === "*") { return null; } - if (isNumericTelegramUserId(stripped)) { - return stripped; + if (isNumericTelegramUserId(normalized) || /\s/.test(normalized)) { + return isNumericTelegramUserId(normalized) ? normalized : null; } - if (/\s/.test(stripped)) { - return null; - } - const username = stripped.startsWith("@") ? stripped : `@${stripped}`; + const username = normalized.startsWith("@") ? normalized : `@${normalized}`; for (const accountId of resolverAccountIds) { try { - const [resolved] = await telegramResolver({ - cfg: resolvedConfig, - accountId, - inputs: [username], - kind: "user", - runtime: resolverRuntime, + const account = resolveTelegramAccount({ cfg: resolvedConfig, accountId }); + const token = account.token.trim(); + if (!token) { + continue; + } + const id = await lookupTelegramChatId({ + token, + chatId: username, + network: account.config.network, + signal: undefined, }); - if (resolved?.resolved && resolved.id) { - return resolved.id; + if (id) { + return id; } } catch { - // ignore and try next configured account + // ignore and try next account } } return null; }; - const changes: string[] = []; const next = structuredClone(cfg); + const changes: string[] = []; const repairList = async (pathLabel: string, holder: Record, key: string) => { const raw = holder[key]; @@ -244,11 +343,7 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig) if (!normalized) { continue; } - if (normalized === "*") { - out.push("*"); - continue; - } - if (isNumericTelegramUserId(normalized)) { + if (normalized === "*" || isNumericTelegramUserId(normalized)) { out.push(normalized); continue; } @@ -271,17 +366,15 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig) deduped.push(entry); } holder[key] = deduped; - if (replaced.length > 0) { - for (const rep of replaced.slice(0, 5)) { - changes.push( - `- ${sanitizeForLog(pathLabel)}: resolved ${sanitizeForLog(rep.from)} -> ${sanitizeForLog(rep.to)}`, - ); - } - if (replaced.length > 5) { - changes.push( - `- ${sanitizeForLog(pathLabel)}: resolved ${replaced.length - 5} more @username entries`, - ); - } + for (const replacement of replaced.slice(0, 5)) { + changes.push( + `- ${sanitizeForLog(pathLabel)}: resolved ${sanitizeForLog(replacement.from)} -> ${sanitizeForLog(replacement.to)}`, + ); + } + if (replaced.length > 5) { + changes.push( + `- ${sanitizeForLog(pathLabel)}: resolved ${replaced.length - 5} more @username entries`, + ); } }; @@ -299,22 +392,18 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig) function hasConfiguredGroups(account: DoctorAccountRecord, parent?: DoctorAccountRecord): boolean { const groups = - (account.groups as Record | undefined) ?? - (parent?.groups as Record | undefined); + (asObjectRecord(account.groups) as DoctorAccountRecord | null) ?? + (asObjectRecord(parent?.groups) as DoctorAccountRecord | null); return Boolean(groups) && Object.keys(groups ?? {}).length > 0; } -type CollectTelegramGroupPolicyWarningsParams = { +export function collectTelegramGroupPolicyWarnings(params: { account: DoctorAccountRecord; prefix: string; effectiveAllowFrom?: DoctorAllowFromList; dmPolicy?: string; parent?: DoctorAccountRecord; -}; - -export function collectTelegramGroupPolicyWarnings( - params: CollectTelegramGroupPolicyWarningsParams, -): string[] { +}): string[] { if (!hasConfiguredGroups(params.account, params.parent)) { const effectiveDmPolicy = params.dmPolicy ?? "pairing"; const dmSetupLine = @@ -333,11 +422,8 @@ export function collectTelegramGroupPolicyWarnings( const rawGroupAllowFrom = (params.account.groupAllowFrom as DoctorAllowFromList | undefined) ?? (params.parent?.groupAllowFrom as DoctorAllowFromList | undefined); - // Match runtime semantics: resolveGroupAllowFromSources treats empty arrays as - // unset and falls back to allowFrom. const groupAllowFrom = hasAllowFromEntries(rawGroupAllowFrom) ? rawGroupAllowFrom : undefined; const effectiveGroupAllowFrom = groupAllowFrom ?? params.effectiveAllowFrom; - if (hasAllowFromEntries(effectiveGroupAllowFrom)) { return []; } @@ -348,18 +434,32 @@ export function collectTelegramGroupPolicyWarnings( } export function collectTelegramEmptyAllowlistExtraWarnings( - params: EmptyAllowlistAccountScanParams, + params: ChannelDoctorEmptyAllowlistAccountContext, ): string[] { + const account = params.account as DoctorAccountRecord; + const parent = params.parent as DoctorAccountRecord | undefined; return params.channelName === "telegram" && - ((params.account.groupPolicy as string | undefined) ?? - (params.parent?.groupPolicy as string | undefined) ?? + ((account.groupPolicy as string | undefined) ?? + (parent?.groupPolicy as string | undefined) ?? undefined) === "allowlist" ? collectTelegramGroupPolicyWarnings({ - account: params.account, + account, dmPolicy: params.dmPolicy, - effectiveAllowFrom: params.effectiveAllowFrom, - parent: params.parent, + effectiveAllowFrom: params.effectiveAllowFrom as DoctorAllowFromList | undefined, + parent, prefix: params.prefix, }) : []; } + +export const telegramDoctor: ChannelDoctorAdapter = { + normalizeCompatibilityConfig: ({ cfg }) => normalizeTelegramCompatibilityConfig(cfg), + collectPreviewWarnings: ({ cfg, doctorFixCommand }) => + collectTelegramAllowFromUsernameWarnings({ + hits: scanTelegramAllowFromUsernameEntries(cfg), + doctorFixCommand, + }), + repairConfig: async ({ cfg }) => await maybeRepairTelegramAllowFromUsernames(cfg), + collectEmptyAllowlistExtraWarnings: collectTelegramEmptyAllowlistExtraWarnings, + shouldSkipDefaultEmptyGroupAllowlistWarning: (params) => params.channelName === "telegram", +}; diff --git a/extensions/telegram/src/interactive-dispatch.ts b/extensions/telegram/src/interactive-dispatch.ts new file mode 100644 index 00000000000..9dace75c49f --- /dev/null +++ b/extensions/telegram/src/interactive-dispatch.ts @@ -0,0 +1,117 @@ +import { + createInteractiveConversationBindingHelpers, + dispatchPluginInteractiveHandler, + type PluginConversationBinding, + type PluginConversationBindingRequestParams, + type PluginConversationBindingRequestResult, + type PluginInteractiveRegistration, +} from "openclaw/plugin-sdk/plugin-runtime"; + +export type TelegramInteractiveButtons = Array< + Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> +>; + +export type TelegramInteractiveHandlerContext = { + channel: "telegram"; + accountId: string; + callbackId: string; + conversationId: string; + parentConversationId?: string; + senderId?: string; + senderUsername?: string; + threadId?: number; + isGroup: boolean; + isForum: boolean; + auth: { + isAuthorizedSender: boolean; + }; + callback: { + data: string; + namespace: string; + payload: string; + messageId: number; + chatId: string; + messageText?: string; + }; + respond: { + reply: (params: { text: string; buttons?: TelegramInteractiveButtons }) => Promise; + editMessage: (params: { text: string; buttons?: TelegramInteractiveButtons }) => Promise; + editButtons: (params: { buttons: TelegramInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + }; + requestConversationBinding: ( + params?: PluginConversationBindingRequestParams, + ) => Promise; + detachConversationBinding: () => Promise<{ removed: boolean }>; + getCurrentConversationBinding: () => Promise; +}; + +export type TelegramInteractiveHandlerRegistration = PluginInteractiveRegistration< + TelegramInteractiveHandlerContext, + "telegram" +>; + +export type TelegramInteractiveDispatchContext = Omit< + TelegramInteractiveHandlerContext, + | "callback" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" +> & { + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; +}; + +export async function dispatchTelegramPluginInteractiveHandler(params: { + data: string; + callbackId: string; + ctx: TelegramInteractiveDispatchContext; + respond: { + reply: (params: { text: string; buttons?: TelegramInteractiveButtons }) => Promise; + editMessage: (params: { text: string; buttons?: TelegramInteractiveButtons }) => Promise; + editButtons: (params: { buttons: TelegramInteractiveButtons }) => Promise; + clearButtons: () => Promise; + deleteMessage: () => Promise; + }; + onMatched?: () => Promise | void; +}) { + return await dispatchPluginInteractiveHandler({ + channel: "telegram", + data: params.data, + dedupeId: params.callbackId, + onMatched: params.onMatched, + invoke: ({ registration, namespace, payload }) => { + const { callbackMessage, ...handlerContext } = params.ctx; + return registration.handler({ + ...handlerContext, + channel: "telegram", + callback: { + data: params.data, + namespace, + payload, + messageId: callbackMessage.messageId, + chatId: callbackMessage.chatId, + messageText: callbackMessage.messageText, + }, + respond: params.respond, + ...createInteractiveConversationBindingHelpers({ + registration, + senderId: handlerContext.senderId, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }), + }); + }, + }); +} diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index f1cb5ef8772..dcb942b7e7f 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -6,6 +6,7 @@ import { import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime"; import { resolveOutboundSendDep, + sanitizeForPlainText, type OutboundSendDeps, } from "openclaw/plugin-sdk/outbound-runtime"; import { @@ -109,6 +110,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { chunker: markdownToTelegramHtmlChunks, chunkerMode: "markdown", textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT, + sanitizeText: ({ text }) => sanitizeForPlainText(text), shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index c6970b097bc..562871fac40 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -19,7 +19,14 @@ import { resolveTelegramAccount, type ResolvedTelegramAccount, } from "./accounts.js"; +import { + buildTelegramCommandsListChannelData, + buildTelegramModelBrowseChannelData, + buildTelegramModelsListChannelData, + buildTelegramModelsProviderChannelData, +} from "./command-ui.js"; import { TelegramChannelConfigSchema } from "./config-schema.js"; +import { telegramDoctor } from "./doctor.js"; export const TELEGRAM_CHANNEL = "telegram" as const; @@ -108,7 +115,16 @@ export function createTelegramPluginBase(params: { setup: NonNullable["setup"]>; }): Pick< ChannelPlugin, - "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "commands" + | "doctor" + | "reload" + | "configSchema" + | "config" + | "setup" > { return createChannelPluginBase({ id: TELEGRAM_CHANNEL, @@ -126,6 +142,15 @@ export function createTelegramPluginBase(params: { nativeCommands: true, blockStreaming: true, }, + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: true, + buildCommandsListChannelData: buildTelegramCommandsListChannelData, + buildModelsProviderChannelData: buildTelegramModelsProviderChannelData, + buildModelsListChannelData: buildTelegramModelsListChannelData, + buildModelBrowseChannelData: buildTelegramModelBrowseChannelData, + }, + doctor: telegramDoctor, reload: { configPrefixes: ["channels.telegram"] }, configSchema: TelegramChannelConfigSchema, config: { @@ -196,6 +221,15 @@ export function createTelegramPluginBase(params: { setup: params.setup, }) as Pick< ChannelPlugin, - "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "commands" + | "doctor" + | "reload" + | "configSchema" + | "config" + | "setup" >; } diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 996e505568c..63a02b41d48 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -91,6 +91,7 @@ export const whatsappPlugin: ChannelPlugin = }, commands: { enforceOwnerForCommands: true, + preferSenderE164ForCommands: true, skipWhenConfigEmpty: true, }, agentPrompt: { diff --git a/extensions/whatsapp/src/doctor.ts b/extensions/whatsapp/src/doctor.ts new file mode 100644 index 00000000000..f1fb8448740 --- /dev/null +++ b/extensions/whatsapp/src/doctor.ts @@ -0,0 +1,52 @@ +import type { + ChannelDoctorAdapter, + ChannelDoctorConfigMutation, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +function normalizeWhatsAppAckReactionConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation { + const legacyAckReaction = cfg.messages?.ackReaction?.trim(); + if (!legacyAckReaction || cfg.channels?.whatsapp === undefined) { + return { config: cfg, changes: [] }; + } + if (cfg.channels.whatsapp?.ackReaction !== undefined) { + return { config: cfg, changes: [] }; + } + + const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions"; + let direct = true; + let group: "always" | "mentions" | "never" = "mentions"; + if (legacyScope === "all") { + direct = true; + group = "always"; + } else if (legacyScope === "direct") { + direct = true; + group = "never"; + } else if (legacyScope === "group-all") { + direct = false; + group = "always"; + } else if (legacyScope === "group-mentions") { + direct = false; + group = "mentions"; + } + + return { + config: { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...cfg.channels?.whatsapp, + ackReaction: { emoji: legacyAckReaction, direct, group }, + }, + }, + }, + changes: [ + `Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: ${legacyScope}).`, + ], + }; +} + +export const whatsappDoctor: ChannelDoctorAdapter = { + normalizeCompatibilityConfig: ({ cfg }) => normalizeWhatsAppAckReactionConfig(cfg), +}; diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index 29587ff8341..8943440f8a7 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -3,12 +3,12 @@ import { createAttachedChannelResultAdapter, createEmptyChannelResult, } from "openclaw/plugin-sdk/channel-send-result"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime"; -import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; +import { resolveOutboundSendDep, sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; import { resolveSendableOutboundReplyParts, sendTextMediaPayload, } from "openclaw/plugin-sdk/reply-payload"; +import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; @@ -22,6 +22,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = { chunker: chunkText, chunkerMode: "text", textChunkLimit: 4000, + sanitizeText: ({ text }) => sanitizeForPlainText(text), pollMaxOptions: 12, resolveTarget: ({ to, allowFrom, mode }) => resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), diff --git a/extensions/whatsapp/src/outbound-base.ts b/extensions/whatsapp/src/outbound-base.ts index 6e5fad9bdaa..026ddaadc3a 100644 --- a/extensions/whatsapp/src/outbound-base.ts +++ b/extensions/whatsapp/src/outbound-base.ts @@ -3,7 +3,7 @@ import { type ChannelOutboundAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveOutboundSendDep, sanitizeForPlainText } from "openclaw/plugin-sdk/infra-runtime"; type WhatsAppChunker = NonNullable; type WhatsAppSendTextOptions = { @@ -54,6 +54,7 @@ export function createWhatsAppOutboundBase({ | "chunker" | "chunkerMode" | "textChunkLimit" + | "sanitizeText" | "pollMaxOptions" | "resolveTarget" | "sendText" @@ -65,6 +66,7 @@ export function createWhatsAppOutboundBase({ chunker, chunkerMode: "text", textChunkLimit: 4000, + sanitizeText: ({ text }) => sanitizeForPlainText(text), pollMaxOptions: 12, resolveTarget, ...createAttachedChannelResultAdapter({ diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index c0b085e679e..3a0ae67f26f 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -18,6 +18,7 @@ import { type ResolvedWhatsAppAccount, } from "./accounts.js"; import { WhatsAppChannelConfigSchema } from "./config-schema.js"; +import { whatsappDoctor } from "./doctor.js"; import { formatWhatsAppConfigAllowFromEntries, getChatChannelMeta, @@ -159,6 +160,7 @@ export function createWhatsAppPluginBase(params: { resolveDmPolicy: whatsappResolveDmPolicy, collectWarnings: collectWhatsAppSecurityWarnings, }, + doctor: whatsappDoctor, setup: params.setup, groups: params.groups, }); @@ -183,6 +185,7 @@ export function createWhatsAppPluginBase(params: { | "configSchema" | "config" | "security" + | "doctor" | "setup" | "groups" >; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index fa09638924d..df7a321d48d 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -39,13 +39,11 @@ import { } from "./accounts.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; -import type { probeZalouser as probeZalouserImpl, ZalouserProbeResult } from "./probe.js"; +import { probeZalouser, type ZalouserProbeResult } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; -import type { - sendMessageZalouser as sendMessageZalouserImpl, - sendReactionZalouser as sendReactionZalouserImpl, -} from "./send.js"; +import { collectZalouserSecurityAuditFindings } from "./security-audit.js"; +import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; import { normalizeZalouserTarget, parseZalouserDirectoryGroupId, @@ -56,29 +54,15 @@ import { zalouserSetupAdapter } from "./setup-core.js"; import { zalouserSetupWizard } from "./setup-surface.js"; import { createZalouserPluginBase } from "./shared.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; - -type ZaloJsModule = typeof import("./zalo-js.js"); -type ZalouserSendModule = typeof import("./send.js"); -type ZalouserProbeModule = typeof import("./probe.js"); - -let zaloJsModulePromise: Promise | undefined; -let zalouserSendModulePromise: Promise | undefined; -let zalouserProbeModulePromise: Promise | undefined; - -async function loadZaloJsModule() { - zaloJsModulePromise ??= import("./zalo-js.js"); - return await zaloJsModulePromise; -} - -async function loadZalouserSendModule() { - zalouserSendModulePromise ??= import("./send.js"); - return await zalouserSendModulePromise; -} - -async function loadZalouserProbeModule() { - zalouserProbeModulePromise ??= import("./probe.js"); - return await zalouserProbeModulePromise; -} +import { + listZaloFriendsMatching, + listZaloGroupMembers, + listZaloGroupsMatching, + logoutZaloProfile, + startZaloQrLogin, + waitForZaloQrLogin, + getZaloUserInfo, +} from "./zalo-js.js"; const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({ @@ -86,9 +70,7 @@ const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({ sendText: async ({ to, text, accountId, cfg }) => { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); const target = parseZalouserOutboundTarget(to); - return await ( - await loadZalouserSendModule() - ).sendMessageZalouser(target.threadId, text, { + return await sendMessageZalouser(target.threadId, text, { profile: account.profile, isGroup: target.isGroup, textMode: "markdown", @@ -99,9 +81,7 @@ const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({ sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots, mediaReadFile }) => { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); const target = parseZalouserOutboundTarget(to); - return await ( - await loadZalouserSendModule() - ).sendMessageZalouser(target.threadId, text, { + return await sendMessageZalouser(target.threadId, text, { profile: account.profile, isGroup: target.isGroup, mediaUrl, @@ -200,12 +180,9 @@ const resolveZalouserDmPolicy = createScopedDmSecurityResolver { - const accounts = (accountId - ? [resolveZalouserAccountSync({ cfg, accountId })] - : listZalouserAccountIds(cfg).map((listedAccountId) => - resolveZalouserAccountSync({ cfg, accountId: listedAccountId }), - )) + describeMessageTool: ({ cfg }) => { + const accounts = listZalouserAccountIds(cfg) + .map((accountId) => resolveZalouserAccountSync({ cfg, accountId })) .filter((account) => account.enabled); if (accounts.length === 0) { return null; @@ -240,9 +217,7 @@ const zalouserMessageActions: ChannelMessageActionAdapter = { "Zalouser react requires messageId + cliMsgId (or a current message context id).", ); } - const result = await ( - await loadZalouserSendModule() - ).sendReactionZalouser({ + const result = await sendReactionZalouser({ profile: account.profile, threadId, isGroup: params.isGroup === true, @@ -305,7 +280,7 @@ export const zalouserPlugin: ChannelPlugin { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const parsed = await (await loadZaloJsModule()).getZaloUserInfo(account.profile); + const parsed = await getZaloUserInfo(account.profile); if (!parsed?.userId) { return null; } @@ -318,9 +293,7 @@ export const zalouserPlugin: ChannelPlugin { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const friends = await ( - await loadZaloJsModule() - ).listZaloFriendsMatching(account.profile, query); + const friends = await listZaloFriendsMatching(account.profile, query); const rows = friends.map((friend) => mapUser({ id: String(friend.userId), @@ -333,9 +306,7 @@ export const zalouserPlugin: ChannelPlugin { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const groups = await ( - await loadZaloJsModule() - ).listZaloGroupsMatching(account.profile, query); + const groups = await listZaloGroupsMatching(account.profile, query); const rows = groups.map((group) => mapGroup({ id: `group:${String(group.groupId)}`, @@ -348,9 +319,7 @@ export const zalouserPlugin: ChannelPlugin { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); const normalizedGroupId = parseZalouserDirectoryGroupId(groupId); - const members = await ( - await loadZaloJsModule() - ).listZaloGroupMembers(account.profile, normalizedGroupId); + const members = await listZaloGroupMembers(account.profile, normalizedGroupId); const rows = members.map((member) => mapUser({ id: member.userId, @@ -381,9 +350,7 @@ export const zalouserPlugin: ChannelPlugin 1 ? "multiple matches; chose first" : undefined, }); } else { - const groups = await ( - await loadZaloJsModule() - ).listZaloGroupsMatching(account.profile, trimmed); + const groups = await listZaloGroupsMatching(account.profile, trimmed); const best = groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ?? groups[0]; @@ -426,9 +391,7 @@ export const zalouserPlugin: ChannelPlugin buildPassiveProbedChannelStatusSummary(snapshot), - probeAccount: async ({ account, timeoutMs }) => - (await loadZalouserProbeModule()).probeZalouser(account.profile, timeoutMs), + probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs), resolveAccountSnapshot: async ({ account, runtime }) => { const configured = await checkZcaAuthenticated(account.profile); const configError = "not authenticated"; @@ -513,9 +470,7 @@ export const zalouserPlugin: ChannelPlugin { const profile = resolveZalouserQrProfile(params.accountId); - return await ( - await loadZaloJsModule() - ).startZaloQrLogin({ + return await startZaloQrLogin({ profile, force: params.force, timeoutMs: params.timeoutMs, @@ -523,21 +478,18 @@ export const zalouserPlugin: ChannelPlugin { const profile = resolveZalouserQrProfile(params.accountId); - return await ( - await loadZaloJsModule() - ).waitForZaloQrLogin({ + return await waitForZaloQrLogin({ profile, timeoutMs: params.timeoutMs, }); }, logoutAccount: async (ctx) => - await ( - await loadZaloJsModule() - ).logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)), + await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)), }, }, security: { resolveDmPolicy: resolveZalouserDmPolicy, + collectAuditFindings: collectZalouserSecurityAuditFindings, }, threading: { resolveReplyToMode: createStaticReplyToModeResolver("off"), @@ -553,9 +505,7 @@ export const zalouserPlugin: ChannelPlugin | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function sanitizeForLog(value: string): string { + return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); +} + +export function collectZalouserMutableAllowlistWarnings(cfg: OpenClawConfig): string[] { + const hits: Array<{ path: string; entry: string }> = []; + + for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "zalouser")) { + if (scope.dangerousNameMatchingEnabled) { + continue; + } + const groups = asObjectRecord(scope.account.groups); + if (!groups) { + continue; + } + for (const entry of Object.keys(groups)) { + if (isZalouserMutableGroupEntry(entry)) { + hits.push({ path: `${scope.prefix}.groups`, entry }); + } + } + } + + if (hits.length === 0) { + return []; + } + const exampleLines = hits + .slice(0, 8) + .map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`); + const remaining = + hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null; + return [ + `- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across zalouser while name matching is disabled by default.`, + ...exampleLines, + ...(remaining ? [remaining] : []), + "- Option A (break-glass): enable channels.zalouser.dangerousNameMatching=true for the affected scope.", + "- Option B (recommended): resolve mutable group names to stable IDs and rewrite the allowlist entries.", + ]; +} + +export const zalouserDoctor: ChannelDoctorAdapter = { + dmAllowFromMode: "topOnly", + groupModel: "hybrid", + groupAllowFromFallbackToAllowFrom: false, + warnOnEmptyGroupSenderAllowlist: false, + collectMutableAllowlistWarnings: ({ cfg }) => collectZalouserMutableAllowlistWarnings(cfg), +}; diff --git a/extensions/zalouser/src/shared.ts b/extensions/zalouser/src/shared.ts index 403c1f6d9a4..9aa71ee66fc 100644 --- a/extensions/zalouser/src/shared.ts +++ b/extensions/zalouser/src/shared.ts @@ -13,6 +13,7 @@ import { type ResolvedZalouserAccount, } from "./accounts.js"; import { ZalouserConfigSchema } from "./config-schema.js"; +import { zalouserDoctor } from "./doctor.js"; export const zalouserMeta = { id: "zalouser", @@ -52,7 +53,15 @@ export function createZalouserPluginBase(params: { setup: NonNullable["setup"]>; }): Pick< ChannelPlugin, - "id" | "meta" | "setupWizard" | "capabilities" | "reload" | "configSchema" | "config" | "setup" + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "doctor" + | "reload" + | "configSchema" + | "config" + | "setup" > { return { id: "zalouser", @@ -67,6 +76,7 @@ export function createZalouserPluginBase(params: { nativeCommands: false, blockStreaming: true, }, + doctor: zalouserDoctor, reload: { configPrefixes: ["channels.zalouser"] }, configSchema: buildChannelConfigSchema(ZalouserConfigSchema), config: { diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 6e1328ce1a8..a3851191c50 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -11,6 +11,7 @@ import { } from "../acp/runtime/session-identifiers.js"; import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js"; import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js"; +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -365,42 +366,18 @@ function resolveConversationIdForThreadBinding(params: { groupId?: string; }): string | undefined { const channel = params.channel?.trim().toLowerCase(); - const normalizedThreadId = - params.threadId != null ? String(params.threadId).trim() || undefined : undefined; - if (channel === "telegram") { - const rawChatId = (params.groupId ?? params.to ?? "").trim(); - let chatId = rawChatId; - while (true) { - const next = (() => { - if (/^(telegram|tg):/i.test(chatId)) { - return chatId.replace(/^(telegram|tg):/i, "").trim(); - } - if (/^(group|channel):/i.test(chatId)) { - return chatId.replace(/^(group|channel):/i, "").trim(); - } - return chatId; - })(); - if (next === chatId) { - break; - } - chatId = next; - } - const topicMatch = /^(.*?):topic:(\d+)$/i.exec(chatId); - if (topicMatch?.[1] && /^-?\d+$/.test(topicMatch[1].trim())) { - const topicId = normalizedThreadId ?? topicMatch[2]; - return `${topicMatch[1].trim()}:topic:${topicId}`; - } - const shorthandTopicMatch = /^(.*?):(\d+)$/i.exec(chatId); - if (shorthandTopicMatch?.[1] && /^-?\d+$/.test(shorthandTopicMatch[1].trim())) { - const topicId = normalizedThreadId ?? shorthandTopicMatch[2]; - return `${shorthandTopicMatch[1].trim()}:topic:${topicId}`; - } - if (/^-?\d+$/.test(chatId)) { - return normalizedThreadId ? `${chatId}:topic:${normalizedThreadId}` : chatId; - } - return undefined; + const normalizedChannelId = channel ? normalizeChannelId(channel) : null; + const pluginResolvedConversationId = normalizedChannelId + ? getChannelPlugin(normalizedChannelId)?.messaging?.resolveInboundConversation?.({ + to: params.groupId ?? params.to, + conversationId: params.groupId ?? params.to, + threadId: params.threadId, + isGroup: true, + })?.conversationId + : null; + if (pluginResolvedConversationId?.trim()) { + return pluginResolvedConversationId.trim(); } - const genericConversationId = resolveConversationIdFromTargets({ threadId: params.threadId, targets: [params.to], @@ -408,17 +385,6 @@ function resolveConversationIdForThreadBinding(params: { if (genericConversationId) { return genericConversationId; } - const target = params.to?.trim() || ""; - if (channel === "line") { - const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1]; - if (prefixed) { - return prefixed; - } - if (/^[UCR][a-f0-9]{32}$/i.test(target)) { - return target; - } - } - return undefined; } diff --git a/src/agents/command/delivery.ts b/src/agents/command/delivery.ts index f5107aaa1ff..4b075c3ff9f 100644 --- a/src/agents/command/delivery.ts +++ b/src/agents/command/delivery.ts @@ -117,7 +117,6 @@ export function normalizeAgentCommandReplyPayloads(params: { for (const payload of payloads) { const normalized = normalizeReplyPayload(payload as ReplyPayload, { responsePrefix: replyPrefix.responsePrefix, - enableSlackInteractiveReplies: replyPrefix.enableSlackInteractiveReplies, applyChannelTransforms, responsePrefixContext, }); @@ -204,9 +203,17 @@ export async function deliverAgentCommandResult(params: { const resolvedTarget = resolved.resolvedTarget; const deliveryTarget = resolved.resolvedTo; const resolvedThreadId = deliveryPlan.resolvedThreadId ?? opts.threadId; - const resolvedReplyToId = - deliveryChannel === "slack" && resolvedThreadId != null ? String(resolvedThreadId) : undefined; - const resolvedThreadTarget = deliveryChannel === "slack" ? undefined : resolvedThreadId; + const replyTransport = + deliveryPlugin?.threading?.resolveReplyTransport?.({ + cfg, + accountId: resolvedAccountId, + threadId: resolvedThreadId, + }) ?? null; + const resolvedReplyToId = replyTransport?.replyToId ?? undefined; + const resolvedThreadTarget = + replyTransport && Object.hasOwn(replyTransport, "threadId") + ? (replyTransport.threadId ?? null) + : (resolvedThreadId ?? null); const logDeliveryError = (err: unknown) => { const message = `Delivery failed (${deliveryChannel}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 5039f5d6327..3ac0189ed22 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -1,4 +1,16 @@ -import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js"; +import { resolveQueueSettings } from "../auto-reply/reply/queue.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; +import { loadConfig } from "../config/config.js"; +import { + loadSessionStore, + resolveAgentIdFromSessionKey, + resolveMainSessionKey, + resolveStorePath, +} from "../config/sessions.js"; +import { callGateway } from "../gateway/call.js"; +import { resolveExternalBestEffortDeliveryTarget } from "../infra/outbound/best-effort-delivery.js"; +import { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-router.js"; +import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; import type { ConversationRef } from "../infra/outbound/session-binding-service.js"; import { getGlobalHookRunner } from "../plugins/hook-runner-global.js"; import { normalizeAccountId, normalizeMainKey } from "../routing/session-key.js"; @@ -19,21 +31,12 @@ import { } from "../utils/message-channel.js"; import { buildAnnounceIdempotencyKey, resolveQueueAnnounceId } from "./announce-idempotency.js"; import type { AgentInternalEvent } from "./internal-events.js"; +import { isEmbeddedPiRunActive, queueEmbeddedPiMessage } from "./pi-embedded.js"; import { runSubagentAnnounceDispatch, type SubagentAnnounceDeliveryResult, } from "./subagent-announce-dispatch.js"; import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js"; -import { - callGateway, - isEmbeddedPiRunActive, - loadConfig, - loadSessionStore, - queueEmbeddedPiMessage, - resolveAgentIdFromSessionKey, - resolveMainSessionKey, - resolveStorePath, -} from "./subagent-announce.runtime.js"; import { getSubagentDepthFromSessionStore } from "./subagent-depth.js"; import type { SpawnSubagentMode } from "./subagent-spawn.js"; @@ -53,15 +56,6 @@ const defaultSubagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = { let subagentAnnounceDeliveryDeps: SubagentAnnounceDeliveryDeps = defaultSubagentAnnounceDeliveryDeps; -let subagentAnnounceDeliveryRuntimePromise: Promise< - typeof import("./subagent-announce-delivery.runtime.js") -> | null = null; - -function loadSubagentAnnounceDeliveryRuntime() { - subagentAnnounceDeliveryRuntimePromise ??= import("./subagent-announce-delivery.runtime.js"); - return subagentAnnounceDeliveryRuntimePromise; -} - function resolveDirectAnnounceTransientRetryDelaysMs() { return process.env.OPENCLAW_TEST_FAST === "1" ? ([8, 16, 32] as const) @@ -99,17 +93,6 @@ function summarizeDeliveryError(error: unknown): string { } } -function parseTelegramAnnounceTarget(to: string): { - chatId: string; - chatType: "direct" | "group" | "unknown"; -} { - const parsed = parseExplicitTargetForChannel("telegram", to); - const chatId = parsed?.to?.trim() ?? to.trim(); - const chatType = - parsed?.chatType === "direct" || parsed?.chatType === "group" ? parsed.chatType : "unknown"; - return { chatId, chatType }; -} - function shouldStripThreadFromAnnounceEntry( normalizedRequester?: DeliveryContext, normalizedEntry?: DeliveryContext, @@ -122,27 +105,13 @@ function shouldStripThreadFromAnnounceEntry( return false; } const requesterChannel = normalizedRequester.channel?.trim().toLowerCase(); - if (requesterChannel && requesterChannel !== "telegram") { - return true; - } - if (!requesterChannel && !normalizedRequester.to.startsWith("telegram:")) { - return true; - } - try { - const requesterTarget = parseTelegramAnnounceTarget(normalizedRequester.to); - if (requesterTarget.chatType !== "group") { - return true; - } - const entryTarget = normalizedEntry.to - ? parseTelegramAnnounceTarget(normalizedEntry.to) - : undefined; - if (entryTarget && entryTarget.chatId !== requesterTarget.chatId) { - return true; - } - return false; - } catch { - return false; - } + const plugin = requesterChannel ? getChannelPlugin(requesterChannel) : undefined; + return Boolean( + plugin?.conversationBindings?.shouldStripThreadFromAnnounceOrigin?.({ + requester: normalizedRequester, + entry: normalizedEntry, + }), + ); } const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [ @@ -266,7 +235,6 @@ export async function resolveSubagentCompletionOrigin(params: { spawnMode?: SpawnSubagentMode; expectsCompletionMessage: boolean; }): Promise { - const deliveryRuntime = await loadSubagentAnnounceDeliveryRuntime(); const requesterOrigin = normalizeDeliveryContext(params.requesterOrigin); const channel = requesterOrigin?.channel?.trim().toLowerCase(); const to = requesterOrigin?.to?.trim(); @@ -277,14 +245,14 @@ export async function resolveSubagentCompletionOrigin(params: { : undefined; const conversationId = threadId || - deliveryRuntime.resolveConversationIdFromTargets({ + resolveConversationIdFromTargets({ targets: [to], }) || ""; const requesterConversation: ConversationRef | undefined = channel && conversationId ? { channel, accountId, conversationId } : undefined; - const route = deliveryRuntime.createBoundDeliveryRouter().resolveDestination({ + const route = createBoundDeliveryRouter().resolveDestination({ eventKind: "task_completion", targetSessionKey: params.childSessionKey, requester: requesterConversation, @@ -445,7 +413,6 @@ async function maybeQueueSubagentAnnounce(params: { if (params.signal?.aborted) { return "none"; } - const deliveryRuntime = await loadSubagentAnnounceDeliveryRuntime(); const { cfg, entry } = loadRequesterSessionEntry(params.requesterSessionKey); const canonicalKey = resolveRequesterStoreKey(cfg, params.requesterSessionKey); const sessionId = entry?.sessionId; @@ -453,7 +420,7 @@ async function maybeQueueSubagentAnnounce(params: { return "none"; } - const queueSettings = deliveryRuntime.resolveQueueSettings({ + const queueSettings = resolveQueueSettings({ cfg, channel: entry?.channel ?? entry?.lastChannel ?? entry?.origin?.provider, sessionEntry: entry, @@ -520,7 +487,6 @@ async function sendSubagentAnnounceDirectly(params: { path: "none", }; } - const deliveryRuntime = await loadSubagentAnnounceDeliveryRuntime(); const cfg = subagentAnnounceDeliveryDeps.loadConfig(); const announceTimeoutMs = resolveSubagentAnnounceTimeoutMs(cfg); const canonicalRequesterSessionKey = resolveRequesterStoreKey( @@ -539,7 +505,7 @@ async function sendSubagentAnnounceDirectly(params: { ? effectiveDirectOrigin : requesterSessionOrigin; const deliveryTarget = !params.requesterIsSubagent - ? deliveryRuntime.resolveExternalBestEffortDeliveryTarget({ + ? resolveExternalBestEffortDeliveryTarget({ channel: effectiveDirectOrigin?.channel, to: effectiveDirectOrigin?.to, accountId: effectiveDirectOrigin?.accountId, diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 90616ed241e..b29ec32dcf7 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -1,6 +1,8 @@ import { createHmac, createHash } from "node:crypto"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import { resolveChannelApprovalCapability } from "../channels/plugins/approvals.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; import type { MemoryCitationsMode } from "../config/types.memory.js"; import { buildMemoryPromptSection } from "../plugins/memory-state.js"; import { listDeliverableMessageChannels } from "../utils/message-channel.js"; @@ -175,13 +177,13 @@ function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readT function buildExecApprovalPromptGuidance(params: { runtimeChannel?: string }) { const runtimeChannel = params.runtimeChannel?.trim().toLowerCase(); - if ( - runtimeChannel === "discord" || - runtimeChannel === "slack" || - runtimeChannel === "telegram" || - runtimeChannel === "webchat" - ) { - return "When exec returns approval-pending on Discord, Slack, Telegram, or WebChat, rely on the native approval card/buttons when they appear and do not also send plain chat /approve instructions. Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible."; + const usesNativeApprovalUi = + runtimeChannel === "webchat" || + (runtimeChannel + ? Boolean(resolveChannelApprovalCapability(getChannelPlugin(runtimeChannel))?.native) + : false); + if (usesNativeApprovalUi) { + return "When exec returns approval-pending on this channel, rely on native approval card/buttons when they appear and do not also send plain chat /approve instructions. Only include the concrete /approve command if the tool result says chat approvals are unavailable or only manual approval is possible."; } return "When exec returns approval-pending, include the concrete /approve command from tool output as plain chat text for the user, and do not ask for a different or rotated code."; } diff --git a/src/auto-reply/command-auth.ts b/src/auto-reply/command-auth.ts index 865f09a6e73..060ba242c3f 100644 --- a/src/auto-reply/command-auth.ts +++ b/src/auto-reply/command-auth.ts @@ -330,7 +330,7 @@ function resolveSenderCandidates(params: { } candidates.push(trimmed); }; - if (params.providerId === "whatsapp") { + if (plugin?.commands?.preferSenderE164ForCommands) { pushCandidate(params.senderE164); pushCandidate(params.senderId); } else { @@ -452,14 +452,6 @@ function resolveFallbackDefaultAccountConfig( return definedAccounts.length === 1 ? definedAccounts[0] : undefined; } -function resolveFallbackCommandOptions(providerId?: ChannelId): { - enforceOwnerForCommands: boolean; -} { - return { - enforceOwnerForCommands: providerId === "whatsapp", - }; -} - export function resolveCommandAuthorization(params: { ctx: MsgContext; cfg: OpenClawConfig; @@ -568,10 +560,7 @@ export function resolveCommandAuthorization(params: { : undefined; const senderId = matchedSender ?? senderCandidates[0]; - const enforceOwner = Boolean( - plugin?.commands?.enforceOwnerForCommands ?? - resolveFallbackCommandOptions(providerId).enforceOwnerForCommands, - ); + const enforceOwner = Boolean(plugin?.commands?.enforceOwnerForCommands); const senderIsOwnerByIdentity = Boolean(matchedSender); const senderIsOwnerByScope = isInternalMessageChannel(ctx.Provider) && diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index f271c3bb582..3a4a662afea 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,6 +1,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { SkillCommandSpec } from "../agents/skills.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; import { isCommandFlagEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/types.js"; import { escapeRegExp } from "../utils.js"; @@ -125,28 +126,19 @@ export function listChatCommandsForConfig( return [...base, ...buildSkillCommandDefinitions(params.skillCommands)]; } -const NATIVE_NAME_OVERRIDES: Record> = { - discord: { - tts: "voice", - }, - slack: { - // Slack reserves /status — registering it returns "invalid name" - // and invalidates the entire slash_commands manifest array. - status: "agentstatus", - }, -}; - function resolveNativeName(command: ChatCommandDefinition, provider?: string): string | undefined { if (!command.nativeName) { return undefined; } - if (provider) { - const override = NATIVE_NAME_OVERRIDES[provider]?.[command.key]; - if (override) { - return override; - } + if (!provider) { + return command.nativeName; } - return command.nativeName; + return ( + getChannelPlugin(provider)?.commands?.resolveNativeCommandName?.({ + commandKey: command.key, + defaultName: command.nativeName, + }) ?? command.nativeName + ); } function toNativeCommandSpec(command: ChatCommandDefinition, provider?: string): NativeCommandSpec { diff --git a/src/auto-reply/reply/auto-topic-label.test.ts b/src/auto-reply/reply/auto-topic-label.test.ts deleted file mode 100644 index f6a612ba2bf..00000000000 --- a/src/auto-reply/reply/auto-topic-label.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; - -const completeSimple = vi.hoisted(() => vi.fn()); -const getApiKeyForModel = vi.hoisted(() => vi.fn()); -const requireApiKey = vi.hoisted(() => vi.fn()); -const resolveDefaultModelForAgent = vi.hoisted(() => vi.fn()); -const resolveModelAsync = vi.hoisted(() => vi.fn()); -const prepareModelForSimpleCompletion = vi.hoisted(() => vi.fn()); - -vi.mock("@mariozechner/pi-ai", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - completeSimple, - }; -}); - -vi.mock("../../agents/model-auth.js", () => ({ - getApiKeyForModel, - requireApiKey, -})); - -vi.mock("../../agents/model-selection.js", () => ({ - resolveDefaultModelForAgent, -})); - -vi.mock("../../agents/pi-embedded-runner/model.js", () => ({ - resolveModelAsync, -})); - -vi.mock("../../agents/simple-completion-transport.js", () => ({ - prepareModelForSimpleCompletion, -})); - -import { generateTopicLabel, resolveAutoTopicLabelConfig } from "./auto-topic-label.js"; - -describe("resolveAutoTopicLabelConfig", () => { - const DEFAULT_PROMPT_SUBSTRING = "Generate a very short topic label"; - - it("returns enabled with default prompt when both configs are undefined", () => { - const result = resolveAutoTopicLabelConfig(undefined, undefined); - expect(result).not.toBeNull(); - expect(result!.enabled).toBe(true); - expect(result!.prompt).toContain(DEFAULT_PROMPT_SUBSTRING); - }); - - it("returns enabled with default prompt when config is true (boolean shorthand)", () => { - const result = resolveAutoTopicLabelConfig(true, undefined); - expect(result).not.toBeNull(); - expect(result!.enabled).toBe(true); - expect(result!.prompt).toContain(DEFAULT_PROMPT_SUBSTRING); - }); - - it("returns null when config is false", () => { - const result = resolveAutoTopicLabelConfig(false, undefined); - expect(result).toBeNull(); - }); - - it("returns enabled with custom prompt (object form)", () => { - const result = resolveAutoTopicLabelConfig( - { enabled: true, prompt: "Custom prompt" }, - undefined, - ); - expect(result).not.toBeNull(); - expect(result!.enabled).toBe(true); - expect(result!.prompt).toBe("Custom prompt"); - }); - - it("returns null when object form has enabled: false", () => { - const result = resolveAutoTopicLabelConfig({ enabled: false }, undefined); - expect(result).toBeNull(); - }); - - it("returns default prompt when object form has no prompt", () => { - const result = resolveAutoTopicLabelConfig({ enabled: true }, undefined); - expect(result).not.toBeNull(); - expect(result!.prompt).toContain(DEFAULT_PROMPT_SUBSTRING); - }); - - it("returns default prompt when object form has empty prompt", () => { - const result = resolveAutoTopicLabelConfig({ enabled: true, prompt: " " }, undefined); - expect(result).not.toBeNull(); - expect(result!.prompt).toContain(DEFAULT_PROMPT_SUBSTRING); - }); - - it("per-DM config takes priority over account config", () => { - const result = resolveAutoTopicLabelConfig(false, true); - expect(result).toBeNull(); - }); - - it("falls back to account config when direct config is undefined", () => { - const result = resolveAutoTopicLabelConfig(undefined, { - enabled: true, - prompt: "Account prompt", - }); - expect(result).not.toBeNull(); - expect(result!.prompt).toBe("Account prompt"); - }); - - it("per-DM disabled overrides account enabled", () => { - const result = resolveAutoTopicLabelConfig(false, { enabled: true, prompt: "Account prompt" }); - expect(result).toBeNull(); - }); - - it("per-DM custom prompt overrides account prompt", () => { - const result = resolveAutoTopicLabelConfig( - { prompt: "DM prompt" }, - { prompt: "Account prompt" }, - ); - expect(result).not.toBeNull(); - expect(result!.prompt).toBe("DM prompt"); - }); - - it("object form without enabled field defaults to enabled", () => { - const result = resolveAutoTopicLabelConfig({ prompt: "Test" }, undefined); - expect(result).not.toBeNull(); - expect(result!.enabled).toBe(true); - expect(result!.prompt).toBe("Test"); - }); -}); - -describe("generateTopicLabel", () => { - beforeEach(() => { - completeSimple.mockReset(); - getApiKeyForModel.mockReset(); - requireApiKey.mockReset(); - resolveDefaultModelForAgent.mockReset(); - resolveModelAsync.mockReset(); - prepareModelForSimpleCompletion.mockReset(); - - resolveDefaultModelForAgent.mockReturnValue({ provider: "openai", model: "gpt-test" }); - resolveModelAsync.mockResolvedValue({ - model: { provider: "openai" }, - authStorage: {}, - modelRegistry: {}, - }); - prepareModelForSimpleCompletion.mockImplementation(({ model }) => model); - getApiKeyForModel.mockResolvedValue({ apiKey: "resolved-key", mode: "api-key" }); - requireApiKey.mockReturnValue("resolved-key"); - completeSimple.mockResolvedValue({ - content: [{ type: "text", text: "Topic label" }], - }); - }); - - it("uses routed agentDir for model and auth resolution", async () => { - await generateTopicLabel({ - userMessage: "Need help with invoices", - prompt: "prompt", - cfg: {}, - agentId: "billing", - agentDir: "/tmp/agents/billing/agent", - }); - - expect(resolveDefaultModelForAgent).toHaveBeenCalledWith({ - cfg: {}, - agentId: "billing", - }); - expect(resolveModelAsync).toHaveBeenCalledWith( - "openai", - "gpt-test", - "/tmp/agents/billing/agent", - {}, - ); - expect(getApiKeyForModel).toHaveBeenCalledWith({ - model: { provider: "openai" }, - cfg: {}, - agentDir: "/tmp/agents/billing/agent", - }); - expect(prepareModelForSimpleCompletion).toHaveBeenCalledWith({ - model: { provider: "openai" }, - cfg: {}, - }); - }); -}); diff --git a/src/auto-reply/reply/channel-context.ts b/src/auto-reply/reply/channel-context.ts index afe77e32805..39cc45b1b2f 100644 --- a/src/auto-reply/reply/channel-context.ts +++ b/src/auto-reply/reply/channel-context.ts @@ -1,4 +1,4 @@ -type DiscordSurfaceParams = { +type CommandSurfaceParams = { ctx: { OriginatingChannel?: string; Surface?: string; @@ -10,25 +10,13 @@ type DiscordSurfaceParams = { }; }; -type DiscordAccountParams = { +type ChannelAccountParams = { ctx: { AccountId?: string; }; }; -export function isDiscordSurface(params: DiscordSurfaceParams): boolean { - return resolveCommandSurfaceChannel(params) === "discord"; -} - -export function isTelegramSurface(params: DiscordSurfaceParams): boolean { - return resolveCommandSurfaceChannel(params) === "telegram"; -} - -export function isMatrixSurface(params: DiscordSurfaceParams): boolean { - return resolveCommandSurfaceChannel(params) === "matrix"; -} - -export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string { +export function resolveCommandSurfaceChannel(params: CommandSurfaceParams): string { const channel = params.ctx.OriginatingChannel ?? params.command.channel ?? @@ -39,11 +27,7 @@ export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): stri .toLowerCase(); } -export function resolveDiscordAccountId(params: DiscordAccountParams): string { - return resolveChannelAccountId(params); -} - -export function resolveChannelAccountId(params: DiscordAccountParams): string { +export function resolveChannelAccountId(params: ChannelAccountParams): string { const accountId = typeof params.ctx.AccountId === "string" ? params.ctx.AccountId.trim() : ""; return accountId || "default"; } diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index f7bf29dfb1d..c03add6db19 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -160,6 +160,19 @@ function setMinimalAcpCommandRegistryForTests(): void { source: "test", plugin: { ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + conversationBindings: { + defaultTopLevelPlacement: "current", + buildBoundReplyChannelData: ({ + operation, + conversation, + }: { + operation: "acp-spawn"; + conversation: { conversationId: string }; + }) => + operation === "acp-spawn" && conversation.conversationId.includes(":topic:") + ? { telegram: { pin: true } } + : null, + }, bindings: { resolveCommandConversation: ({ threadId, @@ -197,6 +210,9 @@ function setMinimalAcpCommandRegistryForTests(): void { source: "test", plugin: { ...createChannelTestPluginBase({ id: "discord", label: "Discord" }), + conversationBindings: { + defaultTopLevelPlacement: "child", + }, bindings: { resolveCommandConversation: ({ threadId, @@ -265,6 +281,9 @@ function setMinimalAcpCommandRegistryForTests(): void { source: "test", plugin: { ...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), + conversationBindings: { + defaultTopLevelPlacement: "child", + }, bindings: { resolveCommandConversation: ({ threadId, diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 3efdd19fa85..c33516084f8 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -16,6 +16,7 @@ import { resolveAcpThreadSessionDetailLines, } from "../../../acp/runtime/session-identifiers.js"; import { resolveAcpSpawnRuntimePolicyError } from "../../../agents/acp-spawn.js"; +import { getChannelPlugin, normalizeChannelId } from "../../../channels/plugins/index.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -70,6 +71,27 @@ function resolveAcpBindingLabelNoun(params: { return params.conversationId === params.threadId ? "thread" : "conversation"; } +async function resolveBoundReplyChannelData(params: { + binding: SessionBindingRecord; + placement: "current" | "child"; +}): Promise | undefined> { + const channelId = normalizeChannelId(params.binding.conversation.channel); + if (!channelId) { + return undefined; + } + const buildChannelData = + getChannelPlugin(channelId)?.conversationBindings?.buildBoundReplyChannelData; + if (!buildChannelData) { + return undefined; + } + const resolved = await buildChannelData({ + operation: "acp-spawn", + placement: params.placement, + conversation: params.binding.conversation, + }); + return resolved ?? undefined; +} + async function bindSpawnedAcpSessionToCurrentConversation(params: { commandParams: HandleCommandsParams; sessionKey: string; @@ -574,19 +596,31 @@ export async function handleAcpSpawnAction( if (binding) { const currentConversationId = resolveAcpCommandConversationId(params)?.trim() || ""; const boundConversationId = binding.conversation.conversationId.trim(); + const bindingPlacement = + currentConversationId && boundConversationId === currentConversationId ? "current" : "child"; const placementLabel = resolveAcpBindingLabelNoun({ conversationId: currentConversationId, - placement: - currentConversationId && boundConversationId === currentConversationId - ? "current" - : "child", + placement: bindingPlacement, threadId: resolveAcpCommandThreadId(params), }); - if (currentConversationId && boundConversationId === currentConversationId) { + if (bindingPlacement === "current") { parts.push(`Bound this ${placementLabel} to ${sessionKey}.`); } else { parts.push(`Created ${placementLabel} ${boundConversationId} and bound it to ${sessionKey}.`); } + const channelData = await resolveBoundReplyChannelData({ + binding, + placement: bindingPlacement, + }); + if (channelData) { + return { + shouldContinue: false, + reply: { + text: parts.join(" "), + channelData, + }, + }; + } } else { parts.push( "Session is unbound (use /acp spawn ... --bind here to bind this conversation, or /focus where supported).", @@ -598,19 +632,6 @@ export async function handleAcpSpawnAction( parts.push(`ℹ️ ${dispatchNote}`); } - const shouldPinBindingNotice = - binding?.conversation.channel === "telegram" && - binding.conversation.conversationId.includes(":topic:"); - if (shouldPinBindingNotice) { - return { - shouldContinue: false, - reply: { - text: parts.join(" "), - channelData: { telegram: { pin: true } }, - }, - }; - } - return stopWithText(parts.join(" ")); } diff --git a/src/auto-reply/reply/commands-info.ts b/src/auto-reply/reply/commands-info.ts index c3025dc8a26..dd71d2ec3b1 100644 --- a/src/auto-reply/reply/commands-info.ts +++ b/src/auto-reply/reply/commands-info.ts @@ -1,5 +1,6 @@ import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import { logVerbose } from "../../globals.js"; import { listSkillCommandsForAgents } from "../skill-commands.js"; import { @@ -55,34 +56,23 @@ export const handleCommandsListCommand: CommandHandler = async (params, allowTex agentIds: params.agentId ? [params.agentId] : undefined, }); const surface = params.ctx.Surface; - - if (surface === "telegram") { - const result = buildCommandsMessagePaginated(params.cfg, skillCommands, { - page: 1, - surface, - }); - - if (result.totalPages > 1) { - return { - shouldContinue: false, - reply: { - text: result.text, - channelData: { - telegram: { - buttons: buildCommandsPaginationKeyboard( - result.currentPage, - result.totalPages, - params.agentId, - ), - }, - }, - }, - }; - } - + const commandPlugin = surface ? getChannelPlugin(surface) : null; + const paginated = buildCommandsMessagePaginated(params.cfg, skillCommands, { + page: 1, + surface, + }); + const channelData = commandPlugin?.commands?.buildCommandsListChannelData?.({ + currentPage: paginated.currentPage, + totalPages: paginated.totalPages, + agentId: params.agentId, + }); + if (channelData) { return { shouldContinue: false, - reply: { text: result.text }, + reply: { + text: paginated.text, + channelData, + }, }; } @@ -172,36 +162,6 @@ export const handleToolsCommand: CommandHandler = async (params, allowTextComman } }; -export function buildCommandsPaginationKeyboard( - currentPage: number, - totalPages: number, - agentId?: string, -): Array> { - const buttons: Array<{ text: string; callback_data: string }> = []; - const suffix = agentId ? `:${agentId}` : ""; - - if (currentPage > 1) { - buttons.push({ - text: "◀ Prev", - callback_data: `commands_page_${currentPage - 1}${suffix}`, - }); - } - - buttons.push({ - text: `${currentPage}/${totalPages}`, - callback_data: `commands_page_noop${suffix}`, - }); - - if (currentPage < totalPages) { - buttons.push({ - text: "Next ▶", - callback_data: `commands_page_${currentPage + 1}${suffix}`, - }); - } - - return [buttons]; -} - export const handleStatusCommand: CommandHandler = async (params, allowTextCommands) => { if (!allowTextCommands) { return null; diff --git a/src/auto-reply/reply/commands-models.telegram.ts b/src/auto-reply/reply/commands-models.telegram.ts deleted file mode 100644 index d28da71f4cc..00000000000 --- a/src/auto-reply/reply/commands-models.telegram.ts +++ /dev/null @@ -1,141 +0,0 @@ -type ButtonRow = Array<{ text: string; callback_data: string }>; - -export type ProviderInfo = { - id: string; - count: number; -}; - -type ModelsKeyboardParams = { - provider: string; - models: readonly string[]; - currentModel?: string; - currentPage: number; - totalPages: number; - pageSize?: number; - modelNames?: ReadonlyMap; -}; - -const MODELS_PAGE_SIZE = 8; -const MAX_CALLBACK_DATA_BYTES = 64; -const CALLBACK_PREFIX = { - providers: "mdl_prov", - back: "mdl_back", - list: "mdl_list_", - selectStandard: "mdl_sel_", - selectCompact: "mdl_sel/", -} as const; - -function truncateModelId(value: string, maxChars: number): string { - if (value.length <= maxChars) { - return value; - } - return `${value.slice(0, Math.max(0, maxChars - 3))}...`; -} - -function buildModelSelectionCallbackData(params: { - provider: string; - model: string; -}): string | null { - const fullCallbackData = `${CALLBACK_PREFIX.selectStandard}${params.provider}/${params.model}`; - if (Buffer.byteLength(fullCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES) { - return fullCallbackData; - } - const compactCallbackData = `${CALLBACK_PREFIX.selectCompact}${params.model}`; - return Buffer.byteLength(compactCallbackData, "utf8") <= MAX_CALLBACK_DATA_BYTES - ? compactCallbackData - : null; -} - -export function buildProviderKeyboard(providers: ProviderInfo[]): ButtonRow[] { - if (providers.length === 0) { - return []; - } - - const rows: ButtonRow[] = []; - let currentRow: ButtonRow = []; - - for (const provider of providers) { - currentRow.push({ - text: `${provider.id} (${provider.count})`, - callback_data: `mdl_list_${provider.id}_1`, - }); - if (currentRow.length === 2) { - rows.push(currentRow); - currentRow = []; - } - } - - if (currentRow.length > 0) { - rows.push(currentRow); - } - - return rows; -} - -export function buildModelsKeyboard(params: ModelsKeyboardParams): ButtonRow[] { - const { provider, models, currentModel, currentPage, totalPages, modelNames } = params; - const pageSize = params.pageSize ?? MODELS_PAGE_SIZE; - - if (models.length === 0) { - return [[{ text: "<< Back", callback_data: CALLBACK_PREFIX.back }]]; - } - - const rows: ButtonRow[] = []; - const startIndex = (currentPage - 1) * pageSize; - const endIndex = Math.min(startIndex + pageSize, models.length); - const pageModels = models.slice(startIndex, endIndex); - const currentModelId = currentModel?.includes("/") - ? currentModel.split("/").slice(1).join("/") - : currentModel; - - for (const model of pageModels) { - const callbackData = buildModelSelectionCallbackData({ provider, model }); - if (!callbackData) { - continue; - } - const isCurrentModel = model === currentModelId; - const displayLabel = modelNames?.get(`${provider}/${model}`) ?? model; - const displayText = truncateModelId(displayLabel, 38); - rows.push([ - { - text: isCurrentModel ? `${displayText} ✓` : displayText, - callback_data: callbackData, - }, - ]); - } - - const navRow: ButtonRow = []; - if (currentPage > 1) { - navRow.push({ - text: "Previous", - callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage - 1}`, - }); - } - if (currentPage < totalPages) { - navRow.push({ - text: "Next", - callback_data: `${CALLBACK_PREFIX.list}${provider}_${currentPage + 1}`, - }); - } - if (navRow.length > 0) { - rows.push(navRow); - } - - rows.push([{ text: "<< Back", callback_data: CALLBACK_PREFIX.providers }]); - return rows; -} - -export function buildBrowseProvidersButton(): ButtonRow[] { - return [[{ text: "Browse providers", callback_data: CALLBACK_PREFIX.providers }]]; -} - -export function getModelsPageSize(): number { - return MODELS_PAGE_SIZE; -} - -export function calculateTotalPages(totalModels: number, pageSize = MODELS_PAGE_SIZE): number { - if (totalModels <= 0) { - return 0; - } - return Math.ceil(totalModels / pageSize); -} diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index c105f924c04..8308529d19a 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -8,17 +8,11 @@ import { resolveDefaultModelForAgent, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; -import { - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - type ProviderInfo, -} from "./commands-models.telegram.js"; import type { CommandHandler } from "./commands-types.js"; const PAGE_SIZE_DEFAULT = 20; @@ -249,25 +243,24 @@ export async function resolveModelsCommandReply(params: { params.cfg, params.agentId, ); - const isTelegram = params.surface === "telegram"; + const commandPlugin = params.surface ? getChannelPlugin(params.surface) : null; // Provider list (no provider specified) if (!provider) { - // For Telegram: show buttons if there are providers - if (isTelegram && providers.length > 0) { - const providerInfos: ProviderInfo[] = providers.map((p) => ({ - id: p, - count: byProvider.get(p)?.size ?? 0, - })); - const buttons = buildProviderKeyboard(providerInfos); - const text = "Select a provider:"; + const providerInfos = providers.map((p) => ({ + id: p, + count: byProvider.get(p)?.size ?? 0, + })); + const channelData = commandPlugin?.commands?.buildModelsProviderChannelData?.({ + providers: providerInfos, + }); + if (channelData) { return { - text, - channelData: { telegram: { buttons } }, + text: "Select a provider:", + channelData, }; } - // Text fallback for non-Telegram surfaces const lines: string[] = [ "Providers:", ...providers.map((p) => @@ -311,22 +304,19 @@ export async function resolveModelsCommandReply(params: { return { text: lines.join("\n") }; } - // For Telegram: use button-based model list with inline keyboard pagination - if (isTelegram) { - const telegramPageSize = getModelsPageSize(); - const totalPages = calculateTotalPages(total, telegramPageSize); - const safePage = Math.max(1, Math.min(page, totalPages)); - - const buttons = buildModelsKeyboard({ - provider, - models, - currentModel: params.currentModel, - currentPage: safePage, - totalPages, - pageSize: telegramPageSize, - modelNames, - }); - + const interactivePageSize = 8; + const interactiveTotalPages = Math.max(1, Math.ceil(total / interactivePageSize)); + const interactivePage = Math.max(1, Math.min(page, interactiveTotalPages)); + const interactiveChannelData = commandPlugin?.commands?.buildModelsListChannelData?.({ + provider, + models, + currentModel: params.currentModel, + currentPage: interactivePage, + totalPages: interactiveTotalPages, + pageSize: interactivePageSize, + modelNames, + }); + if (interactiveChannelData) { const text = formatModelsAvailableHeader({ provider, total, @@ -336,11 +326,10 @@ export async function resolveModelsCommandReply(params: { }); return { text, - channelData: { telegram: { buttons } }, + channelData: interactiveChannelData, }; } - // Text fallback for non-Telegram surfaces const effectivePageSize = all ? total : pageSize; const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1; const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount)); diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index 4952334391e..8a1d8a839f6 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -2,44 +2,102 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; -function resolveThreadBindingIdleTimeoutMs(params: { - record: { idleTimeoutMs?: number }; - defaultIdleTimeoutMs: number; -}): number { - return typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; +const THREAD_CHANNEL = "thread-chat"; +const ROOM_CHANNEL = "room-chat"; +const TOPIC_CHANNEL = "topic-chat"; + +type ResolveCommandConversationParams = { + threadId?: string; + threadParentId?: string; + parentSessionKey?: string; + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; +}; + +function firstText(values: Array): string | undefined { + return values.map((value) => value?.trim() ?? "").find(Boolean) || undefined; } -function resolveThreadBindingInactivityExpiresAt(params: { - record: { boundAt: number; lastActivityAt: number; idleTimeoutMs?: number }; - defaultIdleTimeoutMs: number; -}): number | undefined { - const idleTimeoutMs = resolveThreadBindingIdleTimeoutMs(params); - return idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; +function resolveThreadTargetId(raw?: string): string | undefined { + const trimmed = raw?.trim() ?? ""; + if (!trimmed) { + return undefined; + } + return trimmed + .replace(/^thread-chat:/i, "") + .replace(/^channel:/i, "") + .trim(); } -function resolveThreadBindingMaxAgeMs(params: { - record: { maxAgeMs?: number }; - defaultMaxAgeMs: number; -}): number { - return typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; +function resolveThreadCommandConversation(params: ResolveCommandConversationParams) { + const parentConversationId = firstText([ + resolveThreadTargetId(params.threadParentId), + resolveThreadTargetId(params.originatingTo), + resolveThreadTargetId(params.commandTo), + resolveThreadTargetId(params.fallbackTo), + ]); + if (params.threadId) { + return { + conversationId: params.threadId, + ...(parentConversationId ? { parentConversationId } : {}), + }; + } + return parentConversationId ? { conversationId: parentConversationId } : null; } -function resolveThreadBindingMaxAgeExpiresAt(params: { - record: { boundAt: number; maxAgeMs?: number }; - defaultMaxAgeMs: number; -}): number | undefined { - const maxAgeMs = resolveThreadBindingMaxAgeMs(params); - return maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; +function resolveRoomId(raw?: string): string | undefined { + const trimmed = raw?.trim() ?? ""; + if (!trimmed) { + return undefined; + } + return trimmed + .replace(/^room-chat:/i, "") + .replace(/^(room|channel):/i, "") + .trim(); +} + +function resolveRoomCommandConversation(params: ResolveCommandConversationParams) { + const parentConversationId = firstText([ + resolveRoomId(params.originatingTo), + resolveRoomId(params.commandTo), + resolveRoomId(params.fallbackTo), + ]); + if (params.threadId) { + return { + conversationId: params.threadId, + ...(parentConversationId ? { parentConversationId } : {}), + }; + } + return parentConversationId ? { conversationId: parentConversationId } : null; +} + +function resolveTopicCommandConversation(params: ResolveCommandConversationParams) { + const chatId = firstText([params.originatingTo, params.commandTo, params.fallbackTo]) + ?.replace(/^topic-chat:/i, "") + .trim(); + if (!chatId) { + return null; + } + if (params.threadId) { + return { + conversationId: `${chatId}:topic:${params.threadId}`, + parentConversationId: chatId, + }; + } + if (chatId.startsWith("-")) { + return null; + } + return { + conversationId: chatId, + parentConversationId: chatId, + }; } const hoisted = vi.hoisted(() => { - const getThreadBindingManagerMock = vi.fn(); + const threadChannel = "thread-chat"; + const roomChannel = "room-chat"; + const topicChannel = "topic-chat"; const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); const setThreadBindingMaxAgeBySessionKeyMock = vi.fn(); const setMatrixThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); @@ -47,8 +105,62 @@ const hoisted = vi.hoisted(() => { const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn(); const sessionBindingResolveByConversationMock = vi.fn(); + const runtimeChannelRegistry = { + channels: [ + { + plugin: { + id: threadChannel, + meta: {}, + config: { + hasPersistedAuthState: () => false, + }, + bindings: { + resolveCommandConversation: resolveThreadCommandConversation, + }, + conversationBindings: { + supportsCurrentConversationBinding: true, + setIdleTimeoutBySessionKey: setThreadBindingIdleTimeoutBySessionKeyMock, + setMaxAgeBySessionKey: setThreadBindingMaxAgeBySessionKeyMock, + }, + }, + }, + { + plugin: { + id: roomChannel, + meta: {}, + config: { + hasPersistedAuthState: () => false, + }, + bindings: { + resolveCommandConversation: resolveRoomCommandConversation, + }, + conversationBindings: { + supportsCurrentConversationBinding: true, + setIdleTimeoutBySessionKey: setMatrixThreadBindingIdleTimeoutBySessionKeyMock, + setMaxAgeBySessionKey: setMatrixThreadBindingMaxAgeBySessionKeyMock, + }, + }, + }, + { + plugin: { + id: topicChannel, + meta: {}, + config: { + hasPersistedAuthState: () => false, + }, + bindings: { + resolveCommandConversation: resolveTopicCommandConversation, + }, + conversationBindings: { + supportsCurrentConversationBinding: true, + setIdleTimeoutBySessionKey: setTelegramThreadBindingIdleTimeoutBySessionKeyMock, + setMaxAgeBySessionKey: setTelegramThreadBindingMaxAgeBySessionKeyMock, + }, + }, + }, + ], + }; return { - getThreadBindingManagerMock, setThreadBindingIdleTimeoutBySessionKeyMock, setThreadBindingMaxAgeBySessionKeyMock, setMatrixThreadBindingIdleTimeoutBySessionKeyMock, @@ -56,53 +168,18 @@ const hoisted = vi.hoisted(() => { setTelegramThreadBindingIdleTimeoutBySessionKeyMock, setTelegramThreadBindingMaxAgeBySessionKeyMock, sessionBindingResolveByConversationMock, + runtimeChannelRegistry, }; }); -vi.mock("../../plugins/runtime/index.js", () => { +vi.mock("../../plugins/runtime.js", () => { return { - createPluginRuntime: () => ({ - channel: { - threadBindings: { - setIdleTimeoutBySessionKey: ({ channelId, ...params }: Record) => { - if (channelId === "telegram") { - return hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock(params); - } - if (channelId === "matrix") { - return hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock(params); - } - return hoisted.setThreadBindingIdleTimeoutBySessionKeyMock(params); - }, - setMaxAgeBySessionKey: ({ channelId, ...params }: Record) => { - if (channelId === "telegram") { - return hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock(params); - } - if (channelId === "matrix") { - return hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock(params); - } - return hoisted.setThreadBindingMaxAgeBySessionKeyMock(params); - }, - }, - discord: { - threadBindings: { - getManager: hoisted.getThreadBindingManagerMock, - resolveIdleTimeoutMs: resolveThreadBindingIdleTimeoutMs, - resolveInactivityExpiresAt: resolveThreadBindingInactivityExpiresAt, - resolveMaxAgeMs: resolveThreadBindingMaxAgeMs, - resolveMaxAgeExpiresAt: resolveThreadBindingMaxAgeExpiresAt, - setIdleTimeoutBySessionKey: hoisted.setThreadBindingIdleTimeoutBySessionKeyMock, - setMaxAgeBySessionKey: hoisted.setThreadBindingMaxAgeBySessionKeyMock, - unbindBySessionKey: vi.fn(), - }, - }, - matrix: { - threadBindings: { - setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock, - setMaxAgeBySessionKey: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock, - }, - }, - }, - }), + getActivePluginRegistry: () => hoisted.runtimeChannelRegistry, + requireActivePluginRegistry: () => hoisted.runtimeChannelRegistry, + getActivePluginChannelRegistry: () => hoisted.runtimeChannelRegistry, + requireActivePluginChannelRegistry: () => hoisted.runtimeChannelRegistry, + getActivePluginRegistryVersion: () => 1, + getActivePluginChannelRegistryVersion: () => 1, }; }); @@ -126,25 +203,11 @@ const baseCfg = { session: { mainKey: "main", scope: "per-sender" }, } satisfies OpenClawConfig; -type FakeBinding = { - accountId: string; - channelId: string; - threadId: string; - targetKind: "subagent" | "acp"; - targetSessionKey: string; - agentId: string; - boundBy: string; - boundAt: number; - lastActivityAt: number; - idleTimeoutMs?: number; - maxAgeMs?: number; -}; - -function createDiscordCommandParams(commandBody: string, overrides?: Record) { +function createThreadCommandParams(commandBody: string, overrides?: Record) { return buildCommandTestParams(commandBody, baseCfg, { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", + Provider: THREAD_CHANNEL, + Surface: THREAD_CHANNEL, + OriginatingChannel: THREAD_CHANNEL, OriginatingTo: "channel:thread-1", AccountId: "default", MessageThreadId: "thread-1", @@ -152,11 +215,11 @@ function createDiscordCommandParams(commandBody: string, overrides?: Record) { +function createTopicCommandParams(commandBody: string, overrides?: Record) { return buildCommandTestParams(commandBody, baseCfg, { - Provider: "telegram", - Surface: "telegram", - OriginatingChannel: "telegram", + Provider: TOPIC_CHANNEL, + Surface: TOPIC_CHANNEL, + OriginatingChannel: TOPIC_CHANNEL, OriginatingTo: "-100200300:topic:77", AccountId: "default", MessageThreadId: "77", @@ -164,11 +227,11 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record) { +function createRoomThreadCommandParams(commandBody: string, overrides?: Record) { return buildCommandTestParams(commandBody, baseCfg, { - Provider: "matrix", - Surface: "matrix", - OriginatingChannel: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, + OriginatingChannel: ROOM_CHANNEL, OriginatingTo: "room:!room:example.org", AccountId: "default", MessageThreadId: "$thread-1", @@ -176,14 +239,14 @@ function createMatrixThreadCommandParams(commandBody: string, overrides?: Record }); } -function createMatrixTriggerThreadCommandParams( +function createRoomTriggerThreadCommandParams( commandBody: string, overrides?: Record, ) { return buildCommandTestParams(commandBody, baseCfg, { - Provider: "matrix", - Surface: "matrix", - OriginatingChannel: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, + OriginatingChannel: ROOM_CHANNEL, OriginatingTo: "room:!room:example.org", AccountId: "default", MessageThreadId: "$root", @@ -191,40 +254,47 @@ function createMatrixTriggerThreadCommandParams( }); } -function createMatrixRoomCommandParams(commandBody: string, overrides?: Record) { +function createRoomCommandParams(commandBody: string, overrides?: Record) { return buildCommandTestParams(commandBody, baseCfg, { - Provider: "matrix", - Surface: "matrix", - OriginatingChannel: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, + OriginatingChannel: ROOM_CHANNEL, OriginatingTo: "room:!room:example.org", AccountId: "default", ...overrides, }); } -function createFakeBinding(overrides: Partial = {}): FakeBinding { - const now = Date.now(); +function createThreadBinding(overrides?: Partial): SessionBindingRecord { return { - accountId: "default", - channelId: "parent-1", - threadId: "thread-1", - targetKind: "subagent", + bindingId: "default:thread-1", targetSessionKey: "agent:main:subagent:child", - agentId: "main", - boundBy: "user-1", - boundAt: now, - lastActivityAt: now, + targetKind: "subagent", + conversation: { + channel: THREAD_CHANNEL, + accountId: "default", + conversationId: "thread-1", + parentConversationId: "thread-1", + }, + status: "active", + boundAt: Date.now(), + metadata: { + boundBy: "user-1", + lastActivityAt: Date.now(), + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }, ...overrides, }; } -function createTelegramBinding(overrides?: Partial): SessionBindingRecord { +function createTopicBinding(overrides?: Partial): SessionBindingRecord { return { bindingId: "default:-100200300:topic:77", targetSessionKey: "agent:main:subagent:child", targetKind: "subagent", conversation: { - channel: "telegram", + channel: TOPIC_CHANNEL, accountId: "default", conversationId: "-100200300:topic:77", }, @@ -240,13 +310,13 @@ function createTelegramBinding(overrides?: Partial): Sessi }; } -function createMatrixBinding(overrides?: Partial): SessionBindingRecord { +function createRoomBinding(overrides?: Partial): SessionBindingRecord { return { bindingId: "default:$thread-1", targetSessionKey: "agent:main:subagent:child", targetKind: "subagent", conversation: { - channel: "matrix", + channel: ROOM_CHANNEL, accountId: "default", conversationId: "$thread-1", parentConversationId: "!room:example.org", @@ -263,13 +333,11 @@ function createMatrixBinding(overrides?: Partial): Session }; } -function createMatrixTriggerBinding( - overrides?: Partial, -): SessionBindingRecord { - return createMatrixBinding({ +function createRoomTriggerBinding(overrides?: Partial): SessionBindingRecord { + return createRoomBinding({ bindingId: "default:$root", conversation: { - channel: "matrix", + channel: ROOM_CHANNEL, accountId: "default", conversationId: "$root", parentConversationId: "!room:example.org", @@ -293,17 +361,8 @@ function expectIdleTimeoutSetReply( expect(text).toContain("2026-02-20T02:00:00.000Z"); } -function createFakeThreadBindingManager(binding: FakeBinding | null) { - return { - getByThreadId: vi.fn((_threadId: string) => binding), - getIdleTimeoutMs: vi.fn(() => 24 * 60 * 60 * 1000), - getMaxAgeMs: vi.fn(() => 0), - }; -} - describe("/session idle and /session max-age", () => { beforeEach(() => { - hoisted.getThreadBindingManagerMock.mockReset(); hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset(); hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); @@ -314,21 +373,21 @@ describe("/session idle and /session max-age", () => { vi.useRealTimers(); }); - it("sets idle timeout for the focused Discord session", async () => { + it("sets idle timeout for the focused thread-chat session", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); - const binding = createFakeBinding(); - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding()); hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([ { - ...binding, + targetSessionKey: "agent:main:subagent:child", + boundAt: Date.now(), lastActivityAt: Date.now(), idleTimeoutMs: 2 * 60 * 60 * 1000, }, ]); - const result = await handleSessionCommand(createDiscordCommandParams("/session idle 2h"), true); + const result = await handleSessionCommand(createThreadCommandParams("/session idle 2h"), true); const text = result?.reply?.text ?? ""; expectIdleTimeoutSetReply( @@ -343,33 +402,38 @@ describe("/session idle and /session max-age", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); - const binding = createFakeBinding({ - idleTimeoutMs: 2 * 60 * 60 * 1000, - lastActivityAt: Date.now(), - }); - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createThreadBinding({ + metadata: { + boundBy: "user-1", + lastActivityAt: Date.now(), + idleTimeoutMs: 2 * 60 * 60 * 1000, + maxAgeMs: 0, + }, + }), + ); - const result = await handleSessionCommand(createDiscordCommandParams("/session idle"), true); + const result = await handleSessionCommand(createThreadCommandParams("/session idle"), true); expect(result?.reply?.text).toContain("Idle timeout active (2h"); expect(result?.reply?.text).toContain("2026-02-20T02:00:00.000Z"); }); - it("sets max age for the focused Discord session", async () => { + it("sets max age for the focused thread-chat session", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); - const binding = createFakeBinding(); - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createThreadBinding()); hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([ { - ...binding, + targetSessionKey: "agent:main:subagent:child", boundAt: Date.now(), + lastActivityAt: Date.now(), maxAgeMs: 3 * 60 * 60 * 1000, }, ]); const result = await handleSessionCommand( - createDiscordCommandParams("/session max-age 3h"), + createThreadCommandParams("/session max-age 3h"), true, ); const text = result?.reply?.text ?? ""; @@ -383,11 +447,11 @@ describe("/session idle and /session max-age", () => { expect(text).toContain("2026-02-20T03:00:00.000Z"); }); - it("sets idle timeout for focused Telegram conversations", async () => { + it("sets idle timeout for focused topic-chat conversations", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); - hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createTelegramBinding()); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createTopicBinding()); hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([ { targetSessionKey: "agent:main:subagent:child", @@ -397,10 +461,7 @@ describe("/session idle and /session max-age", () => { }, ]); - const result = await handleSessionCommand( - createTelegramCommandParams("/session idle 2h"), - true, - ); + const result = await handleSessionCommand(createTopicCommandParams("/session idle 2h"), true); const text = result?.reply?.text ?? ""; expectIdleTimeoutSetReply( @@ -411,11 +472,11 @@ describe("/session idle and /session max-age", () => { ); }); - it("sets idle timeout for focused Matrix threads", async () => { + it("sets idle timeout for focused room-chat threads", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); - hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixBinding()); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomBinding()); hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([ { targetSessionKey: "agent:main:subagent:child", @@ -426,7 +487,7 @@ describe("/session idle and /session max-age", () => { ]); const result = await handleSessionCommand( - createMatrixThreadCommandParams("/session idle 2h"), + createRoomThreadCommandParams("/session idle 2h"), true, ); const text = result?.reply?.text ?? ""; @@ -439,11 +500,11 @@ describe("/session idle and /session max-age", () => { ); }); - it("sets idle timeout for the triggering Matrix always-thread turn", async () => { + it("sets idle timeout for the triggering room-chat always-thread turn", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); - hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixTriggerBinding()); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomTriggerBinding()); hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([ { targetSessionKey: "agent:main:subagent:child", @@ -454,16 +515,17 @@ describe("/session idle and /session max-age", () => { ]); const result = await handleSessionCommand( - createMatrixTriggerThreadCommandParams("/session idle 2h"), + createRoomTriggerThreadCommandParams("/session idle 2h"), true, ); const text = result?.reply?.text ?? ""; expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({ - channel: "matrix", + channel: ROOM_CHANNEL, accountId: "default", conversationId: "$root", parentConversationId: "!room:example.org", + threadId: "$root", }); expectIdleTimeoutSetReply( hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock, @@ -473,14 +535,12 @@ describe("/session idle and /session max-age", () => { ); }); - it("sets max age for focused Matrix threads", async () => { + it("sets max age for focused room-chat threads", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); const boundAt = Date.parse("2026-02-19T22:00:00.000Z"); - hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createMatrixBinding({ boundAt }), - ); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createRoomBinding({ boundAt })); hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([ { targetSessionKey: "agent:main:subagent:child", @@ -491,7 +551,7 @@ describe("/session idle and /session max-age", () => { ]); const result = await handleSessionCommand( - createMatrixThreadCommandParams("/session max-age 3h"), + createRoomThreadCommandParams("/session max-age 3h"), true, ); const text = result?.reply?.text ?? ""; @@ -505,13 +565,13 @@ describe("/session idle and /session max-age", () => { expect(text).toContain("2026-02-20T01:00:00.000Z"); }); - it("reports Telegram max-age expiry from the original bind time", async () => { + it("reports topic-chat max-age expiry from the original bind time", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); const boundAt = Date.parse("2026-02-19T22:00:00.000Z"); hoisted.sessionBindingResolveByConversationMock.mockReturnValue( - createTelegramBinding({ boundAt }), + createTopicBinding({ boundAt }), ); hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([ { @@ -523,7 +583,7 @@ describe("/session idle and /session max-age", () => { ]); const result = await handleSessionCommand( - createTelegramCommandParams("/session max-age 3h"), + createTopicCommandParams("/session max-age 3h"), true, ); const text = result?.reply?.text ?? ""; @@ -538,12 +598,27 @@ describe("/session idle and /session max-age", () => { }); it("disables max age when set to off", async () => { - const binding = createFakeBinding({ maxAgeMs: 2 * 60 * 60 * 1000 }); - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); - hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([{ ...binding, maxAgeMs: 0 }]); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createThreadBinding({ + metadata: { + boundBy: "user-1", + lastActivityAt: Date.now(), + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 2 * 60 * 60 * 1000, + }, + }), + ); + hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([ + { + targetSessionKey: "agent:main:subagent:child", + boundAt: Date.now(), + lastActivityAt: Date.now(), + maxAgeMs: 0, + }, + ]); const result = await handleSessionCommand( - createDiscordCommandParams("/session max-age off"), + createThreadCommandParams("/session max-age off"), true, ); @@ -555,30 +630,35 @@ describe("/session idle and /session max-age", () => { expect(result?.reply?.text).toContain("Max age disabled"); }); - it("is unavailable outside discord and telegram", async () => { + it("is unavailable outside bindable channels", async () => { const params = buildCommandTestParams("/session idle 2h", baseCfg); const result = await handleSessionCommand(params, true); expect(result?.reply?.text).toContain( - "currently available for Discord, Matrix, and Telegram bound sessions", + "currently available only on channels that support focused conversation bindings", ); }); - it("requires a focused Matrix thread for lifecycle updates", async () => { - const result = await handleSessionCommand( - createMatrixRoomCommandParams("/session idle 2h"), - true, - ); + it("requires a focused room-chat thread for lifecycle updates", async () => { + const result = await handleSessionCommand(createRoomCommandParams("/session idle 2h"), true); - expect(result?.reply?.text).toContain("must be run inside a focused Matrix thread"); + expect(result?.reply?.text).toContain("This conversation is not currently focused."); expect(hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled(); }); it("requires binding owner for lifecycle updates", async () => { - const binding = createFakeBinding({ boundBy: "owner-1" }); - hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createThreadBinding({ + metadata: { + boundBy: "owner-1", + lastActivityAt: Date.now(), + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }, + }), + ); const result = await handleSessionCommand( - createDiscordCommandParams("/session idle 2h", { + createThreadCommandParams("/session idle 2h", { SenderId: "other-user", }), true, diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index a0f208e1c6d..9d36ed8e31f 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -3,6 +3,7 @@ import { setChannelConversationBindingIdleTimeoutBySessionKey, setChannelConversationBindingMaxAgeBySessionKey, } from "../../channels/plugins/conversation-bindings.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { formatThreadBindingDurationLabel } from "../../channels/thread-bindings-messages.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; import { isRestartEnabled } from "../../config/commands.js"; @@ -15,21 +16,12 @@ import { formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { parseActivationCommand } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; import { normalizeFastMode, normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js"; -import { - isDiscordSurface, - isMatrixSurface, - isTelegramSurface, - resolveChannelAccountId, -} from "./channel-context.js"; +import { resolveCommandSurfaceChannel } from "./channel-context.js"; import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js"; import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js"; import { persistSessionEntry } from "./commands-session-store.js"; import type { CommandHandler } from "./commands-types.js"; -import { - resolveMatrixConversationId, - resolveMatrixParentConversationId, -} from "./matrix-context.js"; -import { resolveTelegramConversationId } from "./telegram-context.js"; +import { resolveConversationBindingContextFromAcpCommand } from "./conversation-binding-input.js"; const SESSION_COMMAND_PREFIX = "/session"; const SESSION_DURATION_OFF_VALUES = new Set(["off", "disable", "disabled", "none", "0"]); @@ -418,118 +410,47 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm }; } - const onDiscord = isDiscordSurface(params); - const onMatrix = isMatrixSurface(params); - const onTelegram = isTelegramSurface(params); - if (!onDiscord && !onMatrix && !onTelegram) { + const channelId = + params.command.channelId ?? + normalizeChannelId(resolveCommandSurfaceChannel(params)) ?? + undefined; + const channelPlugin = channelId ? getChannelPlugin(channelId) : undefined; + const conversationBindings = channelPlugin?.conversationBindings; + const supportsCurrentConversationBinding = Boolean( + conversationBindings?.supportsCurrentConversationBinding, + ); + const supportsLifecycleUpdate = + action === SESSION_ACTION_IDLE + ? typeof conversationBindings?.setIdleTimeoutBySessionKey === "function" + : typeof conversationBindings?.setMaxAgeBySessionKey === "function"; + if (!channelId || !supportsCurrentConversationBinding || !supportsLifecycleUpdate) { return { shouldContinue: false, reply: { - text: "⚠️ /session idle and /session max-age are currently available for Discord, Matrix, and Telegram bound sessions.", + text: "⚠️ /session idle and /session max-age are currently available only on channels that support focused conversation bindings.", }, }; } - const accountId = resolveChannelAccountId(params); const sessionBindingService = getSessionBindingService(); - const threadId = - params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; - const matrixConversationId = onMatrix - ? resolveMatrixConversationId({ - ctx: { - MessageThreadId: params.ctx.MessageThreadId, - OriginatingTo: params.ctx.OriginatingTo, - To: params.ctx.To, - }, - command: { - to: params.command.to, - }, - }) - : undefined; - const matrixParentConversationId = onMatrix - ? resolveMatrixParentConversationId({ - ctx: { - MessageThreadId: params.ctx.MessageThreadId, - OriginatingTo: params.ctx.OriginatingTo, - To: params.ctx.To, - }, - command: { - to: params.command.to, - }, - }) - : undefined; - const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined; - const discordBinding = - onDiscord && threadId - ? sessionBindingService.resolveByConversation({ - channel: "discord", - accountId, - conversationId: threadId, - }) - : null; - const telegramBinding = - onTelegram && telegramConversationId - ? sessionBindingService.resolveByConversation({ - channel: "telegram", - accountId, - conversationId: telegramConversationId, - }) - : null; - const matrixBinding = - onMatrix && matrixConversationId - ? sessionBindingService.resolveByConversation({ - channel: "matrix", - accountId, - conversationId: matrixConversationId, - ...(matrixParentConversationId && matrixParentConversationId !== matrixConversationId - ? { parentConversationId: matrixParentConversationId } - : {}), - }) - : null; - if (onDiscord && !discordBinding) { - if (onDiscord && !threadId) { - return { - shouldContinue: false, - reply: { - text: "⚠️ /session idle and /session max-age must be run inside a focused Discord thread.", - }, - }; - } + const bindingContext = resolveConversationBindingContextFromAcpCommand(params); + if (!bindingContext) { return { shouldContinue: false, - reply: { text: "ℹ️ This thread is not currently focused." }, + reply: { + text: "⚠️ /session idle and /session max-age must be run inside a focused conversation.", + }, }; } - if (onMatrix && !matrixBinding) { - if (!threadId) { - return { - shouldContinue: false, - reply: { - text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.", - }, - }; - } - return { - shouldContinue: false, - reply: { text: "ℹ️ This thread is not currently focused." }, - }; - } - if (onTelegram && !telegramBinding) { - if (!telegramConversationId) { - return { - shouldContinue: false, - reply: { - text: "⚠️ /session idle and /session max-age on Telegram require a topic context in groups, or a direct-message conversation.", - }, - }; - } + + const activeBinding = sessionBindingService.resolveByConversation(bindingContext); + if (!activeBinding) { return { shouldContinue: false, reply: { text: "ℹ️ This conversation is not currently focused." }, }; } - const activeBinding = (onDiscord ? discordBinding : onMatrix ? matrixBinding : telegramBinding)!; const idleTimeoutMs = resolveSessionBindingDurationMs( activeBinding, "idleTimeoutMs", @@ -587,11 +508,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm return { shouldContinue: false, reply: { - text: onDiscord - ? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.` - : onMatrix - ? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.` - : `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`, + text: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`, }, }; } @@ -606,19 +523,18 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm }; } - const channelId = onDiscord ? "discord" : onMatrix ? "matrix" : "telegram"; const updatedBindings = action === SESSION_ACTION_IDLE ? setChannelConversationBindingIdleTimeoutBySessionKey({ - channelId, + channelId: bindingContext.channel, targetSessionKey: activeBinding.targetSessionKey, - accountId, + accountId: bindingContext.accountId, idleTimeoutMs: durationMs, }) : setChannelConversationBindingMaxAgeBySessionKey({ - channelId, + channelId: bindingContext.channel, targetSessionKey: activeBinding.targetSessionKey, - accountId, + accountId: bindingContext.accountId, maxAgeMs: durationMs, }); if (updatedBindings.length === 0) { diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index b6c14f0bb2c..51e37125f37 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -7,7 +7,101 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import { installSubagentsCommandCoreMocks } from "./commands-subagents.test-mocks.js"; +const THREAD_CHANNEL = "thread-chat"; +const ROOM_CHANNEL = "room-chat"; +const TOPIC_CHANNEL = "topic-chat"; + +type ResolveCommandConversationParams = { + threadId?: string; + threadParentId?: string; + parentSessionKey?: string; + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; +}; + +function firstText(values: Array): string | undefined { + return values.map((value) => value?.trim() ?? "").find(Boolean) || undefined; +} + +function resolveThreadCommandConversation(params: ResolveCommandConversationParams) { + const parentConversationId = firstText([ + params.threadParentId, + params.originatingTo + ?.replace(/^thread-chat:/i, "") + .replace(/^channel:/i, "") + .trim(), + params.commandTo + ?.replace(/^thread-chat:/i, "") + .replace(/^channel:/i, "") + .trim(), + params.fallbackTo + ?.replace(/^thread-chat:/i, "") + .replace(/^channel:/i, "") + .trim(), + ]); + if (params.threadId) { + return { + conversationId: params.threadId, + ...(parentConversationId ? { parentConversationId } : {}), + }; + } + return parentConversationId ? { conversationId: parentConversationId } : null; +} + +function normalizeRoomTarget(raw?: string): string | undefined { + const trimmed = raw?.trim() ?? ""; + if (!trimmed) { + return undefined; + } + return trimmed + .replace(/^room-chat:/i, "") + .replace(/^room:/i, "") + .trim(); +} + +function resolveRoomCommandConversation(params: ResolveCommandConversationParams) { + const parentConversationId = firstText([ + normalizeRoomTarget(params.originatingTo), + normalizeRoomTarget(params.commandTo), + normalizeRoomTarget(params.fallbackTo), + ]); + if (params.threadId) { + return { + conversationId: params.threadId, + ...(parentConversationId ? { parentConversationId } : {}), + }; + } + return parentConversationId ? { conversationId: parentConversationId } : null; +} + +function resolveTopicCommandConversation(params: ResolveCommandConversationParams) { + const chatId = firstText([params.originatingTo, params.commandTo, params.fallbackTo]) + ?.replace(/^topic-chat:/i, "") + .replace(/:topic:\d+$/i, "") + .trim(); + if (!chatId) { + return null; + } + if (params.threadId) { + return { + conversationId: `${chatId}:topic:${params.threadId}`, + parentConversationId: chatId, + }; + } + if (chatId.startsWith("-")) { + return null; + } + return { + conversationId: chatId, + parentConversationId: chatId, + }; +} + const hoisted = vi.hoisted(() => { + const threadChannel = "thread-chat"; + const roomChannel = "room-chat"; + const topicChannel = "topic-chat"; const callGatewayMock = vi.fn(); const readAcpSessionEntryMock = vi.fn(); const sessionBindingCapabilitiesMock = vi.fn(); @@ -15,6 +109,36 @@ const hoisted = vi.hoisted(() => { const sessionBindingResolveByConversationMock = vi.fn(); const sessionBindingListBySessionMock = vi.fn(); const sessionBindingUnbindMock = vi.fn(); + const runtimeChannelRegistry = { + channels: [ + { + plugin: { + id: threadChannel, + meta: {}, + config: { hasPersistedAuthState: () => false }, + bindings: { resolveCommandConversation: resolveThreadCommandConversation }, + }, + }, + { + plugin: { + id: roomChannel, + meta: {}, + config: { hasPersistedAuthState: () => false }, + conversationBindings: { defaultTopLevelPlacement: "child" }, + bindings: { resolveCommandConversation: resolveRoomCommandConversation }, + }, + }, + { + plugin: { + id: topicChannel, + meta: {}, + config: { hasPersistedAuthState: () => false }, + conversationBindings: { defaultTopLevelPlacement: "current" }, + bindings: { resolveCommandConversation: resolveTopicCommandConversation }, + }, + }, + ], + }; return { callGatewayMock, readAcpSessionEntryMock, @@ -23,6 +147,7 @@ const hoisted = vi.hoisted(() => { sessionBindingResolveByConversationMock, sessionBindingListBySessionMock, sessionBindingUnbindMock, + runtimeChannelRegistry, }; }); @@ -68,6 +193,15 @@ vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal }; }); +vi.mock("../../plugins/runtime.js", () => ({ + getActivePluginRegistry: () => hoisted.runtimeChannelRegistry, + requireActivePluginRegistry: () => hoisted.runtimeChannelRegistry, + getActivePluginChannelRegistry: () => hoisted.runtimeChannelRegistry, + requireActivePluginChannelRegistry: () => hoisted.runtimeChannelRegistry, + getActivePluginRegistryVersion: () => 1, + getActivePluginChannelRegistryVersion: () => 1, +})); + installSubagentsCommandCoreMocks(); const { handleSubagentsCommand } = await import("./commands-subagents.js"); @@ -77,11 +211,11 @@ const baseCfg = { session: { mainKey: "main", scope: "per-sender" }, } satisfies OpenClawConfig; -function createDiscordCommandParams(commandBody: string) { +function createThreadCommandParams(commandBody: string) { const params = buildCommandTestParams(commandBody, baseCfg, { - Provider: "discord", - Surface: "discord", - OriginatingChannel: "discord", + Provider: THREAD_CHANNEL, + Surface: THREAD_CHANNEL, + OriginatingChannel: THREAD_CHANNEL, OriginatingTo: "channel:parent-1", AccountId: "default", MessageThreadId: "thread-1", @@ -90,11 +224,11 @@ function createDiscordCommandParams(commandBody: string) { return params; } -function createTelegramTopicCommandParams(commandBody: string) { +function createTopicCommandParams(commandBody: string) { const params = buildCommandTestParams(commandBody, baseCfg, { - Provider: "telegram", - Surface: "telegram", - OriginatingChannel: "telegram", + Provider: TOPIC_CHANNEL, + Surface: TOPIC_CHANNEL, + OriginatingChannel: TOPIC_CHANNEL, OriginatingTo: "-100200300:topic:77", AccountId: "default", MessageThreadId: "77", @@ -103,11 +237,11 @@ function createTelegramTopicCommandParams(commandBody: string) { return params; } -function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { +function createRoomThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = buildCommandTestParams(commandBody, cfg, { - Provider: "matrix", - Surface: "matrix", - OriginatingChannel: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, + OriginatingChannel: ROOM_CHANNEL, OriginatingTo: "room:!room:example.org", AccountId: "default", MessageThreadId: "$thread-1", @@ -116,14 +250,11 @@ function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfi return params; } -function createMatrixTriggerThreadCommandParams( - commandBody: string, - cfg: OpenClawConfig = baseCfg, -) { +function createRoomTriggerThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = buildCommandTestParams(commandBody, cfg, { - Provider: "matrix", - Surface: "matrix", - OriginatingChannel: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, + OriginatingChannel: ROOM_CHANNEL, OriginatingTo: "room:!room:example.org", AccountId: "default", MessageThreadId: "$root", @@ -132,11 +263,11 @@ function createMatrixTriggerThreadCommandParams( return params; } -function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { +function createRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = buildCommandTestParams(commandBody, cfg, { - Provider: "matrix", - Surface: "matrix", - OriginatingChannel: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, + OriginatingChannel: ROOM_CHANNEL, OriginatingTo: "room:!room:example.org", AccountId: "default", }); @@ -152,7 +283,7 @@ function createSessionBindingRecord( targetSessionKey: "agent:codex-acp:session-1", targetKind: "session", conversation: { - channel: "discord", + channel: THREAD_CHANNEL, accountId: "default", conversationId: "thread-1", parentConversationId: "parent-1", @@ -177,7 +308,7 @@ function createSessionBindingCapabilities() { } async function focusCodexAcp( - params = createDiscordCommandParams("/focus codex-acp"), + params = createThreadCommandParams("/focus codex-acp"), options?: { existingBinding?: SessionBindingRecord | null }, ) { hoisted.sessionBindingCapabilitiesMock.mockReturnValue(createSessionBindingCapabilities()); @@ -234,10 +365,10 @@ describe("/focus, /unfocus, /agents", () => { hoisted.sessionBindingBindMock.mockReset(); }); - it("/focus resolves ACP sessions and binds the current Discord thread", async () => { + it("/focus resolves ACP sessions and binds the current thread-chat thread", async () => { const result = await focusCodexAcp(); - expect(result?.reply?.text).toContain("bound this thread"); + expect(result?.reply?.text).toContain("bound this conversation"); expect(result?.reply?.text).toContain("(acp)"); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ @@ -245,7 +376,7 @@ describe("/focus, /unfocus, /agents", () => { targetKind: "session", targetSessionKey: "agent:codex-acp:session-1", conversation: expect.objectContaining({ - channel: "discord", + channel: THREAD_CHANNEL, conversationId: "thread-1", }), metadata: expect.objectContaining({ @@ -256,57 +387,57 @@ describe("/focus, /unfocus, /agents", () => { ); }); - it("/focus binds Telegram topics as current conversations", async () => { - const result = await focusCodexAcp(createTelegramTopicCommandParams("/focus codex-acp")); + it("/focus binds topic-chat topics as current conversations", async () => { + const result = await focusCodexAcp(createTopicCommandParams("/focus codex-acp")); expect(result?.reply?.text).toContain("bound this conversation"); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ placement: "current", conversation: expect.objectContaining({ - channel: "telegram", + channel: TOPIC_CHANNEL, conversationId: "-100200300:topic:77", }), }), ); }); - it("/focus creates a Matrix thread from a top-level room when spawnSubagentSessions is enabled", async () => { + it("/focus creates a room-chat thread from a top-level room when spawnSubagentSessions is enabled", async () => { const cfg = { ...baseCfg, channels: { - matrix: { + [ROOM_CHANNEL]: { threadBindings: { enabled: true, spawnSubagentSessions: true, }, }, - }, - } satisfies OpenClawConfig; + } as OpenClawConfig["channels"], + } as OpenClawConfig; - const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg)); + const result = await focusCodexAcp(createRoomCommandParams("/focus codex-acp", cfg)); - expect(result?.reply?.text).toContain("created thread thread-created and bound it"); + expect(result?.reply?.text).toContain("created child conversation thread-created and bound it"); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ placement: "child", conversation: expect.objectContaining({ - channel: "matrix", + channel: ROOM_CHANNEL, conversationId: "!room:example.org", }), }), ); }); - it("/focus treats the triggering Matrix always-thread turn as the current thread", async () => { - const result = await focusCodexAcp(createMatrixTriggerThreadCommandParams("/focus codex-acp")); + it("/focus treats the triggering room-chat always-thread turn as the current thread", async () => { + const result = await focusCodexAcp(createRoomTriggerThreadCommandParams("/focus codex-acp")); - expect(result?.reply?.text).toContain("bound this thread"); + expect(result?.reply?.text).toContain("bound this conversation"); expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( expect.objectContaining({ placement: "current", conversation: expect.objectContaining({ - channel: "matrix", + channel: ROOM_CHANNEL, conversationId: "$root", parentConversationId: "!room:example.org", }), @@ -314,21 +445,23 @@ describe("/focus, /unfocus, /agents", () => { ); }); - it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => { + it("/focus rejects room-chat top-level thread creation when spawnSubagentSessions is disabled", async () => { const cfg = { ...baseCfg, channels: { - matrix: { + [ROOM_CHANNEL]: { threadBindings: { enabled: true, }, }, - }, - } satisfies OpenClawConfig; + } as OpenClawConfig["channels"], + } as OpenClawConfig; - const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg)); + const result = await focusCodexAcp(createRoomCommandParams("/focus codex-acp", cfg)); - expect(result?.reply?.text).toContain("spawnSubagentSessions=true"); + expect(result?.reply?.text).toContain( + `channels.${ROOM_CHANNEL}.threadBindings.spawnSubagentSessions=true`, + ); expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); }); @@ -378,7 +511,7 @@ describe("/focus, /unfocus, /agents", () => { }); it("/unfocus removes an active binding for the binding owner", async () => { - const params = createDiscordCommandParams("/unfocus"); + const params = createThreadCommandParams("/unfocus"); hoisted.sessionBindingResolveByConversationMock.mockReturnValue( createSessionBindingRecord({ bindingId: "default:thread-1", @@ -388,20 +521,20 @@ describe("/focus, /unfocus, /agents", () => { const result = await handleSubagentsCommand(params, true); - expect(result?.reply?.text).toContain("Thread unfocused"); + expect(result?.reply?.text).toContain("Conversation unfocused"); expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({ bindingId: "default:thread-1", reason: "manual", }); }); - it("/unfocus removes an active Matrix thread binding for the binding owner", async () => { - const params = createMatrixThreadCommandParams("/unfocus"); + it("/unfocus removes an active room-chat thread binding for the binding owner", async () => { + const params = createRoomThreadCommandParams("/unfocus"); hoisted.sessionBindingResolveByConversationMock.mockReturnValue( createSessionBindingRecord({ - bindingId: "default:matrix-thread-1", + bindingId: "default:room-thread-1", conversation: { - channel: "matrix", + channel: ROOM_CHANNEL, accountId: "default", conversationId: "$thread-1", parentConversationId: "!room:example.org", @@ -412,15 +545,15 @@ describe("/focus, /unfocus, /agents", () => { const result = await handleSubagentsCommand(params, true); - expect(result?.reply?.text).toContain("Thread unfocused"); + expect(result?.reply?.text).toContain("Conversation unfocused"); expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({ - channel: "matrix", + channel: ROOM_CHANNEL, accountId: "default", conversationId: "$thread-1", parentConversationId: "!room:example.org", }); expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({ - bindingId: "default:matrix-thread-1", + bindingId: "default:room-thread-1", reason: "manual", }); }); @@ -432,7 +565,7 @@ describe("/focus, /unfocus, /agents", () => { }), }); - expect(result?.reply?.text).toContain("Only user-2 can refocus this thread."); + expect(result?.reply?.text).toContain("Only user-2 can refocus this conversation."); expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); }); @@ -456,7 +589,7 @@ describe("/focus, /unfocus, /agents", () => { targetSessionKey: sessionKey, targetKind: "subagent", conversation: { - channel: "discord", + channel: THREAD_CHANNEL, accountId: "default", conversationId: "thread-1", }, @@ -470,7 +603,7 @@ describe("/focus, /unfocus, /agents", () => { targetSessionKey: sessionKey, targetKind: "session", conversation: { - channel: "discord", + channel: THREAD_CHANNEL, accountId: "default", conversationId: "thread-2", }, @@ -482,7 +615,7 @@ describe("/focus, /unfocus, /agents", () => { targetSessionKey: sessionKey, targetKind: "session", conversation: { - channel: "telegram", + channel: TOPIC_CHANNEL, accountId: "default", conversationId: "12345", }, @@ -492,11 +625,11 @@ describe("/focus, /unfocus, /agents", () => { return []; }); - const result = await handleSubagentsCommand(createDiscordCommandParams("/agents"), true); + const result = await handleSubagentsCommand(createThreadCommandParams("/agents"), true); const text = result?.reply?.text ?? ""; expect(text).toContain("agents:"); - expect(text).toContain("thread:thread-1"); + expect(text).toContain("binding:thread-1"); expect(text).toContain("acp/session bindings:"); expect(text).toContain("session:agent:main:main"); expect(text).not.toContain("default:tg-1"); @@ -525,7 +658,7 @@ describe("/focus, /unfocus, /agents", () => { targetSessionKey: sessionKey, targetKind: "subagent", conversation: { - channel: "discord", + channel: THREAD_CHANNEL, accountId: "default", conversationId: "thread-persistent-1", }, @@ -533,16 +666,16 @@ describe("/focus, /unfocus, /agents", () => { ]; }); - const result = await handleSubagentsCommand(createDiscordCommandParams("/agents"), true); + const result = await handleSubagentsCommand(createThreadCommandParams("/agents"), true); const text = result?.reply?.text ?? ""; expect(text).toContain("persistent-1"); - expect(text).toContain("thread:thread-persistent-1"); + expect(text).toContain("binding:thread-persistent-1"); }); it("/focus rejects unsupported channels", async () => { const params = buildCommandTestParams("/focus codex-acp", baseCfg); const result = await handleSubagentsCommand(params, true); - expect(result?.reply?.text).toContain("only available on Discord, Matrix, and Telegram"); + expect(result?.reply?.text).toContain("must be run inside a bindable conversation"); }); }); diff --git a/src/auto-reply/reply/commands-subagents/action-agents.test.ts b/src/auto-reply/reply/commands-subagents/action-agents.test.ts index 0b7a076a5f4..17b6449c514 100644 --- a/src/auto-reply/reply/commands-subagents/action-agents.test.ts +++ b/src/auto-reply/reply/commands-subagents/action-agents.test.ts @@ -1,7 +1,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { listBySessionMock } = vi.hoisted(() => ({ +const THREAD_CHANNEL = "thread-chat"; +const ROOM_CHANNEL = "room-chat"; + +const { listBySessionMock, getChannelPluginMock, normalizeChannelIdMock } = vi.hoisted(() => ({ listBySessionMock: vi.fn(), + getChannelPluginMock: vi.fn((channel: string) => + channel === "thread-chat" || channel === "room-chat" + ? { + config: { + hasPersistedAuthState: () => false, + }, + conversationBindings: { + supportsCurrentConversationBinding: true, + }, + } + : null, + ), + normalizeChannelIdMock: vi.fn((channel: string) => channel), })); vi.mock("../../../infra/outbound/session-binding-service.js", () => ({ @@ -10,6 +26,11 @@ vi.mock("../../../infra/outbound/session-binding-service.js", () => ({ }), })); +vi.mock("../../../channels/plugins/index.js", () => ({ + getChannelPlugin: getChannelPluginMock, + normalizeChannelId: normalizeChannelIdMock, +})); + let handleSubagentsAgentsAction: typeof import("./action-agents.js").handleSubagentsAgentsAction; describe("handleSubagentsAgentsAction", () => { @@ -17,6 +38,8 @@ describe("handleSubagentsAgentsAction", () => { vi.resetModules(); ({ handleSubagentsAgentsAction } = await import("./action-agents.js")); listBySessionMock.mockReset(); + getChannelPluginMock.mockClear(); + normalizeChannelIdMock.mockClear(); }); it("dedupes stale bound rows for the same child session", () => { @@ -29,7 +52,7 @@ describe("handleSubagentsAgentsAction", () => { targetSessionKey: childSessionKey, targetKind: "subagent", conversation: { - channel: "discord", + channel: THREAD_CHANNEL, accountId: "default", conversationId: "thread-1", }, @@ -43,11 +66,11 @@ describe("handleSubagentsAgentsAction", () => { const result = handleSubagentsAgentsAction({ params: { ctx: { - Provider: "discord", - Surface: "discord", + Provider: THREAD_CHANNEL, + Surface: THREAD_CHANNEL, }, command: { - channel: "discord", + channel: THREAD_CHANNEL, }, }, requesterKey: "agent:main:main", @@ -93,7 +116,7 @@ describe("handleSubagentsAgentsAction", () => { targetSessionKey: visibleSessionKey, targetKind: "subagent", conversation: { - channel: "discord", + channel: THREAD_CHANNEL, accountId: "default", conversationId: "thread-visible", }, @@ -107,11 +130,11 @@ describe("handleSubagentsAgentsAction", () => { const result = handleSubagentsAgentsAction({ params: { ctx: { - Provider: "discord", - Surface: "discord", + Provider: THREAD_CHANNEL, + Surface: THREAD_CHANNEL, }, command: { - channel: "discord", + channel: THREAD_CHANNEL, }, }, requesterKey: "agent:main:main", @@ -149,27 +172,27 @@ describe("handleSubagentsAgentsAction", () => { expect(result.reply?.text).not.toContain("hidden recent worker"); }); - it("shows matrix runs as unbound instead of claiming only discord/telegram bindings", () => { + it("shows room-channel runs as unbound when the plugin supports conversation bindings", () => { listBySessionMock.mockReturnValue([]); const result = handleSubagentsAgentsAction({ params: { ctx: { - Provider: "matrix", - Surface: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, }, command: { - channel: "matrix", + channel: ROOM_CHANNEL, }, }, requesterKey: "agent:main:main", runs: [ { - runId: "run-matrix-worker", - childSessionKey: "agent:main:subagent:matrix-worker", + runId: "run-room-worker", + childSessionKey: "agent:main:subagent:room-worker", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", - task: "matrix worker", + task: "room worker", cleanup: "keep", createdAt: Date.now() - 20_000, startedAt: Date.now() - 20_000, @@ -178,21 +201,21 @@ describe("handleSubagentsAgentsAction", () => { restTokens: [], } as never); - expect(result.reply?.text).toContain("matrix worker (unbound)"); - expect(result.reply?.text).not.toContain("bindings available on discord/telegram"); + expect(result.reply?.text).toContain("room worker (unbound)"); + expect(result.reply?.text).not.toContain("bindings unavailable"); }); - it("formats matrix bindings as threads", () => { - const childSessionKey = "agent:main:subagent:matrix-bound"; + it("formats bindings generically", () => { + const childSessionKey = "agent:main:subagent:room-bound"; listBySessionMock.mockImplementation((sessionKey: string) => sessionKey === childSessionKey ? [ { - bindingId: "binding-matrix", + bindingId: "binding-room", targetSessionKey: childSessionKey, targetKind: "subagent", conversation: { - channel: "matrix", + channel: ROOM_CHANNEL, accountId: "default", conversationId: "room-thread-1", }, @@ -206,21 +229,21 @@ describe("handleSubagentsAgentsAction", () => { const result = handleSubagentsAgentsAction({ params: { ctx: { - Provider: "matrix", - Surface: "matrix", + Provider: ROOM_CHANNEL, + Surface: ROOM_CHANNEL, }, command: { - channel: "matrix", + channel: ROOM_CHANNEL, }, }, requesterKey: "agent:main:main", runs: [ { - runId: "run-matrix-bound", + runId: "run-room-bound", childSessionKey, requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", - task: "matrix bound worker", + task: "room bound worker", cleanup: "keep", createdAt: Date.now() - 20_000, startedAt: Date.now() - 20_000, @@ -229,7 +252,39 @@ describe("handleSubagentsAgentsAction", () => { restTokens: [], } as never); - expect(result.reply?.text).toContain("matrix bound worker (thread:room-thread-1)"); - expect(result.reply?.text).not.toContain("binding:room-thread-1"); + expect(result.reply?.text).toContain("room bound worker (binding:room-thread-1)"); + }); + + it("shows bindings unavailable for channels without conversation binding support", () => { + getChannelPluginMock.mockReturnValueOnce(null); + listBySessionMock.mockReturnValue([]); + + const result = handleSubagentsAgentsAction({ + params: { + ctx: { + Provider: "irc", + Surface: "irc", + }, + command: { + channel: "irc", + }, + }, + requesterKey: "agent:main:main", + runs: [ + { + runId: "run-irc-worker", + childSessionKey: "agent:main:subagent:irc-worker", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + task: "irc worker", + cleanup: "keep", + createdAt: Date.now() - 20_000, + startedAt: Date.now() - 20_000, + }, + ], + restTokens: [], + } as never); + + expect(result.reply?.text).toContain("irc worker (bindings unavailable)"); }); }); diff --git a/src/auto-reply/reply/commands-subagents/action-agents.ts b/src/auto-reply/reply/commands-subagents/action-agents.ts index 1992d3b9a65..ebb1ba0a8b2 100644 --- a/src/auto-reply/reply/commands-subagents/action-agents.ts +++ b/src/auto-reply/reply/commands-subagents/action-agents.ts @@ -1,4 +1,5 @@ import { countPendingDescendantRuns } from "../../../agents/subagent-registry.js"; +import { getChannelPlugin, normalizeChannelId } from "../../../channels/plugins/index.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; import { formatRunLabel, sortSubagentRuns } from "../subagents-utils.js"; @@ -10,23 +11,25 @@ import { stopWithText, } from "./shared.js"; -function formatConversationBindingText(params: { - channel: string; - conversationId: string; -}): string { - if (params.channel === "discord" || params.channel === "matrix") { - return `thread:${params.conversationId}`; - } - if (params.channel === "telegram") { - return `conversation:${params.conversationId}`; - } +function formatConversationBindingText(params: { conversationId: string }): string { return `binding:${params.conversationId}`; } +function supportsConversationBindings(channel: string): boolean { + const channelId = normalizeChannelId(channel); + if (!channelId) { + return false; + } + return ( + getChannelPlugin(channelId)?.conversationBindings?.supportsCurrentConversationBinding === true + ); +} + export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): CommandHandlerResult { const { params, requesterKey, runs } = ctx; const channel = resolveCommandSurfaceChannel(params); const accountId = resolveChannelAccountId(params); + const currentConversationBindingsSupported = supportsConversationBindings(channel); const bindingService = getSessionBindingService(); const bindingsBySession = new Map>(); @@ -93,12 +96,11 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma const binding = resolveSessionBindings(entry.childSessionKey)[0]; const bindingText = binding ? formatConversationBindingText({ - channel, conversationId: binding.conversation.conversationId, }) - : channel === "discord" || channel === "telegram" || channel === "matrix" + : currentConversationBindingsSupported ? "unbound" - : "bindings available on discord/telegram"; + : "bindings unavailable"; const resolvedIndex = indexByChildSessionKey.get(entry.childSessionKey); const prefix = resolvedIndex ? `${resolvedIndex}.` : "-"; lines.push(`${prefix} ${formatRunLabel(entry)} (${bindingText})`); @@ -117,7 +119,6 @@ export function handleSubagentsAgentsAction(ctx: SubagentsCommandContext): Comma : binding.targetSessionKey; lines.push( `- ${label} (${formatConversationBindingText({ - channel, conversationId: binding.conversation.conversationId, })}, session:${binding.targetSessionKey})`, ); diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index f55cbe95a39..022b7534713 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -3,6 +3,7 @@ import { resolveAcpThreadSessionDetailLines, } from "../../../acp/runtime/session-identifiers.js"; import { readAcpSessionEntry } from "../../../acp/runtime/session-meta.js"; +import { normalizeChatType } from "../../../channels/chat-type.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -12,134 +13,68 @@ import { formatThreadBindingSpawnDisabledError, resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingPlacementForCurrentContext, resolveThreadBindingSpawnPolicy, } from "../../../channels/thread-bindings-policy.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; -import { - resolveMatrixConversationId, - resolveMatrixParentConversationId, -} from "../matrix-context.js"; -import { - type SubagentsCommandContext, - isDiscordSurface, - isMatrixSurface, - isTelegramSurface, - resolveChannelAccountId, - resolveCommandSurfaceChannel, - resolveDiscordChannelIdForFocus, - resolveFocusTargetSession, - resolveTelegramConversationId, - stopWithText, -} from "./shared.js"; +import { resolveConversationBindingContextFromAcpCommand } from "../conversation-binding-input.js"; +import { type SubagentsCommandContext, resolveFocusTargetSession, stopWithText } from "./shared.js"; type FocusBindingContext = { - channel: "discord" | "matrix" | "telegram"; + channel: string; accountId: string; conversationId: string; parentConversationId?: string; placement: "current" | "child"; - labelNoun: "thread" | "conversation"; }; function resolveFocusBindingContext( params: SubagentsCommandContext["params"], ): FocusBindingContext | null { - if (isDiscordSurface(params)) { - const currentThreadId = - params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; - const parentChannelId = currentThreadId ? undefined : resolveDiscordChannelIdForFocus(params); - const conversationId = currentThreadId || parentChannelId; - if (!conversationId) { - return null; - } - return { - channel: "discord", - accountId: resolveChannelAccountId(params), - conversationId, - placement: currentThreadId ? "current" : "child", - labelNoun: "thread", - }; + const bindingContext = resolveConversationBindingContextFromAcpCommand(params); + if (!bindingContext) { + return null; } - if (isTelegramSurface(params)) { - const conversationId = resolveTelegramConversationId(params); - if (!conversationId) { - return null; - } - return { - channel: "telegram", - accountId: resolveChannelAccountId(params), - conversationId, - placement: "current", - labelNoun: "conversation", - }; - } - if (isMatrixSurface(params)) { - const conversationId = resolveMatrixConversationId({ - ctx: { - MessageThreadId: params.ctx.MessageThreadId, - OriginatingTo: params.ctx.OriginatingTo, - To: params.ctx.To, - }, - command: { - to: params.command.to, - }, - }); - if (!conversationId) { - return null; - } - const parentConversationId = resolveMatrixParentConversationId({ - ctx: { - MessageThreadId: params.ctx.MessageThreadId, - OriginatingTo: params.ctx.OriginatingTo, - To: params.ctx.To, - }, - command: { - to: params.command.to, - }, - }); - const currentThreadId = - params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; - return { - channel: "matrix", - accountId: resolveChannelAccountId(params), - conversationId, - ...(parentConversationId ? { parentConversationId } : {}), - placement: currentThreadId ? "current" : "child", - labelNoun: "thread", - }; - } - return null; + const chatType = normalizeChatType(params.ctx.ChatType); + return { + channel: bindingContext.channel, + accountId: bindingContext.accountId, + conversationId: bindingContext.conversationId, + ...(bindingContext.parentConversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), + placement: + chatType === "direct" + ? "current" + : resolveThreadBindingPlacementForCurrentContext({ + channel: bindingContext.channel, + threadId: bindingContext.threadId || undefined, + }), + }; } export async function handleSubagentsFocusAction( ctx: SubagentsCommandContext, ): Promise { const { params, runs, restTokens } = ctx; - const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") { - return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram."); - } - const token = restTokens.join(" ").trim(); if (!token) { return stopWithText("Usage: /focus "); } - const accountId = resolveChannelAccountId(params); + const bindingContext = resolveFocusBindingContext(params); + if (!bindingContext) { + return stopWithText("⚠️ /focus must be run inside a bindable conversation."); + } + const bindingService = getSessionBindingService(); const capabilities = bindingService.getCapabilities({ - channel, - accountId, + channel: bindingContext.channel, + accountId: bindingContext.accountId, }); if (!capabilities.adapterAvailable || !capabilities.bindSupported) { - const label = - channel === "discord" - ? "Discord thread" - : channel === "matrix" - ? "Matrix thread" - : "Telegram conversation"; - return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`); + return stopWithText("⚠️ Conversation bindings are unavailable for this account."); } const focusTarget = await resolveFocusTargetSession({ runs, token }); @@ -147,23 +82,10 @@ export async function handleSubagentsFocusAction( return stopWithText(`⚠️ Unable to resolve focus target: ${token}`); } - const bindingContext = resolveFocusBindingContext(params); - if (!bindingContext) { - if (channel === "telegram") { - return stopWithText( - "⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.", - ); - } - if (channel === "matrix") { - return stopWithText("⚠️ Could not resolve a Matrix room for /focus."); - } - return stopWithText("⚠️ Could not resolve a Discord channel for /focus."); - } - - if (channel === "matrix") { + if (bindingContext.placement === "child") { const spawnPolicy = resolveThreadBindingSpawnPolicy({ cfg: params.cfg, - channel, + channel: bindingContext.channel, accountId: bindingContext.accountId, kind: "subagent", }); @@ -202,10 +124,11 @@ export async function handleSubagentsFocusAction( ? existingBinding.metadata.boundBy.trim() : ""; if (existingBinding && boundBy && boundBy !== "system" && senderId && senderId !== boundBy) { - return stopWithText(`⚠️ Only ${boundBy} can refocus this ${bindingContext.labelNoun}.`); + return stopWithText(`⚠️ Only ${boundBy} can refocus this conversation.`); } const label = focusTarget.label || token; + const accountId = bindingContext.accountId; const acpMeta = focusTarget.targetKind === "acp" ? readAcpSessionEntry({ @@ -214,7 +137,7 @@ export async function handleSubagentsFocusAction( })?.acp : undefined; if (!capabilities.placements.includes(bindingContext.placement)) { - return stopWithText(`⚠️ ${channel} bindings are unavailable for this account.`); + return stopWithText("⚠️ Conversation bindings are unavailable for this account."); } let binding; @@ -265,14 +188,12 @@ export async function handleSubagentsFocusAction( }, }); } catch { - return stopWithText( - `⚠️ Failed to bind this ${bindingContext.labelNoun} to the target session.`, - ); + return stopWithText("⚠️ Failed to bind this conversation to the target session."); } const actionText = bindingContext.placement === "child" - ? `created thread ${binding.conversation.conversationId} and bound it to ${binding.targetSessionKey}` - : `bound this ${bindingContext.labelNoun} to ${binding.targetSessionKey}`; + ? `created child conversation ${binding.conversation.conversationId} and bound it to ${binding.targetSessionKey}` + : `bound this conversation to ${binding.targetSessionKey}`; return stopWithText(`✅ ${actionText} (${focusTarget.targetKind}).`); } diff --git a/src/auto-reply/reply/commands-subagents/action-unfocus.ts b/src/auto-reply/reply/commands-subagents/action-unfocus.ts index 0331772316e..cee0ce9321c 100644 --- a/src/auto-reply/reply/commands-subagents/action-unfocus.ts +++ b/src/auto-reply/reply/commands-subagents/action-unfocus.ts @@ -1,120 +1,41 @@ import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; -import { - resolveMatrixConversationId, - resolveMatrixParentConversationId, -} from "../matrix-context.js"; -import { - type SubagentsCommandContext, - isDiscordSurface, - isMatrixSurface, - isTelegramSurface, - resolveChannelAccountId, - resolveCommandSurfaceChannel, - resolveTelegramConversationId, - stopWithText, -} from "./shared.js"; +import { resolveConversationBindingContextFromAcpCommand } from "../conversation-binding-input.js"; +import { type SubagentsCommandContext, stopWithText } from "./shared.js"; export async function handleSubagentsUnfocusAction( ctx: SubagentsCommandContext, ): Promise { const { params } = ctx; - const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") { - return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram."); - } - - const accountId = resolveChannelAccountId(params); const bindingService = getSessionBindingService(); - - const conversationId = (() => { - if (isDiscordSurface(params)) { - const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId) : ""; - return threadId.trim() || undefined; - } - if (isTelegramSurface(params)) { - return resolveTelegramConversationId(params); - } - if (isMatrixSurface(params)) { - return resolveMatrixConversationId({ - ctx: { - MessageThreadId: params.ctx.MessageThreadId, - OriginatingTo: params.ctx.OriginatingTo, - To: params.ctx.To, - }, - command: { - to: params.command.to, - }, - }); - } - return undefined; - })(); - const parentConversationId = (() => { - if (!isMatrixSurface(params)) { - return undefined; - } - return resolveMatrixParentConversationId({ - ctx: { - MessageThreadId: params.ctx.MessageThreadId, - OriginatingTo: params.ctx.OriginatingTo, - To: params.ctx.To, - }, - command: { - to: params.command.to, - }, - }); - })(); - - if (!conversationId) { - if (channel === "discord") { - return stopWithText("⚠️ /unfocus must be run inside a Discord thread."); - } - if (channel === "matrix") { - return stopWithText("⚠️ /unfocus must be run inside a Matrix thread."); - } - return stopWithText( - "⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.", - ); + const bindingContext = resolveConversationBindingContextFromAcpCommand(params); + if (!bindingContext) { + return stopWithText("⚠️ /unfocus must be run inside a focused conversation."); } const binding = bindingService.resolveByConversation({ - channel, - accountId, - conversationId, - ...(parentConversationId && parentConversationId !== conversationId - ? { parentConversationId } + channel: bindingContext.channel, + accountId: bindingContext.accountId, + conversationId: bindingContext.conversationId, + ...(bindingContext.parentConversationId && + bindingContext.parentConversationId !== bindingContext.conversationId + ? { parentConversationId: bindingContext.parentConversationId } : {}), }); if (!binding) { - return stopWithText( - channel === "discord" - ? "ℹ️ This thread is not currently focused." - : channel === "matrix" - ? "ℹ️ This thread is not currently focused." - : "ℹ️ This conversation is not currently focused.", - ); + return stopWithText("ℹ️ This conversation is not currently focused."); } const senderId = params.command.senderId?.trim() || ""; const boundBy = typeof binding.metadata?.boundBy === "string" ? binding.metadata.boundBy.trim() : ""; if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) { - return stopWithText( - channel === "discord" - ? `⚠️ Only ${boundBy} can unfocus this thread.` - : channel === "matrix" - ? `⚠️ Only ${boundBy} can unfocus this thread.` - : `⚠️ Only ${boundBy} can unfocus this conversation.`, - ); + return stopWithText(`⚠️ Only ${boundBy} can unfocus this conversation.`); } await bindingService.unbind({ bindingId: binding.bindingId, reason: "manual", }); - return stopWithText( - channel === "discord" || channel === "matrix" - ? "✅ Thread unfocused." - : "✅ Conversation unfocused.", - ); + return stopWithText("✅ Conversation unfocused."); } diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 649431d3797..baebdc12f2f 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -12,7 +12,6 @@ import { sanitizeTextContent, stripToolMessages, } from "../../../agents/tools/sessions-helpers.js"; -import { parseExplicitTargetForChannel } from "../../../channels/plugins/target-parsing.js"; import type { SessionEntry, loadSessionStore as loadSessionStoreFn, @@ -29,14 +28,7 @@ import { formatTokenUsageDisplay, truncateLine, } from "../../../shared/subagents-format.js"; -import { - isDiscordSurface, - isMatrixSurface, - isTelegramSurface, - resolveCommandSurfaceChannel, - resolveDiscordAccountId, - resolveChannelAccountId, -} from "../channel-context.js"; +import { resolveCommandSurfaceChannel, resolveChannelAccountId } from "../channel-context.js"; import type { CommandHandler, CommandHandlerResult } from "../commands-types.js"; import { formatRunLabel, @@ -44,18 +36,9 @@ import { resolveSubagentTargetFromRuns, type SubagentTargetResolution, } from "../subagents-utils.js"; -import { resolveTelegramConversationId } from "../telegram-context.js"; export { extractAssistantText, stripToolMessages }; -export { - isDiscordSurface, - isMatrixSurface, - isTelegramSurface, - resolveCommandSurfaceChannel, - resolveDiscordAccountId, - resolveChannelAccountId, - resolveTelegramConversationId, -}; +export { resolveCommandSurfaceChannel, resolveChannelAccountId }; export const COMMAND = "/subagents"; export const COMMAND_KILL = "/kill"; @@ -308,23 +291,6 @@ export type FocusTargetResolution = { label?: string; }; -export function resolveDiscordChannelIdForFocus( - params: SubagentsCommandParams, -): string | undefined { - const toCandidates = [ - typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo.trim() : "", - typeof params.command.to === "string" ? params.command.to.trim() : "", - typeof params.ctx.To === "string" ? params.ctx.To.trim() : "", - ].filter(Boolean); - for (const candidate of toCandidates) { - const target = parseExplicitTargetForChannel("discord", candidate); - if (target?.chatType === "channel" && target.to) { - return target.to; - } - } - return undefined; -} - export async function resolveFocusTargetSession(params: { runs: SubagentRunRecord[]; token: string; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index f405c8d5cee..c6e9914b337 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -162,6 +162,10 @@ const whatsappCommandTestPlugin: ChannelPlugin = { nativeCommands: true, }, }), + commands: { + enforceOwnerForCommands: true, + preferSenderE164ForCommands: true, + }, allowlist: buildDmGroupAccountAllowlistAdapter({ channelId: "whatsapp", resolveAccount: ({ cfg }) => cfg.channels?.whatsapp ?? {}, @@ -281,7 +285,6 @@ const { abortEmbeddedPiRun, compactEmbeddedPiSession } = const { __testing: subagentControlTesting } = await import("../../agents/subagent-control.js"); const { resetBashChatCommandForTests } = await import("./bash-command.js"); const { handleCompactCommand } = await import("./commands-compact.js"); -const { buildCommandsPaginationKeyboard } = await import("./commands-info.js"); const { extractMessageText } = await import("./commands-subagents.js"); const { buildCommandTestParams } = await import("./commands.test-harness.js"); const { parseConfigCommand } = await import("./config-commands.js"); @@ -1506,17 +1509,6 @@ describe("abort trigger command", () => { }); }); -describe("buildCommandsPaginationKeyboard", () => { - it("adds agent id to callback data when provided", () => { - const keyboard = buildCommandsPaginationKeyboard(2, 3, "agent-main"); - expect(keyboard[0]).toEqual([ - { text: "◀ Prev", callback_data: "commands_page_1:agent-main" }, - { text: "2/3", callback_data: "commands_page_noop:agent-main" }, - { text: "Next ▶", callback_data: "commands_page_3:agent-main" }, - ]); - }); -}); - describe("parseConfigCommand", () => { it("parses config/debug command actions and JSON payloads", () => { const cases: Array<{ diff --git a/src/auto-reply/reply/conversation-label-generator.test.ts b/src/auto-reply/reply/conversation-label-generator.test.ts new file mode 100644 index 00000000000..fe77e179dab --- /dev/null +++ b/src/auto-reply/reply/conversation-label-generator.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const completeSimple = vi.hoisted(() => vi.fn()); +const getApiKeyForModel = vi.hoisted(() => vi.fn()); +const requireApiKey = vi.hoisted(() => vi.fn()); +const resolveDefaultModelForAgent = vi.hoisted(() => vi.fn()); +const resolveModelAsync = vi.hoisted(() => vi.fn()); +const prepareModelForSimpleCompletion = vi.hoisted(() => vi.fn()); + +vi.mock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + completeSimple, + }; +}); + +vi.mock("../../agents/model-auth.js", () => ({ + getApiKeyForModel, + requireApiKey, +})); + +vi.mock("../../agents/model-selection.js", () => ({ + resolveDefaultModelForAgent, +})); + +vi.mock("../../agents/pi-embedded-runner/model.js", () => ({ + resolveModelAsync, +})); + +vi.mock("../../agents/simple-completion-transport.js", () => ({ + prepareModelForSimpleCompletion, +})); + +import { generateConversationLabel } from "./conversation-label-generator.js"; + +describe("generateConversationLabel", () => { + beforeEach(() => { + completeSimple.mockReset(); + getApiKeyForModel.mockReset(); + requireApiKey.mockReset(); + resolveDefaultModelForAgent.mockReset(); + resolveModelAsync.mockReset(); + prepareModelForSimpleCompletion.mockReset(); + + resolveDefaultModelForAgent.mockReturnValue({ provider: "openai", model: "gpt-test" }); + resolveModelAsync.mockResolvedValue({ + model: { provider: "openai" }, + authStorage: {}, + modelRegistry: {}, + }); + prepareModelForSimpleCompletion.mockImplementation(({ model }) => model); + getApiKeyForModel.mockResolvedValue({ apiKey: "resolved-key", mode: "api-key" }); + requireApiKey.mockReturnValue("resolved-key"); + completeSimple.mockResolvedValue({ + content: [{ type: "text", text: "Topic label" }], + }); + }); + + it("uses routed agentDir for model and auth resolution", async () => { + await generateConversationLabel({ + userMessage: "Need help with invoices", + prompt: "prompt", + cfg: {}, + agentId: "billing", + agentDir: "/tmp/agents/billing/agent", + }); + + expect(resolveDefaultModelForAgent).toHaveBeenCalledWith({ + cfg: {}, + agentId: "billing", + }); + expect(resolveModelAsync).toHaveBeenCalledWith( + "openai", + "gpt-test", + "/tmp/agents/billing/agent", + {}, + ); + expect(getApiKeyForModel).toHaveBeenCalledWith({ + model: { provider: "openai" }, + cfg: {}, + agentDir: "/tmp/agents/billing/agent", + }); + expect(prepareModelForSimpleCompletion).toHaveBeenCalledWith({ + model: { provider: "openai" }, + cfg: {}, + }); + }); +}); diff --git a/src/auto-reply/reply/auto-topic-label.ts b/src/auto-reply/reply/conversation-label-generator.ts similarity index 61% rename from src/auto-reply/reply/auto-topic-label.ts rename to src/auto-reply/reply/conversation-label-generator.ts index 911f7842329..4ac37ce00d4 100644 --- a/src/auto-reply/reply/auto-topic-label.ts +++ b/src/auto-reply/reply/conversation-label-generator.ts @@ -1,10 +1,3 @@ -/** - * Auto-rename Telegram DM forum topics on first message using LLM. - * - * This module provides LLM-based label generation. - * Config resolution is in auto-topic-label-config.ts (lightweight, testable). - * The actual topic rename call is channel-specific and handled by the caller. - */ import { completeSimple, type TextContent } from "@mariozechner/pi-ai"; import { getApiKeyForModel, requireApiKey } from "../../agents/model-auth.js"; import { resolveDefaultModelForAgent } from "../../agents/model-selection.js"; @@ -13,45 +6,38 @@ import { prepareModelForSimpleCompletion } from "../../agents/simple-completion- import type { OpenClawConfig } from "../../config/config.js"; import { logVerbose } from "../../globals.js"; -export { resolveAutoTopicLabelConfig } from "./auto-topic-label-config.js"; -export type { AutoTopicLabelConfig } from "../../config/types.telegram.js"; - -const MAX_LABEL_LENGTH = 128; +const DEFAULT_MAX_LABEL_LENGTH = 128; const TIMEOUT_MS = 15_000; -export type AutoTopicLabelParams = { - /** The user's first message text. */ +export type ConversationLabelParams = { userMessage: string; - /** System prompt for the LLM. */ prompt: string; - /** The full config object. */ cfg: OpenClawConfig; - /** Agent ID for model resolution. */ agentId?: string; - /** Routed agent directory for model/auth resolution. */ agentDir?: string; + maxLength?: number; }; function isTextContentBlock(block: { type: string }): block is TextContent { return block.type === "text"; } -/** - * Generate a topic label using LLM. - * Returns the generated label or null on failure. - */ -export async function generateTopicLabel(params: { - userMessage: string; - prompt: string; - cfg: OpenClawConfig; - agentId?: string; - agentDir?: string; -}): Promise { +export async function generateConversationLabel( + params: ConversationLabelParams, +): Promise { const { userMessage, prompt, cfg, agentId, agentDir } = params; + const maxLength = + typeof params.maxLength === "number" && + Number.isFinite(params.maxLength) && + params.maxLength > 0 + ? Math.floor(params.maxLength) + : DEFAULT_MAX_LABEL_LENGTH; const modelRef = resolveDefaultModelForAgent({ cfg, agentId }); const resolved = await resolveModelAsync(modelRef.provider, modelRef.model, agentDir, cfg); if (!resolved.model) { - logVerbose(`auto-topic-label: failed to resolve model ${modelRef.provider}/${modelRef.model}`); + logVerbose( + `conversation-label-generator: failed to resolve model ${modelRef.provider}/${modelRef.model}`, + ); return null; } const completionModel = prepareModelForSimpleCompletion({ model: resolved.model, cfg }); @@ -85,7 +71,7 @@ export async function generateTopicLabel(params: { const text = result.content .filter(isTextContentBlock) - .map((b) => b.text) + .map((block) => block.text) .join("") .trim(); @@ -93,8 +79,7 @@ export async function generateTopicLabel(params: { return null; } - // Enforce max length for Telegram topic names. - return text.slice(0, MAX_LABEL_LENGTH); + return text.slice(0, maxLength); } finally { clearTimeout(timeout); } diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index ccaa58aefb3..5beb9494ca1 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -6,13 +6,13 @@ import { resolveConfiguredModelRef, resolveModelRefFromString, } from "../../agents/model-selection.js"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import type { ReplyPayload } from "../types.js"; import { resolveModelsCommandReply } from "./commands-models.js"; -import { buildBrowseProvidersButton } from "./commands-models.telegram.js"; import { formatAuthLabel, type ModelAuthDetailMode, @@ -241,13 +241,12 @@ export async function maybeHandleModelDirectiveInfo(params: { sessionEntry: params.sessionEntry, }); const current = modelRefs.selected.label; - const isTelegram = params.surface === "telegram"; const activeRuntimeLine = modelRefs.activeDiffers ? `Active: ${modelRefs.active.label} (runtime)` : null; - - if (isTelegram) { - const buttons = buildBrowseProvidersButton(); + const commandPlugin = params.surface ? getChannelPlugin(params.surface) : null; + const channelData = commandPlugin?.commands?.buildModelBrowseChannelData?.(); + if (channelData) { return { text: [ `Current: ${current}${modelRefs.activeDiffers ? " (selected)" : ""}`, @@ -259,7 +258,7 @@ export async function maybeHandleModelDirectiveInfo(params: { ] .filter(Boolean) .join("\n"), - channelData: { telegram: { buttons } }, + channelData, }; } diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index 67a12370176..de863975699 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -1,4 +1,5 @@ import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; import { logVerbose } from "../../globals.js"; @@ -39,7 +40,16 @@ function shouldTreatDeliveredTextAsVisible(params: { if (params.kind === "final") { return true; } - return normalizeDeliveryChannel(params.channel) === "telegram"; + const channelId = normalizeDeliveryChannel(params.channel); + if (!channelId) { + return false; + } + return ( + getChannelPlugin(channelId)?.outbound?.shouldTreatRoutedTextAsVisible?.({ + kind: params.kind, + text: params.text, + }) === true + ); } type AcpDispatchDeliveryState = { diff --git a/src/auto-reply/reply/group-id.ts b/src/auto-reply/reply/group-id.ts index 18ec92a9f5f..1b2c9dd9410 100644 --- a/src/auto-reply/reply/group-id.ts +++ b/src/auto-reply/reply/group-id.ts @@ -1,3 +1,5 @@ +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; + export function extractExplicitGroupId(raw: string | undefined | null): string | undefined { const trimmed = (raw ?? "").trim(); if (!trimmed) { @@ -8,16 +10,16 @@ export function extractExplicitGroupId(raw: string | undefined | null): string | const joined = parts.slice(2).join(":"); return joined.replace(/:topic:.*$/, "") || undefined; } - if ( - parts.length >= 2 && - parts[0]?.toLowerCase() === "whatsapp" && - trimmed.toLowerCase().includes("@g.us") - ) { - return parts.slice(1).join(":") || undefined; - } if (parts.length >= 2 && (parts[0] === "group" || parts[0] === "channel")) { const joined = parts.slice(1).join(":"); return joined.replace(/:topic:.*$/, "") || undefined; } + const channelId = normalizeChannelId(parts[0] ?? "") ?? parts[0]?.trim().toLowerCase(); + const parsed = channelId + ? getChannelPlugin(channelId)?.messaging?.parseExplicitTarget?.({ raw: trimmed }) + : null; + if (parsed && parsed.chatType && parsed.chatType !== "direct") { + return parsed.to.replace(/:topic:.*$/, "") || undefined; + } return undefined; } diff --git a/src/auto-reply/reply/groups.runtime.ts b/src/auto-reply/reply/groups.runtime.ts index 99da87e2d03..a1155fd3a22 100644 --- a/src/auto-reply/reply/groups.runtime.ts +++ b/src/auto-reply/reply/groups.runtime.ts @@ -1,22 +1,3 @@ import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; -import type { ChannelGroupContext } from "../../channels/plugins/types.js"; - -function resolveRequireMentionForChannel( - channelId: "discord" | "slack", - params: ChannelGroupContext, -): boolean | undefined { - const plugin = getChannelPlugin(channelId); - return plugin?.groups?.resolveRequireMention?.(params); -} export { getChannelPlugin, normalizeChannelId }; - -export function resolveDiscordGroupRequireMention( - params: ChannelGroupContext, -): boolean | undefined { - return resolveRequireMentionForChannel("discord", params); -} - -export function resolveSlackGroupRequireMention(params: ChannelGroupContext): boolean | undefined { - return resolveRequireMentionForChannel("slack", params); -} diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts index 0e88c777168..ff0b4fa57e7 100644 --- a/src/auto-reply/reply/groups.test.ts +++ b/src/auto-reply/reply/groups.test.ts @@ -25,7 +25,7 @@ describe("group runtime loading", () => { Provider: "whatsapp", }, }), - ).toContain('You are in the WhatsApp group chat "Ops".'); + ).toContain('You are in the Whatsapp group chat "Ops".'); expect( groups.buildGroupIntro({ cfg: {} as OpenClawConfig, @@ -33,7 +33,7 @@ describe("group runtime loading", () => { defaultActivation: "mention", silentToken: "NO_REPLY", }), - ).toContain("WhatsApp IDs:"); + ).toContain("Activation: trigger-only"); expect(groupsRuntimeLoads).not.toHaveBeenCalled(); vi.doUnmock("./groups.runtime.js"); }); diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index b7e8ca9a18a..f8f8e603d2e 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,4 +1,3 @@ -import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveChannelGroupRequireMention } from "../../config/group-policy.js"; import type { GroupKeyResolution, SessionEntry } from "../../config/sessions.js"; @@ -7,21 +6,6 @@ import { normalizeGroupActivation } from "../group-activation.js"; import type { TemplateContext } from "../templating.js"; import { extractExplicitGroupId } from "./group-id.js"; -const WHATSAPP_GROUP_INTRO_HINT = - "WhatsApp IDs: SenderId is the participant JID (group participant id)."; - -const CHANNEL_LABELS: Partial> = { - bluebubbles: "BlueBubbles", - discord: "Discord", - imessage: "iMessage", - line: "LINE", - signal: "Signal", - slack: "Slack", - telegram: "Telegram", - webchat: "WebChat", - whatsapp: "WhatsApp", -}; - let groupsRuntimePromise: Promise | null = null; function loadGroupsRuntime() { @@ -34,15 +18,15 @@ function resolveGroupId(raw: string | undefined | null): string | undefined { return extractExplicitGroupId(trimmed) ?? (trimmed || undefined); } -function resolveLooseChannelId(raw?: string | null): ChannelId | null { +function resolveLooseChannelId(raw?: string | null): string | null { const normalized = raw?.trim().toLowerCase(); if (!normalized) { return null; } - return normalized as ChannelId; + return normalized; } -async function resolveRuntimeChannelId(raw?: string | null): Promise { +async function resolveRuntimeChannelId(raw?: string | null): Promise { const normalized = resolveLooseChannelId(raw); if (!normalized) { return null; @@ -62,25 +46,6 @@ async function resolveRuntimeChannelId(raw?: string | null): Promise { - const runtime = await loadGroupsRuntime(); - switch (params.channel) { - case "discord": - return runtime.resolveDiscordGroupRequireMention(params); - case "slack": - return runtime.resolveSlackGroupRequireMention(params); - default: - return undefined; - } -} - export async function resolveGroupRequireMention(params: { cfg: OpenClawConfig; ctx: TemplateContext; @@ -111,17 +76,6 @@ export async function resolveGroupRequireMention(params: { if (typeof requireMention === "boolean") { return requireMention; } - const builtInRequireMention = await resolveBuiltInRequireMentionFromConfig({ - cfg, - channel, - groupChannel, - groupId, - groupSpace, - accountId: ctx.AccountId, - }); - if (typeof builtInRequireMention === "boolean") { - return builtInRequireMention; - } return resolveChannelGroupRequireMention({ cfg, channel, @@ -142,10 +96,6 @@ function resolveProviderLabel(rawProvider: string | undefined): string { if (isInternalMessageChannel(providerKey)) { return "WebChat"; } - const providerId = resolveLooseChannelId(rawProvider?.trim()); - if (providerId) { - return CHANNEL_LABELS[providerId] ?? providerId; - } return `${providerKey.at(0)?.toUpperCase() ?? ""}${providerKey.slice(1)}`; } @@ -178,12 +128,10 @@ export function buildGroupIntro(params: { }): string { const activation = normalizeGroupActivation(params.sessionEntry?.groupActivation) ?? params.defaultActivation; - const providerId = resolveLooseChannelId(params.sessionCtx.Provider?.trim()); const activationLine = activation === "always" ? "Activation: always-on (you receive every group message)." : "Activation: trigger-only (you are invoked only when explicitly mentioned; recent context may be included)."; - const providerIdsLine = providerId === "whatsapp" ? WHATSAPP_GROUP_INTRO_HINT : undefined; const silenceLine = activation === "always" ? `If no response is needed, reply with exactly "${params.silentToken}" (and nothing else) so OpenClaw stays silent. Do not add any other words, punctuation, tags, markdown/code blocks, or explanations.` @@ -196,7 +144,7 @@ export function buildGroupIntro(params: { "Be a good group participant: mostly lurk and follow the conversation; reply only when directly addressed or you can add clear value. Emoji reactions are welcome when available."; const styleLine = "Write like a human. Avoid Markdown tables. Don't type literal \\n sequences; use real line breaks sparingly."; - return [activationLine, providerIdsLine, silenceLine, cautionLine, lurkLine, styleLine] + return [activationLine, silenceLine, cautionLine, lurkLine, styleLine] .filter(Boolean) .join(" ") .concat(" Address the specific sender noted in the message context."); diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 6979fff25b0..97b7d65bbc0 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -1,4 +1,5 @@ import { normalizeChatType } from "../../channels/chat-type.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { resolveSenderLabel } from "../../channels/sender-label.js"; import type { EnvelopeFormatOptions } from "../envelope.js"; import { formatEnvelopeTimestamp } from "../envelope.js"; @@ -35,28 +36,18 @@ function resolveInboundChannel(ctx: TemplateContext): string | undefined { function resolveInboundFormattingHints(ctx: TemplateContext): | { - text_markup: "slack_mrkdwn"; + text_markup: string; rules: string[]; } | undefined { const channelValue = resolveInboundChannel(ctx); - const surface = safeTrim(ctx.Surface); - const provider = safeTrim(ctx.Provider); - const isSlack = channelValue === "slack" || surface === "slack" || provider === "slack"; - if (!isSlack) { + if (!channelValue) { return undefined; } - - return { - text_markup: "slack_mrkdwn", - rules: [ - "Use Slack mrkdwn, not standard Markdown.", - "Bold uses *single asterisks*.", - "Links use .", - "Code blocks use triple backticks without a language identifier.", - "Do not use markdown headings or pipe tables.", - ], - }; + const normalizedChannel = normalizeChannelId(channelValue) ?? channelValue; + return getChannelPlugin(normalizedChannel)?.agentPrompt?.inboundFormattingHints?.({ + accountId: safeTrim(ctx.AccountId) ?? undefined, + }); } export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string { diff --git a/src/auto-reply/reply/matrix-context.ts b/src/auto-reply/reply/matrix-context.ts deleted file mode 100644 index 8689cc79d57..00000000000 --- a/src/auto-reply/reply/matrix-context.ts +++ /dev/null @@ -1,54 +0,0 @@ -type MatrixConversationParams = { - ctx: { - MessageThreadId?: string | number | null; - OriginatingTo?: string; - To?: string; - }; - command: { - to?: string; - }; -}; - -function normalizeMatrixTarget(value: unknown): string { - return typeof value === "string" ? value.trim() : ""; -} - -function resolveMatrixRoomIdFromTarget(raw: string): string | undefined { - let target = normalizeMatrixTarget(raw); - if (!target) { - return undefined; - } - if (target.toLowerCase().startsWith("matrix:")) { - target = target.slice("matrix:".length).trim(); - } - if (/^(room|channel):/i.test(target)) { - const roomId = target.replace(/^(room|channel):/i, "").trim(); - return roomId || undefined; - } - if (target.startsWith("!") || target.startsWith("#")) { - return target; - } - return undefined; -} - -export function resolveMatrixParentConversationId( - params: MatrixConversationParams, -): string | undefined { - const targets = [params.ctx.OriginatingTo, params.command.to, params.ctx.To]; - for (const candidate of targets) { - const roomId = resolveMatrixRoomIdFromTarget(candidate ?? ""); - if (roomId) { - return roomId; - } - } - return undefined; -} - -export function resolveMatrixConversationId(params: MatrixConversationParams): string | undefined { - const threadId = - params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; - if (threadId) { - return threadId; - } - return resolveMatrixParentConversationId(params); -} diff --git a/src/auto-reply/reply/mentions.ts b/src/auto-reply/reply/mentions.ts index d8038130f2b..c5a75432621 100644 --- a/src/auto-reply/reply/mentions.ts +++ b/src/auto-reply/reply/mentions.ts @@ -118,17 +118,6 @@ function resolveMentionPatterns(cfg: OpenClawConfig | undefined, agentId?: strin return derived.length > 0 ? derived : []; } -function resolveFallbackProviderMentionStripRegexes(providerId?: string | null): RegExp[] { - switch (providerId?.trim().toLowerCase()) { - case "discord": - return [/<@!?\d+>/gi]; - case "slack": - return [/<@[^>\s]+>/gi]; - default: - return []; - } -} - export function buildMentionRegexes(cfg: OpenClawConfig | undefined, agentId?: string): RegExp[] { const patterns = normalizeMentionPatterns(resolveMentionPatterns(cfg, agentId)); return compileMentionPatternsCached({ @@ -230,9 +219,7 @@ export function stripMentions( cache: mentionStripRegexCompileCache, warnRejected: false, }); - const fallbackProviderRegexes = - providerRegexes.length > 0 ? [] : resolveFallbackProviderMentionStripRegexes(providerId); - for (const re of [...configRegexes, ...providerRegexes, ...fallbackProviderRegexes]) { + for (const re of [...configRegexes, ...providerRegexes]) { result = result.replace(re, " "); } if (providerMentions?.stripMentions) { diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index f1e3776bef6..81afdc6c62f 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -14,13 +14,11 @@ import { resolveResponsePrefixTemplate, type ResponsePrefixContext, } from "./response-prefix-template.js"; -import { compileSlackInteractiveReplies } from "./slack-directives.js"; export type NormalizeReplySkipReason = "empty" | "silent" | "heartbeat"; export type NormalizeReplyOptions = { responsePrefix?: string; - enableSlackInteractiveReplies?: boolean; applyChannelTransforms?: boolean; /** Context for template variable interpolation in responsePrefix */ responsePrefixContext?: ResponsePrefixContext; @@ -118,9 +116,5 @@ export function normalizeReplyPayload( } enrichedPayload = { ...enrichedPayload, text }; - if (applyChannelTransforms && opts.enableSlackInteractiveReplies && text) { - enrichedPayload = compileSlackInteractiveReplies(enrichedPayload); - } - return enrichedPayload; } diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index 5df01c7ac22..e0cae6a9dd9 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -44,7 +44,6 @@ function getHumanDelay(config: HumanDelayConfig | undefined): number { export type ReplyDispatcherOptions = { deliver: ReplyDispatchDeliverer; responsePrefix?: string; - enableSlackInteractiveReplies?: boolean; /** Static context for response prefix template interpolation. */ responsePrefixContext?: ResponsePrefixContext; /** Dynamic context provider for response prefix template interpolation. @@ -87,11 +86,7 @@ export type ReplyDispatcher = { type NormalizeReplyPayloadInternalOptions = Pick< ReplyDispatcherOptions, - | "responsePrefix" - | "enableSlackInteractiveReplies" - | "responsePrefixContext" - | "responsePrefixContextProvider" - | "onHeartbeatStrip" + "responsePrefix" | "responsePrefixContext" | "responsePrefixContextProvider" | "onHeartbeatStrip" > & { onSkip?: (reason: NormalizeReplySkipReason) => void; }; @@ -105,7 +100,6 @@ function normalizeReplyPayloadInternal( return normalizeReplyPayload(payload, { responsePrefix: opts.responsePrefix, - enableSlackInteractiveReplies: opts.enableSlackInteractiveReplies, responsePrefixContext: prefixContext, onHeartbeatStrip: opts.onHeartbeatStrip, onSkip: opts.onSkip, @@ -142,7 +136,6 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => { const normalized = normalizeReplyPayloadInternal(payload, { responsePrefix: options.responsePrefix, - enableSlackInteractiveReplies: options.enableSlackInteractiveReplies, responsePrefixContext: options.responsePrefixContext, responsePrefixContextProvider: options.responsePrefixContextProvider, onHeartbeatStrip: options.onHeartbeatStrip, diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index 41e9f99618e..d18b1e53dbd 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -38,38 +38,6 @@ describe("createReplyDispatcher", () => { expect(onHeartbeatStrip).toHaveBeenCalledTimes(2); }); - it("compiles Slack directives in dispatcher flows when enabled", async () => { - const deliver = vi.fn().mockResolvedValue(undefined); - const dispatcher = createReplyDispatcher({ - deliver, - enableSlackInteractiveReplies: true, - }); - - expect( - dispatcher.sendFinalReply({ - text: "Choose [[slack_buttons: Retry:retry]]", - }), - ).toBe(true); - await dispatcher.waitForIdle(); - - expect(deliver).toHaveBeenCalledTimes(1); - expect(deliver.mock.calls[0]?.[0]).toMatchObject({ - text: "Choose", - interactive: { - blocks: [ - { - type: "text", - text: "Choose", - }, - { - type: "buttons", - buttons: [{ label: "Retry", value: "retry" }], - }, - ], - }, - }); - }); - it("avoids double-prefixing and keeps media when heartbeat is the only text", async () => { const deliver = vi.fn().mockResolvedValue(undefined); const dispatcher = createReplyDispatcher({ diff --git a/src/auto-reply/reply/reply-payloads-dedupe.ts b/src/auto-reply/reply/reply-payloads-dedupe.ts index 8a757c6a8a4..0e6a0dae5ba 100644 --- a/src/auto-reply/reply/reply-payloads-dedupe.ts +++ b/src/auto-reply/reply/reply-payloads-dedupe.ts @@ -1,7 +1,6 @@ import { isMessagingToolDuplicate } from "../../agents/pi-embedded-helpers.js"; import type { MessagingToolSend } from "../../agents/pi-embedded-runner.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; -import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js"; +import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import type { ReplyPayload } from "../types.js"; @@ -106,32 +105,15 @@ function targetsMatchForSuppression(params: { targetKey: string; targetThreadId?: string; }): boolean { - if (params.provider !== "telegram") { - return params.targetKey === params.originTarget; + const pluginMatch = getChannelPlugin(params.provider)?.outbound?.targetsMatchForReplySuppression; + if (pluginMatch) { + return pluginMatch({ + originTarget: params.originTarget, + targetKey: params.targetKey, + targetThreadId: normalizeThreadIdForComparison(params.targetThreadId), + }); } - - const origin = parseExplicitTargetForChannel("telegram", params.originTarget); - const target = parseExplicitTargetForChannel("telegram", params.targetKey); - if (!origin || !target) { - return params.targetKey === params.originTarget; - } - const explicitTargetThreadId = normalizeThreadIdForComparison(params.targetThreadId); - const targetThreadId = - explicitTargetThreadId ?? (target.threadId != null ? String(target.threadId) : undefined); - const originThreadId = origin.threadId != null ? String(origin.threadId) : undefined; - if (origin.to.trim().toLowerCase() !== target.to.trim().toLowerCase()) { - return false; - } - if (originThreadId && targetThreadId != null) { - return originThreadId === targetThreadId; - } - if (originThreadId && targetThreadId == null) { - return false; - } - if (!originThreadId && targetThreadId != null) { - return false; - } - return true; + return params.targetKey === params.originTarget; } export function shouldSuppressMessagingToolReplies(params: { diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index 18c5ea7cf19..fe1ffc10aeb 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -189,106 +189,45 @@ describe("normalizeReplyPayload", () => { expect(result!.interactive).toBeUndefined(); }); - it("applies responsePrefix before compiling Slack directives into shared interactive blocks", () => { + it("applies responsePrefix before channel-owned transforms run", () => { const result = normalizeReplyPayload( { text: "hello [[slack_buttons: Retry:retry, Ignore:ignore]]", }, - { responsePrefix: "[bot]", enableSlackInteractiveReplies: true }, + { responsePrefix: "[bot]" }, ); expect(result).not.toBeNull(); - expect(result!.text).toBe("[bot] hello"); - expect(result!.interactive).toEqual({ - blocks: [ - { - type: "text", - text: "[bot] hello", - }, - { - type: "buttons", - buttons: [ - { - label: "Retry", - value: "retry", - }, - { - label: "Ignore", - value: "ignore", - }, - ], - }, - ], - }); + expect(result!.text).toBe("[bot] hello [[slack_buttons: Retry:retry, Ignore:ignore]]"); + expect(result!.interactive).toBeUndefined(); }); - it("compiles simple trailing Options lines into Slack buttons when interactive replies are enabled", () => { - const result = normalizeReplyPayload( - { - text: "Current verbose level: off.\nOptions: on, full, off.", - }, - { enableSlackInteractiveReplies: true }, - ); + it("leaves trailing Options lines for channel-owned transforms", () => { + const result = normalizeReplyPayload({ + text: "Current verbose level: off.\nOptions: on, full, off.", + }); expect(result).not.toBeNull(); - expect(result!.text).toBe("Current verbose level: off."); - expect(result!.interactive).toEqual({ - blocks: [ - { - type: "text", - text: "Current verbose level: off.", - }, - { - type: "buttons", - buttons: [ - { label: "on", value: "on" }, - { label: "full", value: "full" }, - { label: "off", value: "off" }, - ], - }, - ], - }); + expect(result!.text).toBe("Current verbose level: off.\nOptions: on, full, off."); + expect(result!.interactive).toBeUndefined(); }); - it("uses a Slack select when simple Options lines exceed the button row size", () => { - const result = normalizeReplyPayload( - { - text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", - }, - { enableSlackInteractiveReplies: true }, - ); + it("leaves larger Options lists for channel-owned transforms", () => { + const result = normalizeReplyPayload({ + text: "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", + }); expect(result).not.toBeNull(); - expect(result!.text).toBe("Choose a reasoning level."); - expect(result!.interactive).toEqual({ - blocks: [ - { - type: "text", - text: "Choose a reasoning level.", - }, - { - type: "select", - placeholder: "Choose an option", - options: [ - { label: "off", value: "off" }, - { label: "minimal", value: "minimal" }, - { label: "low", value: "low" }, - { label: "medium", value: "medium" }, - { label: "high", value: "high" }, - { label: "adaptive", value: "adaptive" }, - ], - }, - ], - }); + expect(result!.text).toBe( + "Choose a reasoning level.\nOptions: off, minimal, low, medium, high, adaptive.", + ); + expect(result!.interactive).toBeUndefined(); }); it("leaves complex Options lines as plain text", () => { - const result = normalizeReplyPayload( - { - text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", - }, - { enableSlackInteractiveReplies: true }, - ); + const result = normalizeReplyPayload({ + text: "ACP runtime choices.\nOptions: host=auto|sandbox|gateway|node, security=deny|allowlist|full.", + }); expect(result).not.toBeNull(); expect(result!.text).toBe( diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index e5b7243a561..0d27c7592bb 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -101,10 +101,6 @@ export async function routeReply(params: RouteReplyParams): Promise { - it("builds canonical topic ids from chat target and message thread id", () => { - const conversationId = resolveTelegramConversationId({ - ctx: { - OriginatingTo: "-100200300", - MessageThreadId: "77", - }, - command: {}, - }); - expect(conversationId).toBe("-100200300:topic:77"); - }); - - it("returns the direct-message chat id when no topic id is present", () => { - const conversationId = resolveTelegramConversationId({ - ctx: { - OriginatingTo: "123456", - }, - command: {}, - }); - expect(conversationId).toBe("123456"); - }); - - it("does not treat non-topic groups as globally bindable conversations", () => { - const conversationId = resolveTelegramConversationId({ - ctx: { - OriginatingTo: "-100200300", - }, - command: {}, - }); - expect(conversationId).toBeUndefined(); - }); - - it("falls back to command target when originating target is missing", () => { - const conversationId = resolveTelegramConversationId({ - ctx: { - To: "123456", - }, - command: { - to: "78910", - }, - }); - expect(conversationId).toBe("78910"); - }); -}); diff --git a/src/auto-reply/reply/telegram-context.ts b/src/auto-reply/reply/telegram-context.ts deleted file mode 100644 index 8ab905a44d1..00000000000 --- a/src/auto-reply/reply/telegram-context.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js"; - -type TelegramConversationParams = { - ctx: { - MessageThreadId?: string | number | null; - OriginatingTo?: string; - To?: string; - }; - command: { - to?: string; - }; -}; - -export function resolveTelegramConversationId( - params: TelegramConversationParams, -): string | undefined { - const rawThreadId = - params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; - const threadId = rawThreadId || undefined; - const toCandidates = [ - typeof params.ctx.OriginatingTo === "string" ? params.ctx.OriginatingTo : "", - typeof params.command.to === "string" ? params.command.to : "", - typeof params.ctx.To === "string" ? params.ctx.To : "", - ] - .map((value) => value.trim()) - .filter(Boolean); - const chatId = toCandidates - .map((candidate) => parseExplicitTargetForChannel("telegram", candidate)?.to.trim() ?? "") - .find((candidate) => candidate.length > 0); - if (!chatId) { - return undefined; - } - if (threadId) { - return `${chatId}:topic:${threadId}`; - } - // Non-topic groups should not become globally focused conversations. - if (chatId.startsWith("-")) { - return undefined; - } - return chatId; -} diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a66c6e887aa..8dc3b3a43a7 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -16,6 +16,7 @@ import { normalizeToolName } from "../agents/tool-policy-shared.js"; import type { EffectiveToolInventoryResult } from "../agents/tools-effective-inventory.js"; import { derivePromptTokens, normalizeUsage, type UsageLike } from "../agents/usage.js"; import { resolveChannelModelOverride } from "../channels/model-overrides.js"; +import { getChannelPlugin } from "../channels/plugins/index.js"; import { isCommandFlagEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -1089,7 +1090,9 @@ export function buildCommandsMessagePaginated( ): CommandsMessageResult { const page = Math.max(1, options?.page ?? 1); const surface = options?.surface?.toLowerCase(); - const isTelegram = surface === "telegram"; + const prefersPaginatedList = Boolean( + surface && getChannelPlugin(surface)?.commands?.buildCommandsListChannelData, + ); const commands = cfg ? listChatCommandsForConfig(cfg, { skillCommands }) @@ -1097,7 +1100,7 @@ export function buildCommandsMessagePaginated( const pluginCommands = listPluginCommands(); const items = buildCommandItems(commands, pluginCommands); - if (!isTelegram) { + if (!prefersPaginatedList) { const lines = ["ℹ️ Slash commands", ""]; lines.push(formatCommandList(items)); lines.push("", "More: /tools for available capabilities"); diff --git a/src/channels/conversation-binding-context.ts b/src/channels/conversation-binding-context.ts index f098623492f..8f7d5df2cad 100644 --- a/src/channels/conversation-binding-context.ts +++ b/src/channels/conversation-binding-context.ts @@ -55,6 +55,10 @@ function getLoadedChannelPlugin(rawChannel: string): ChannelPlugin | undefined { ?.plugin; } +function shouldDefaultParentConversationToSelf(plugin?: ChannelPlugin): boolean { + return plugin?.bindings?.selfParentConversationByDefault === true; +} + function resolveChannelTargetId(params: { channel: string; target?: string | null; @@ -137,7 +141,9 @@ export function resolveConversationBindingContext( }); if (resolvedByProvider?.conversationId) { const resolvedParentConversationId = - channel === "telegram" && !threadId && !resolvedByProvider.parentConversationId + shouldDefaultParentConversationToSelf(loadedPlugin) && + !threadId && + !resolvedByProvider.parentConversationId ? resolvedByProvider.conversationId : resolvedByProvider.parentConversationId; return { @@ -201,7 +207,7 @@ export function resolveConversationBindingContext( return null; } const normalizedParentConversationId = - channel === "telegram" && !threadId && !parentConversationId + shouldDefaultParentConversationToSelf(loadedPlugin) && !threadId && !parentConversationId ? conversationId : parentConversationId; return { diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts deleted file mode 100644 index a2b42afcf34..00000000000 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../../extensions/discord/action-runtime-api.js"; diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts deleted file mode 100644 index a2b42afcf34..00000000000 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../../../../../extensions/discord/action-runtime-api.js"; diff --git a/src/channels/plugins/lifecycle-startup.ts b/src/channels/plugins/lifecycle-startup.ts new file mode 100644 index 00000000000..f4f1bb45992 --- /dev/null +++ b/src/channels/plugins/lifecycle-startup.ts @@ -0,0 +1,29 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { listChannelPlugins } from "./registry.js"; + +type ChannelStartupLogger = { + info?: (message: string) => void; + warn?: (message: string) => void; +}; + +export async function runChannelPluginStartupMaintenance(params: { + cfg: OpenClawConfig; + env?: NodeJS.ProcessEnv; + log: ChannelStartupLogger; + trigger?: string; + logPrefix?: string; +}): Promise { + for (const plugin of listChannelPlugins()) { + const runStartupMaintenance = plugin.lifecycle?.runStartupMaintenance; + if (!runStartupMaintenance) { + continue; + } + try { + await runStartupMaintenance(params); + } catch (err) { + params.log.warn?.( + `${params.logPrefix?.trim() || "gateway"}: ${plugin.id} startup maintenance failed; continuing: ${String(err)}`, + ); + } + } +} diff --git a/src/channels/reply-prefix.ts b/src/channels/reply-prefix.ts index cfda423eeb9..e3292ca80ea 100644 --- a/src/channels/reply-prefix.ts +++ b/src/channels/reply-prefix.ts @@ -4,7 +4,6 @@ import { type ResponsePrefixContext, } from "../auto-reply/reply/response-prefix-template.js"; import type { GetReplyOptions } from "../auto-reply/types.js"; -import { getChannelPlugin } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/config.js"; type ModelSelectionContext = Parameters>[0]; @@ -12,17 +11,13 @@ type ModelSelectionContext = Parameters ResponsePrefixContext; onModelSelected: (ctx: ModelSelectionContext) => void; }; export type ReplyPrefixOptions = Pick< ReplyPrefixContextBundle, - | "responsePrefix" - | "enableSlackInteractiveReplies" - | "responsePrefixContextProvider" - | "onModelSelected" + "responsePrefix" | "responsePrefixContextProvider" | "onModelSelected" >; export function createReplyPrefixContext(params: { @@ -50,12 +45,6 @@ export function createReplyPrefixContext(params: { channel: params.channel, accountId: params.accountId, }).responsePrefix, - enableSlackInteractiveReplies: params.channel - ? (getChannelPlugin(params.channel)?.messaging?.enableInteractiveReplies?.({ - cfg, - accountId: params.accountId, - }) ?? undefined) - : undefined, responsePrefixContextProvider: () => prefixContext, onModelSelected, }; @@ -67,15 +56,10 @@ export function createReplyPrefixOptions(params: { channel?: string; accountId?: string; }): ReplyPrefixOptions { - const { - responsePrefix, - enableSlackInteractiveReplies, - responsePrefixContextProvider, - onModelSelected, - } = createReplyPrefixContext(params); + const { responsePrefix, responsePrefixContextProvider, onModelSelected } = + createReplyPrefixContext(params); return { responsePrefix, - enableSlackInteractiveReplies, responsePrefixContextProvider, onModelSelected, }; diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 9c1cde5df06..aade4d61d4a 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -1,8 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeAccountId } from "../routing/session-key.js"; +import { getChannelPlugin } from "./plugins/index.js"; -export const DISCORD_THREAD_BINDING_CHANNEL = "discord"; -export const MATRIX_THREAD_BINDING_CHANNEL = "matrix"; const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; @@ -35,27 +34,31 @@ function normalizeChannelId(value: string | undefined | null): string { } export function supportsAutomaticThreadBindingSpawn(channel: string): boolean { - const normalized = normalizeChannelId(channel); - return ( - normalized === DISCORD_THREAD_BINDING_CHANNEL || normalized === MATRIX_THREAD_BINDING_CHANNEL - ); + return resolveDefaultTopLevelPlacement(channel) === "child"; } export function requiresNativeThreadContextForThreadHere(channel: string): boolean { - const normalized = normalizeChannelId(channel); - return normalized !== "telegram" && normalized !== "feishu" && normalized !== "line"; + return resolveDefaultTopLevelPlacement(channel) === "child"; } export function resolveThreadBindingPlacementForCurrentContext(params: { channel: string; threadId?: string; }): "current" | "child" { - if (!requiresNativeThreadContextForThreadHere(params.channel)) { + if (resolveDefaultTopLevelPlacement(params.channel) !== "child") { return "current"; } return params.threadId ? "current" : "child"; } +function resolveDefaultTopLevelPlacement(channel: string): "current" | "child" { + const normalized = normalizeChannelId(channel); + if (!normalized) { + return "current"; + } + return getChannelPlugin(normalized)?.conversationBindings?.defaultTopLevelPlacement ?? "current"; +} + function normalizeBoolean(value: unknown): boolean | undefined { if (typeof value !== "boolean") { return undefined; @@ -202,7 +205,7 @@ export function resolveThreadBindingSpawnPolicy(params: { const spawnFlagKey = resolveSpawnFlagKey(params.kind); const spawnEnabledRaw = normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]); - const spawnEnabled = spawnEnabledRaw ?? !supportsAutomaticThreadBindingSpawn(channel); + const spawnEnabled = spawnEnabledRaw ?? resolveDefaultTopLevelPlacement(channel) !== "child"; return { channel, accountId, @@ -254,13 +257,7 @@ export function formatThreadBindingDisabledError(params: { accountId: string; kind: ThreadBindingSpawnKind; }): string { - if (params.channel === DISCORD_THREAD_BINDING_CHANNEL) { - return "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally)."; - } - if (params.channel === MATRIX_THREAD_BINDING_CHANNEL) { - return "Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally)."; - } - return `Thread bindings are disabled for ${params.channel} (set session.threadBindings.enabled=true to enable).`; + return `Thread bindings are disabled for ${params.channel} (set channels.${params.channel}.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).`; } export function formatThreadBindingSpawnDisabledError(params: { @@ -268,17 +265,6 @@ export function formatThreadBindingSpawnDisabledError(params: { accountId: string; kind: ThreadBindingSpawnKind; }): string { - if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "acp") { - return "Discord thread-bound ACP spawns are disabled for this account (set channels.discord.threadBindings.spawnAcpSessions=true to enable)."; - } - if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "subagent") { - return "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable)."; - } - if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "acp") { - return "Matrix thread-bound ACP spawns are disabled for this account (set channels.matrix.threadBindings.spawnAcpSessions=true to enable)."; - } - if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "subagent") { - return "Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable)."; - } - return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`; + const spawnFlagKey = resolveSpawnFlagKey(params.kind); + return `Thread-bound ${params.kind} spawns are disabled for ${params.channel} (set channels.${params.channel}.threadBindings.${spawnFlagKey}=true to enable).`; } diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 5c647b46387..abda4eb2027 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,6 +1,8 @@ +import { listChannelPlugins } from "../channels/plugins/index.js"; import type { OutboundSendDeps } from "../infra/outbound/send-deps.js"; import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js"; import { createOutboundSendDepsFromCliSource } from "./outbound-send-mapping.js"; +import { createChannelOutboundRuntimeSend } from "./send-runtime/channel-outbound-send.js"; /** * Lazy-loaded per-channel send functions, keyed by channel ID. @@ -40,32 +42,20 @@ function createLazySender( export function createDefaultDeps(): CliDeps { // Keep the default dependency barrel limited to lazy senders so callers that // only need outbound deps do not pull channel runtime boundaries on import. - return { - whatsapp: createLazySender( - "whatsapp", - () => import("./send-runtime/whatsapp.js") as Promise, - ), - telegram: createLazySender( - "telegram", - () => import("./send-runtime/telegram.js") as Promise, - ), - discord: createLazySender( - "discord", - () => import("./send-runtime/discord.js") as Promise, - ), - slack: createLazySender( - "slack", - () => import("./send-runtime/slack.js") as Promise, - ), - signal: createLazySender( - "signal", - () => import("./send-runtime/signal.js") as Promise, - ), - imessage: createLazySender( - "imessage", - () => import("./send-runtime/imessage.js") as Promise, - ), - }; + const deps: CliDeps = {}; + for (const plugin of listChannelPlugins()) { + deps[plugin.id] = createLazySender( + plugin.id, + async () => + ({ + runtimeSend: createChannelOutboundRuntimeSend({ + channelId: plugin.id, + unavailableMessage: `${plugin.meta.label ?? plugin.id} outbound adapter is unavailable.`, + }) as RuntimeSend, + }) satisfies RuntimeSendModule, + ); + } + return deps; } export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { diff --git a/src/cli/plugin-install-config-policy.ts b/src/cli/plugin-install-config-policy.ts index 5350b6b84d4..4e7034da8f3 100644 --- a/src/cli/plugin-install-config-policy.ts +++ b/src/cli/plugin-install-config-policy.ts @@ -1,15 +1,20 @@ +import fs from "node:fs"; import path from "node:path"; import type { Command } from "commander"; +import { findBundledPluginSource } from "../plugins/bundled-sources.js"; +import { loadPluginManifest } from "../plugins/manifest.js"; import { resolveUserPath } from "../utils.js"; import { resolveFileNpmSpecToLocalPath } from "./plugins-command-helpers.js"; -export type PluginInstallInvalidConfigPolicy = "deny" | "recover-matrix-only"; +export type PluginInstallInvalidConfigPolicy = "deny" | "allow-bundled-recovery"; export type PluginInstallRequestContext = { rawSpec: string; normalizedSpec: string; resolvedPath?: string; marketplace?: string; + bundledPluginId?: string; + allowInvalidConfigRecovery?: boolean; }; type PluginInstallRequestResolution = @@ -20,21 +25,71 @@ function isPluginInstallCommand(commandPath: string[]): boolean { return commandPath[0] === "plugins" && commandPath[1] === "install"; } -function isExplicitMatrixInstallRequest(request: PluginInstallRequestContext): boolean { +function readBundledInstallRecoveryMetadata(rootDir: string): { + pluginId?: string; + allowInvalidConfigRecovery: boolean; +} { + const packageJsonPath = path.join(rootDir, "package.json"); + if (!fs.existsSync(packageJsonPath)) { + return { allowInvalidConfigRecovery: false }; + } + const manifest = loadPluginManifest(rootDir, false); + const pluginId = manifest.ok ? manifest.manifest.id : undefined; + try { + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + openclaw?: { + install?: { + allowInvalidConfigRecovery?: boolean; + }; + }; + }; + return { + ...(pluginId ? { pluginId } : {}), + allowInvalidConfigRecovery: parsed.openclaw?.install?.allowInvalidConfigRecovery === true, + }; + } catch { + return { + ...(pluginId ? { pluginId } : {}), + allowInvalidConfigRecovery: false, + }; + } +} + +function resolveBundledInstallRecoveryMetadata( + request: Pick< + PluginInstallRequestContext, + "rawSpec" | "normalizedSpec" | "resolvedPath" | "marketplace" + >, +): { + pluginId?: string; + allowInvalidConfigRecovery?: boolean; +} { if (request.marketplace) { - return false; + return {}; } - const candidates = [request.rawSpec.trim(), request.normalizedSpec.trim()]; - if (candidates.includes("@openclaw/matrix")) { - return true; + if (request.resolvedPath && fs.existsSync(path.join(request.resolvedPath, "package.json"))) { + const direct = readBundledInstallRecoveryMetadata(request.resolvedPath); + if (direct.pluginId || direct.allowInvalidConfigRecovery) { + return direct; + } } - if (!request.resolvedPath) { - return false; + for (const value of [request.rawSpec.trim(), request.normalizedSpec.trim()]) { + if (!value) { + continue; + } + const bundled = findBundledPluginSource({ + lookup: { kind: "npmSpec", value }, + }); + if (!bundled) { + continue; + } + const recovered = readBundledInstallRecoveryMetadata(bundled.localPath); + return { + pluginId: recovered.pluginId ?? bundled.pluginId, + allowInvalidConfigRecovery: recovered.allowInvalidConfigRecovery, + }; } - return ( - path.basename(request.resolvedPath) === "matrix" && - path.basename(path.dirname(request.resolvedPath)) === "extensions" - ); + return {}; } function resolvePluginInstallArgvTokens(commandPath: string[], argv: string[]): string[] { @@ -103,12 +158,22 @@ export function resolvePluginInstallRequestContext(params: { }; } const normalizedSpec = fileSpec && fileSpec.ok ? fileSpec.path : params.rawSpec; + const recovered = resolveBundledInstallRecoveryMetadata({ + rawSpec: params.rawSpec, + normalizedSpec, + resolvedPath: resolveUserPath(normalizedSpec), + marketplace: params.marketplace, + }); return { ok: true, request: { rawSpec: params.rawSpec, normalizedSpec, resolvedPath: resolveUserPath(normalizedSpec), + ...(recovered.pluginId ? { bundledPluginId: recovered.pluginId } : {}), + ...(recovered.allowInvalidConfigRecovery !== undefined + ? { allowInvalidConfigRecovery: recovered.allowInvalidConfigRecovery } + : {}), }, }; } @@ -144,5 +209,5 @@ export function resolvePluginInstallInvalidConfigPolicy( if (!request) { return "deny"; } - return isExplicitMatrixInstallRequest(request) ? "recover-matrix-only" : "deny"; + return request.allowInvalidConfigRecovery === true ? "allow-bundled-recovery" : "deny"; } diff --git a/src/cli/plugin-registry.ts b/src/cli/plugin-registry.ts index 08351cd5d8f..03912568f5e 100644 --- a/src/cli/plugin-registry.ts +++ b/src/cli/plugin-registry.ts @@ -1,118 +1,5 @@ -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { loadConfig } from "../config/config.js"; -import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; -import { createSubsystemLogger } from "../logging.js"; -import { - resolveChannelPluginIds, - resolveConfiguredChannelPluginIds, -} from "../plugins/channel-plugin-ids.js"; -import { loadOpenClawPlugins } from "../plugins/loader.js"; -import { getActivePluginRegistry } from "../plugins/runtime.js"; -import type { PluginLogger } from "../plugins/types.js"; - -const log = createSubsystemLogger("plugins"); -let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none"; - -export type PluginRegistryScope = "configured-channels" | "channels" | "all"; - -function scopeRank(scope: typeof pluginRegistryLoaded): number { - switch (scope) { - case "none": - return 0; - case "configured-channels": - return 1; - case "channels": - return 2; - case "all": - return 3; - } -} - -function activeRegistrySatisfiesScope( - scope: PluginRegistryScope, - active: ReturnType, - expectedChannelPluginIds: readonly string[], -): boolean { - if (!active) { - return false; - } - const activeChannelPluginIds = new Set(active.channels.map((entry) => entry.plugin.id)); - switch (scope) { - case "configured-channels": - case "channels": - return ( - active.channels.length > 0 && - expectedChannelPluginIds.every((pluginId) => activeChannelPluginIds.has(pluginId)) - ); - case "all": - return false; - } -} - -export function ensurePluginRegistryLoaded(options?: { scope?: PluginRegistryScope }): void { - const scope = options?.scope ?? "all"; - if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { - return; - } - const config = loadConfig(); - const autoEnabled = applyPluginAutoEnable({ config, env: process.env }); - const resolvedConfig = autoEnabled.config; - const workspaceDir = resolveAgentWorkspaceDir( - resolvedConfig, - resolveDefaultAgentId(resolvedConfig), - ); - const expectedChannelPluginIds = - scope === "configured-channels" - ? resolveConfiguredChannelPluginIds({ - config: resolvedConfig, - workspaceDir, - env: process.env, - }) - : scope === "channels" - ? resolveChannelPluginIds({ - config: resolvedConfig, - workspaceDir, - env: process.env, - }) - : []; - const active = getActivePluginRegistry(); - // Tests (and callers) can pre-seed a registry (e.g. `test/setup.ts`); avoid - // doing an expensive load when we already have plugins/channels/tools. - if ( - pluginRegistryLoaded === "none" && - activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds) - ) { - pluginRegistryLoaded = scope; - return; - } - const logger: PluginLogger = { - info: (msg) => log.info(msg), - warn: (msg) => log.warn(msg), - error: (msg) => log.error(msg), - debug: (msg) => log.debug(msg), - }; - loadOpenClawPlugins({ - config: resolvedConfig, - activationSourceConfig: config, - autoEnabledReasons: autoEnabled.autoEnabledReasons, - workspaceDir, - logger, - throwOnLoadError: true, - ...(scope === "configured-channels" - ? { - onlyPluginIds: expectedChannelPluginIds, - } - : scope === "channels" - ? { - onlyPluginIds: expectedChannelPluginIds, - } - : {}), - }); - pluginRegistryLoaded = scope; -} - -export const __testing = { - resetPluginRegistryLoadedForTests(): void { - pluginRegistryLoaded = "none"; - }, -}; +export { + __testing, + ensurePluginRegistryLoaded, + type PluginRegistryScope, +} from "../plugins/runtime/runtime-registry-loader.js"; diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index c1618d6d89f..d9c97e9bef4 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -1,5 +1,5 @@ import fs from "node:fs"; -import { cleanStaleMatrixPluginConfig } from "../commands/doctor/providers/matrix.js"; +import { collectChannelDoctorStaleConfigMutations } from "../commands/doctor/shared/channel-doctor.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, readConfigFileSnapshot } from "../config/config.js"; import { installHooksFromNpmSpec, installHooksFromPath } from "../hooks/install.js"; @@ -174,9 +174,17 @@ async function tryInstallHookPackFromNpmSpec(params: { return { ok: true }; } -function isAllowedMatrixRecoveryIssue(issue: { path?: string; message?: string }): boolean { +function isAllowedBundledRecoveryIssue( + issue: { path?: string; message?: string }, + request: PluginInstallRequestContext, +): boolean { + const pluginId = request.bundledPluginId?.trim(); + if (!pluginId) { + return false; + } return ( - (issue.path === "channels.matrix" && issue.message === "unknown channel id: matrix") || + (issue.path === `channels.${pluginId}` && + issue.message === `unknown channel id: ${pluginId}`) || (issue.path === "plugins.load.paths" && typeof issue.message === "string" && issue.message.includes("plugin path not found")) @@ -192,7 +200,7 @@ function buildInvalidPluginInstallConfigError(message: string): Error { async function loadConfigFromSnapshotForInstall( request: PluginInstallRequestContext, ): Promise { - if (resolvePluginInstallInvalidConfigPolicy(request) !== "recover-matrix-only") { + if (resolvePluginInstallInvalidConfigPolicy(request) !== "allow-bundled-recovery") { throw buildInvalidPluginInstallConfigError( "Config invalid; run `openclaw doctor --fix` before installing plugins.", ); @@ -207,14 +215,18 @@ async function loadConfigFromSnapshotForInstall( if ( snapshot.legacyIssues.length > 0 || snapshot.issues.length === 0 || - snapshot.issues.some((issue) => !isAllowedMatrixRecoveryIssue(issue)) + snapshot.issues.some((issue) => !isAllowedBundledRecoveryIssue(issue, request)) ) { + const pluginLabel = request.bundledPluginId ?? "the requested plugin"; throw buildInvalidPluginInstallConfigError( - "Config invalid outside the Matrix upgrade recovery path; run `openclaw doctor --fix` before reinstalling Matrix.", + `Config invalid outside the bundled recovery path for ${pluginLabel}; run \`openclaw doctor --fix\` before reinstalling it.`, ); } - const cleaned = await cleanStaleMatrixPluginConfig(snapshot.config); - return cleaned.config; + let nextConfig = snapshot.config; + for (const mutation of await collectChannelDoctorStaleConfigMutations(snapshot.config)) { + nextConfig = mutation.config; + } + return nextConfig; } export async function loadConfigForInstall( diff --git a/src/cli/plugins-install-config.test.ts b/src/cli/plugins-install-config.test.ts index 614b6bc047a..bd1404902dd 100644 --- a/src/cli/plugins-install-config.test.ts +++ b/src/cli/plugins-install-config.test.ts @@ -7,20 +7,22 @@ import { loadConfigForInstall } from "./plugins-install-command.js"; const hoisted = vi.hoisted(() => ({ loadConfigMock: vi.fn<() => OpenClawConfig>(), readConfigFileSnapshotMock: vi.fn<() => Promise>(), - cleanStaleMatrixPluginConfigMock: vi.fn(), + collectChannelDoctorStaleConfigMutationsMock: vi.fn(), })); const loadConfigMock = hoisted.loadConfigMock; const readConfigFileSnapshotMock = hoisted.readConfigFileSnapshotMock; -const cleanStaleMatrixPluginConfigMock = hoisted.cleanStaleMatrixPluginConfigMock; +const collectChannelDoctorStaleConfigMutationsMock = + hoisted.collectChannelDoctorStaleConfigMutationsMock; vi.mock("../config/config.js", () => ({ loadConfig: () => loadConfigMock(), readConfigFileSnapshot: () => readConfigFileSnapshotMock(), })); -vi.mock("../commands/doctor/providers/matrix.js", () => ({ - cleanStaleMatrixPluginConfig: (cfg: OpenClawConfig) => cleanStaleMatrixPluginConfigMock(cfg), +vi.mock("../commands/doctor/shared/channel-doctor.js", () => ({ + collectChannelDoctorStaleConfigMutations: (cfg: OpenClawConfig) => + collectChannelDoctorStaleConfigMutationsMock(cfg), })); const MATRIX_REPO_INSTALL_SPEC = repoInstallSpec("matrix"); @@ -53,12 +55,14 @@ describe("loadConfigForInstall", () => { beforeEach(() => { loadConfigMock.mockReset(); readConfigFileSnapshotMock.mockReset(); - cleanStaleMatrixPluginConfigMock.mockReset(); + collectChannelDoctorStaleConfigMutationsMock.mockReset(); - cleanStaleMatrixPluginConfigMock.mockImplementation((cfg: OpenClawConfig) => ({ - config: cfg, - changes: [], - })); + collectChannelDoctorStaleConfigMutationsMock.mockImplementation(async (cfg: OpenClawConfig) => [ + { + config: cfg, + changes: [], + }, + ]); }); it("returns the config directly when loadConfig succeeds", async () => { @@ -75,7 +79,7 @@ describe("loadConfigForInstall", () => { loadConfigMock.mockReturnValue(cfg); const result = await loadConfigForInstall(matrixNpmRequest); - expect(cleanStaleMatrixPluginConfigMock).not.toHaveBeenCalled(); + expect(collectChannelDoctorStaleConfigMutationsMock).not.toHaveBeenCalled(); expect(result).toBe(cfg); }); @@ -102,7 +106,7 @@ describe("loadConfigForInstall", () => { const result = await loadConfigForInstall(matrixNpmRequest); expect(readConfigFileSnapshotMock).toHaveBeenCalled(); - expect(cleanStaleMatrixPluginConfigMock).toHaveBeenCalledWith(snapshotCfg); + expect(collectChannelDoctorStaleConfigMutationsMock).toHaveBeenCalledWith(snapshotCfg); expect(result).toBe(snapshotCfg); }); diff --git a/src/cli/program/message/register.thread.ts b/src/cli/program/message/register.thread.ts index 7df3007c568..6fe35b4d3df 100644 --- a/src/cli/program/message/register.thread.ts +++ b/src/cli/program/message/register.thread.ts @@ -1,21 +1,25 @@ import type { Command } from "commander"; +import { getChannelPlugin } from "../../../channels/plugins/index.js"; +import type { ChannelMessageActionName } from "../../../channels/plugins/types.js"; import type { MessageCliHelpers } from "./helpers.js"; function resolveThreadCreateRequest(opts: Record) { const channel = typeof opts.channel === "string" ? opts.channel.trim().toLowerCase() : ""; - if (channel !== "telegram") { - return { - action: "thread-create" as const, - params: opts, - }; + if (channel) { + const request = getChannelPlugin(channel)?.actions?.resolveCliActionRequest?.({ + action: "thread-create", + args: opts, + }); + if (request) { + return { + action: request.action, + params: request.args, + }; + } } - const { threadName, ...rest } = opts; return { - action: "topic-create" as const, - params: { - ...rest, - name: typeof threadName === "string" ? threadName : undefined, - }, + action: "thread-create" as ChannelMessageActionName, + params: opts, }; } diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index e083921236a..462b684a878 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -91,7 +91,7 @@ function shouldAllowInvalidConfigForAction(actionCommand: Command, commandPath: commandPath, argv: process.argv, }), - ) === "recover-matrix-only" + ) === "allow-bundled-recovery" ); } diff --git a/src/cli/send-runtime/channel-outbound-send.ts b/src/cli/send-runtime/channel-outbound-send.ts new file mode 100644 index 00000000000..ee390e84de5 --- /dev/null +++ b/src/cli/send-runtime/channel-outbound-send.ts @@ -0,0 +1,47 @@ +import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; +import type { ChannelId } from "../../channels/plugins/types.js"; +import { loadConfig } from "../../config/config.js"; + +type RuntimeSendOpts = { + cfg?: ReturnType; + mediaUrl?: string; + mediaLocalRoots?: readonly string[]; + accountId?: string; + messageThreadId?: string | number; + replyToMessageId?: string | number; + silent?: boolean; + forceDocument?: boolean; + gifPlayback?: boolean; + gatewayClientScopes?: readonly string[]; +}; + +export function createChannelOutboundRuntimeSend(params: { + channelId: ChannelId; + unavailableMessage: string; +}) { + return { + sendMessage: async (to: string, text: string, opts: RuntimeSendOpts = {}) => { + const outbound = await loadChannelOutboundAdapter(params.channelId); + if (!outbound?.sendText) { + throw new Error(params.unavailableMessage); + } + return await outbound.sendText({ + cfg: opts.cfg ?? loadConfig(), + to, + text, + mediaUrl: opts.mediaUrl, + mediaLocalRoots: opts.mediaLocalRoots, + accountId: opts.accountId, + threadId: opts.messageThreadId, + replyToId: + opts.replyToMessageId == null + ? undefined + : String(opts.replyToMessageId).trim() || undefined, + silent: opts.silent, + forceDocument: opts.forceDocument, + gifPlayback: opts.gifPlayback, + gatewayClientScopes: opts.gatewayClientScopes, + }); + }, + }; +} diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index ca0e8de2567..885f16e2d8e 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -52,16 +52,50 @@ function detectAutoKind(input: string): ChannelResolveKind { if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) { return "user"; } - if ( - /^(user|discord|slack|matrix|msteams|teams|zalo|zalouser|googlechat|google-chat|gchat):/i.test( - trimmed, - ) - ) { + if (/^user:/i.test(trimmed)) { return "user"; } return "group"; } +function detectAutoKindForPlugin( + input: string, + plugin?: { + id: string; + meta?: { + aliases?: readonly string[]; + }; + }, +): ChannelResolveKind { + const generic = detectAutoKind(input); + if (generic === "user" || !plugin) { + return generic; + } + const trimmed = input.trim(); + const lowered = trimmed.toLowerCase(); + const prefixes = [plugin.id, ...(plugin.meta?.aliases ?? [])] + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + for (const prefix of prefixes) { + if (!lowered.startsWith(`${prefix}:`)) { + continue; + } + const remainder = lowered.slice(prefix.length + 1); + if ( + remainder.startsWith("group:") || + remainder.startsWith("channel:") || + remainder.startsWith("room:") || + remainder.startsWith("conversation:") || + remainder.startsWith("spaces/") || + remainder.startsWith("channels/") + ) { + return "group"; + } + return "user"; + } + return generic; +} + function formatResolveResult(result: ResolveResult): string { if (!result.resolved || !result.id) { return `${result.input} -> unresolved`; @@ -146,7 +180,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti } else { const byKind = new Map(); for (const entry of entries) { - const kind = detectAutoKind(entry); + const kind = detectAutoKindForPlugin(entry, plugin); byKind.set(kind, [...(byKind.get(kind) ?? []), entry]); } const resolved: ChannelResolveResult[] = []; diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index 33ce66d4b65..37279e82347 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -9,11 +9,13 @@ import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; import type { DoctorOptions } from "./doctor-prompter.js"; import { emitDoctorNotes } from "./doctor/emit-notes.js"; import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js"; -import { - cleanStaleMatrixPluginConfig, - runMatrixDoctorSequence, -} from "./doctor/providers/matrix.js"; import { runDoctorRepairSequence } from "./doctor/repair-sequencing.js"; +import { + collectChannelDoctorCompatibilityMutations, + collectChannelDoctorMutableAllowlistWarnings, + collectChannelDoctorStaleConfigMutations, + runChannelDoctorConfigSequences, +} from "./doctor/shared/channel-doctor.js"; import { applyLegacyCompatibilityStep, applyUnknownConfigKeyStep, @@ -68,6 +70,19 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { })); } + for (const compatibility of collectChannelDoctorCompatibilityMutations(candidate)) { + if (compatibility.changes.length === 0) { + continue; + } + note(compatibility.changes.join("\n"), "Doctor changes"); + ({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({ + state: { cfg, candidate, pendingChanges, fixHints }, + mutation: compatibility, + shouldRepair, + fixHint: `Run "${doctorFixCommand}" to apply these changes.`, + })); + } + const autoEnable = applyPluginAutoEnable({ config: candidate, env: process.env }); if (autoEnable.changes.length > 0) { note(autoEnable.changes.join("\n"), "Doctor changes"); @@ -79,25 +94,27 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { })); } - const matrixSequence = await runMatrixDoctorSequence({ + const channelDoctorSequence = await runChannelDoctorConfigSequences({ cfg: candidate, env: process.env, shouldRepair, }); emitDoctorNotes({ note, - changeNotes: matrixSequence.changeNotes, - warningNotes: matrixSequence.warningNotes, + changeNotes: channelDoctorSequence.changeNotes, + warningNotes: channelDoctorSequence.warningNotes, }); - const staleMatrixCleanup = await cleanStaleMatrixPluginConfig(candidate); - if (staleMatrixCleanup.changes.length > 0) { - note(staleMatrixCleanup.changes.join("\n"), "Doctor changes"); + for (const staleCleanup of await collectChannelDoctorStaleConfigMutations(candidate)) { + if (staleCleanup.changes.length === 0) { + continue; + } + note(staleCleanup.changes.join("\n"), "Doctor changes"); ({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({ state: { cfg, candidate, pendingChanges, fixHints }, - mutation: staleMatrixCleanup, + mutation: staleCleanup, shouldRepair, - fixHint: `Run "${doctorFixCommand}" to remove stale Matrix plugin references.`, + fixHint: `Run "${doctorFixCommand}" to remove stale channel plugin references.`, })); } @@ -125,7 +142,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { } else { emitDoctorNotes({ note, - warningNotes: collectDoctorPreviewWarnings({ + warningNotes: await collectDoctorPreviewWarnings({ cfg: candidate, doctorFixCommand, }), @@ -133,8 +150,14 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { } const mutableAllowlistHits = scanMutableAllowlistEntries(candidate); - if (mutableAllowlistHits.length > 0) { - note(collectMutableAllowlistWarnings(mutableAllowlistHits).join("\n"), "Doctor warnings"); + const mutableAllowlistWarnings = [ + ...(mutableAllowlistHits.length > 0 + ? collectMutableAllowlistWarnings(mutableAllowlistHits) + : []), + ...(await collectChannelDoctorMutableAllowlistWarnings({ cfg: candidate })), + ]; + if (mutableAllowlistWarnings.length > 0) { + note(mutableAllowlistWarnings.join("\n"), "Doctor warnings"); } const unknownStep = applyUnknownConfigKeyStep({ diff --git a/src/commands/doctor-legacy-config.test.ts b/src/commands/doctor-legacy-config.test.ts index 74d86e89c45..64b80c4f47d 100644 --- a/src/commands/doctor-legacy-config.test.ts +++ b/src/commands/doctor-legacy-config.test.ts @@ -101,3 +101,31 @@ describe("normalizeCompatibilityConfigValues preview streaming aliases", () => { ]); }); }); + +describe("normalizeCompatibilityConfigValues browser compatibility aliases", () => { + it("removes legacy browser relay bind host and migrates extension profiles", () => { + const res = normalizeCompatibilityConfigValues({ + browser: { + relayBindHost: "127.0.0.1", + profiles: { + work: { + driver: "extension", + }, + keep: { + driver: "existing-session", + }, + }, + }, + } as never); + + expect( + (res.config.browser as { relayBindHost?: string } | undefined)?.relayBindHost, + ).toBeUndefined(); + expect(res.config.browser?.profiles?.work?.driver).toBe("existing-session"); + expect(res.config.browser?.profiles?.keep?.driver).toBe("existing-session"); + expect(res.changes).toEqual([ + "Removed browser.relayBindHost (legacy Chrome extension relay setting; host-local Chrome now uses Chrome MCP existing-session attach).", + 'Moved browser.profiles.work.driver "extension" → "existing-session" (Chrome MCP attach).', + ]); + }); +}); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index a1aeeaa9174..bdde8e01431 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -2,14 +2,6 @@ import { normalizeProviderId } from "../agents/model-selection.js"; import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js"; import type { OpenClawConfig } from "../config/config.js"; import { resolveNormalizedProviderModelMaxTokens } from "../config/defaults.js"; -import { - formatSlackStreamingBooleanMigrationMessage, - formatSlackStreamModeMigrationMessage, - resolveDiscordPreviewStreamMode, - resolveSlackNativeStreaming, - resolveSlackStreamingMode, - resolveTelegramPreviewStreamMode, -} from "../config/discord-preview-streaming.js"; import { migrateLegacyWebFetchConfig } from "../config/legacy-web-fetch.js"; import { migrateLegacyWebSearchConfig } from "../config/legacy-web-search.js"; import { migrateLegacyXSearchConfig } from "../config/legacy-x-search.js"; @@ -29,286 +21,6 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === "object" && !Array.isArray(value); - const normalizeDmAliases = (params: { - provider: "slack" | "discord"; - entry: Record; - pathPrefix: string; - }): { entry: Record; changed: boolean } => { - let changed = false; - let updated: Record = params.entry; - const rawDm = updated.dm; - const dm = isRecord(rawDm) ? structuredClone(rawDm) : null; - let dmChanged = false; - - const allowFromEqual = (a: unknown, b: unknown): boolean => { - if (!Array.isArray(a) || !Array.isArray(b)) { - return false; - } - const na = a.map((v) => String(v).trim()).filter(Boolean); - const nb = b.map((v) => String(v).trim()).filter(Boolean); - if (na.length !== nb.length) { - return false; - } - return na.every((v, i) => v === nb[i]); - }; - - const topDmPolicy = updated.dmPolicy; - const legacyDmPolicy = dm?.policy; - if (topDmPolicy === undefined && legacyDmPolicy !== undefined) { - updated = { ...updated, dmPolicy: legacyDmPolicy }; - changed = true; - if (dm) { - delete dm.policy; - dmChanged = true; - } - changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`); - } else if (topDmPolicy !== undefined && legacyDmPolicy !== undefined) { - if (topDmPolicy === legacyDmPolicy) { - if (dm) { - delete dm.policy; - dmChanged = true; - changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`); - } - } - } - - const topAllowFrom = updated.allowFrom; - const legacyAllowFrom = dm?.allowFrom; - if (topAllowFrom === undefined && legacyAllowFrom !== undefined) { - updated = { ...updated, allowFrom: legacyAllowFrom }; - changed = true; - if (dm) { - delete dm.allowFrom; - dmChanged = true; - } - changes.push(`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`); - } else if (topAllowFrom !== undefined && legacyAllowFrom !== undefined) { - if (allowFromEqual(topAllowFrom, legacyAllowFrom)) { - if (dm) { - delete dm.allowFrom; - dmChanged = true; - changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`); - } - } - } - - if (dm && isRecord(rawDm) && dmChanged) { - const keys = Object.keys(dm); - if (keys.length === 0) { - if (updated.dm !== undefined) { - const { dm: _ignored, ...rest } = updated; - updated = rest; - changed = true; - changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`); - } - } else { - updated = { ...updated, dm }; - changed = true; - } - } - - return { entry: updated, changed }; - }; - - const normalizePreviewStreamingAliases = (params: { - entry: Record; - pathPrefix: string; - resolveStreaming: (entry: Record) => string; - }): { entry: Record; changed: boolean } => { - let updated = params.entry; - const hadLegacyStreamMode = updated.streamMode !== undefined; - const beforeStreaming = updated.streaming; - const resolved = params.resolveStreaming(updated); - const shouldNormalize = - hadLegacyStreamMode || - typeof beforeStreaming === "boolean" || - (typeof beforeStreaming === "string" && beforeStreaming !== resolved); - if (!shouldNormalize) { - return { entry: updated, changed: false }; - } - - let changed = false; - if (beforeStreaming !== resolved) { - updated = { ...updated, streaming: resolved }; - changed = true; - } - if (hadLegacyStreamMode) { - const { streamMode: _ignored, ...rest } = updated; - updated = rest; - changed = true; - changes.push( - `Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`, - ); - } - if (typeof beforeStreaming === "boolean") { - changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`); - } else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) { - changes.push( - `Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`, - ); - } - if ( - params.pathPrefix.startsWith("channels.discord") && - resolved === "off" && - hadLegacyStreamMode - ) { - changes.push( - `${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`, - ); - } - - return { entry: updated, changed }; - }; - - const normalizeSlackStreamingAliases = (params: { - entry: Record; - pathPrefix: string; - }): { entry: Record; changed: boolean } => { - let updated = params.entry; - const hadLegacyStreamMode = updated.streamMode !== undefined; - const legacyStreaming = updated.streaming; - const beforeStreaming = updated.streaming; - const beforeNativeStreaming = updated.nativeStreaming; - const resolvedStreaming = resolveSlackStreamingMode(updated); - const resolvedNativeStreaming = resolveSlackNativeStreaming(updated); - const shouldNormalize = - hadLegacyStreamMode || - typeof legacyStreaming === "boolean" || - (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming); - if (!shouldNormalize) { - return { entry: updated, changed: false }; - } - - let changed = false; - if (beforeStreaming !== resolvedStreaming) { - updated = { ...updated, streaming: resolvedStreaming }; - changed = true; - } - if ( - typeof beforeNativeStreaming !== "boolean" || - beforeNativeStreaming !== resolvedNativeStreaming - ) { - updated = { ...updated, nativeStreaming: resolvedNativeStreaming }; - changed = true; - } - if (hadLegacyStreamMode) { - const { streamMode: _ignored, ...rest } = updated; - updated = rest; - changed = true; - changes.push(formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming)); - } - if (typeof legacyStreaming === "boolean") { - changes.push( - formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming), - ); - } else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) { - changes.push( - `Normalized ${params.pathPrefix}.streaming (${legacyStreaming}) → (${resolvedStreaming}).`, - ); - } - - return { entry: updated, changed }; - }; - - const normalizeStreamingAliasesForProvider = (params: { - provider: "telegram" | "slack" | "discord"; - entry: Record; - pathPrefix: string; - }): { entry: Record; changed: boolean } => { - if (params.provider === "telegram") { - return normalizePreviewStreamingAliases({ - entry: params.entry, - pathPrefix: params.pathPrefix, - resolveStreaming: resolveTelegramPreviewStreamMode, - }); - } - if (params.provider === "discord") { - return normalizePreviewStreamingAliases({ - entry: params.entry, - pathPrefix: params.pathPrefix, - resolveStreaming: resolveDiscordPreviewStreamMode, - }); - } - return normalizeSlackStreamingAliases({ - entry: params.entry, - pathPrefix: params.pathPrefix, - }); - }; - - const normalizeProvider = (provider: "telegram" | "slack" | "discord") => { - const channels = next.channels as Record | undefined; - const rawEntry = channels?.[provider]; - if (!isRecord(rawEntry)) { - return; - } - - let updated = rawEntry; - let changed = false; - if (provider !== "telegram") { - const base = normalizeDmAliases({ - provider, - entry: rawEntry, - pathPrefix: `channels.${provider}`, - }); - updated = base.entry; - changed = base.changed; - } - const providerStreaming = normalizeStreamingAliasesForProvider({ - provider, - entry: updated, - pathPrefix: `channels.${provider}`, - }); - updated = providerStreaming.entry; - changed = changed || providerStreaming.changed; - - const rawAccounts = updated.accounts; - if (isRecord(rawAccounts)) { - let accountsChanged = false; - const accounts = { ...rawAccounts }; - for (const [accountId, rawAccount] of Object.entries(rawAccounts)) { - if (!isRecord(rawAccount)) { - continue; - } - let accountEntry = rawAccount; - let accountChanged = false; - if (provider !== "telegram") { - const res = normalizeDmAliases({ - provider, - entry: rawAccount, - pathPrefix: `channels.${provider}.accounts.${accountId}`, - }); - accountEntry = res.entry; - accountChanged = res.changed; - } - const accountStreaming = normalizeStreamingAliasesForProvider({ - provider, - entry: accountEntry, - pathPrefix: `channels.${provider}.accounts.${accountId}`, - }); - accountEntry = accountStreaming.entry; - accountChanged = accountChanged || accountStreaming.changed; - if (accountChanged) { - accounts[accountId] = accountEntry; - accountsChanged = true; - } - } - if (accountsChanged) { - updated = { ...updated, accounts }; - changed = true; - } - } - - if (changed) { - next = { - ...next, - channels: { - ...next.channels, - [provider]: updated as unknown, - }, - }; - } - }; - const normalizeLegacyBrowserProfiles = () => { const rawBrowser = next.browser; if (!isRecord(rawBrowser)) { @@ -440,9 +152,6 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { }; }; - normalizeProvider("telegram"); - normalizeProvider("slack"); - normalizeProvider("discord"); seedMissingDefaultAccountsFromSingleAccountBase(); normalizeLegacyBrowserProfiles(); const webSearchMigration = migrateLegacyWebSearchConfig(next); @@ -914,42 +623,5 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): { normalizeLegacyMediaProviderOptions(); normalizeLegacyMistralModelMaxTokens(); - const legacyAckReaction = cfg.messages?.ackReaction?.trim(); - const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined; - if (legacyAckReaction && hasWhatsAppConfig) { - const hasWhatsAppAck = cfg.channels?.whatsapp?.ackReaction !== undefined; - if (!hasWhatsAppAck) { - const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions"; - let direct = true; - let group: "always" | "mentions" | "never" = "mentions"; - if (legacyScope === "all") { - direct = true; - group = "always"; - } else if (legacyScope === "direct") { - direct = true; - group = "never"; - } else if (legacyScope === "group-all") { - direct = false; - group = "always"; - } else if (legacyScope === "group-mentions") { - direct = false; - group = "mentions"; - } - next = { - ...next, - channels: { - ...next.channels, - whatsapp: { - ...next.channels?.whatsapp, - ackReaction: { emoji: legacyAckReaction, direct, group }, - }, - }, - }; - changes.push( - `Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: ${legacyScope}).`, - ); - } - } - return { config: next, changes }; } diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index ff21588e0c5..cd600785e1a 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -133,7 +133,9 @@ export const autoMigrateLegacyStateDir = vi.fn().mockResolvedValue({ changes: [], warnings: [], }) as unknown as MockFn; -export const runStartupMatrixMigration = vi.fn().mockResolvedValue(undefined) as unknown as MockFn; +export const runChannelPluginStartupMaintenance = vi + .fn() + .mockResolvedValue(undefined) as unknown as MockFn; function createLegacyStateMigrationDetectionResult(params?: { hasLegacySessions?: boolean; @@ -341,8 +343,8 @@ vi.mock("./doctor-state-migrations.js", () => ({ runLegacyStateMigrations, })); -vi.mock("../gateway/server-startup-matrix-migration.js", () => ({ - runStartupMatrixMigration, +vi.mock("../channels/plugins/lifecycle-startup.js", () => ({ + runChannelPluginStartupMaintenance, })); export function mockDoctorConfigSnapshot( @@ -447,7 +449,7 @@ beforeEach(() => { serviceUninstall.mockReset().mockResolvedValue(undefined); serviceReadCommand.mockReset().mockResolvedValue(null); callGateway.mockReset().mockRejectedValue(new Error("gateway closed")); - runStartupMatrixMigration.mockReset().mockResolvedValue(undefined); + runChannelPluginStartupMaintenance.mockReset().mockResolvedValue(undefined); originalIsTTY = process.stdin.isTTY; setStdinTty(true); diff --git a/src/commands/doctor.matrix-migration.test.ts b/src/commands/doctor.matrix-migration.test.ts index ae9524f51eb..c9c0e95cf41 100644 --- a/src/commands/doctor.matrix-migration.test.ts +++ b/src/commands/doctor.matrix-migration.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createDoctorRuntime, mockDoctorConfigSnapshot, - runStartupMatrixMigration, + runChannelPluginStartupMaintenance, } from "./doctor.e2e-harness.js"; import "./doctor.fast-path-mocks.js"; import { doctorCommand } from "./doctor.js"; @@ -41,8 +41,8 @@ describe("doctor command", () => { await doctorCommand(createDoctorRuntime(), { nonInteractive: true, repair: true }); - expect(runStartupMatrixMigration).toHaveBeenCalledTimes(1); - expect(runStartupMatrixMigration).toHaveBeenCalledWith( + expect(runChannelPluginStartupMaintenance).toHaveBeenCalledTimes(1); + expect(runChannelPluginStartupMaintenance).toHaveBeenCalledWith( expect.objectContaining({ cfg: expect.objectContaining({ channels: { diff --git a/src/commands/doctor/channel-capabilities.ts b/src/commands/doctor/channel-capabilities.ts index bb0b36dbdb4..e47c2eb8021 100644 --- a/src/commands/doctor/channel-capabilities.ts +++ b/src/commands/doctor/channel-capabilities.ts @@ -1,3 +1,4 @@ +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { AllowFromMode } from "./shared/allow-from-mode.js"; export type DoctorGroupModel = "sender" | "route" | "hybrid"; @@ -17,18 +18,6 @@ const DEFAULT_DOCTOR_CHANNEL_CAPABILITIES: DoctorChannelCapabilities = { }; const DOCTOR_CHANNEL_CAPABILITIES: Record = { - discord: { - dmAllowFromMode: "topOrNested", - groupModel: "route", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: false, - }, - googlechat: { - dmAllowFromMode: "nestedOnly", - groupModel: "route", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: false, - }, imessage: { dmAllowFromMode: "topOnly", groupModel: "sender", @@ -41,35 +30,25 @@ const DOCTOR_CHANNEL_CAPABILITIES: Record = { groupAllowFromFallbackToAllowFrom: false, warnOnEmptyGroupSenderAllowlist: true, }, - matrix: { - dmAllowFromMode: "nestedOnly", - groupModel: "sender", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: true, - }, - msteams: { - dmAllowFromMode: "topOnly", - groupModel: "hybrid", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: true, - }, - slack: { - dmAllowFromMode: "topOrNested", - groupModel: "route", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: false, - }, - zalouser: { - dmAllowFromMode: "topOnly", - groupModel: "hybrid", - groupAllowFromFallbackToAllowFrom: false, - warnOnEmptyGroupSenderAllowlist: false, - }, }; export function getDoctorChannelCapabilities(channelName?: string): DoctorChannelCapabilities { if (!channelName) { return DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; } + const pluginDoctor = getChannelPlugin(channelName)?.doctor; + if (pluginDoctor) { + return { + dmAllowFromMode: + pluginDoctor.dmAllowFromMode ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.dmAllowFromMode, + groupModel: pluginDoctor.groupModel ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.groupModel, + groupAllowFromFallbackToAllowFrom: + pluginDoctor.groupAllowFromFallbackToAllowFrom ?? + DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.groupAllowFromFallbackToAllowFrom, + warnOnEmptyGroupSenderAllowlist: + pluginDoctor.warnOnEmptyGroupSenderAllowlist ?? + DEFAULT_DOCTOR_CHANNEL_CAPABILITIES.warnOnEmptyGroupSenderAllowlist, + }; + } return DOCTOR_CHANNEL_CAPABILITIES[channelName] ?? DEFAULT_DOCTOR_CHANNEL_CAPABILITIES; } diff --git a/src/commands/doctor/providers/discord.test.ts b/src/commands/doctor/providers/discord.test.ts deleted file mode 100644 index 9e5034b3f67..00000000000 --- a/src/commands/doctor/providers/discord.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { - collectDiscordNumericIdWarnings, - maybeRepairDiscordNumericIds, - scanDiscordNumericIdEntries, -} from "./discord.js"; - -describe("doctor discord provider repairs", () => { - it("finds numeric id entries across discord scopes", () => { - const cfg = { - channels: { - discord: { - allowFrom: [123], - dm: { allowFrom: ["ok"], groupChannels: [456] }, - execApprovals: { approvers: [789] }, - guilds: { - main: { - users: [111], - roles: [222], - channels: { - general: { - users: [333], - roles: [444], - }, - }, - }, - }, - }, - }, - } as unknown as OpenClawConfig; - - const hits = scanDiscordNumericIdEntries(cfg); - - expect(hits.map((hit) => hit.path)).toEqual([ - "channels.discord.allowFrom[0]", - "channels.discord.dm.groupChannels[0]", - "channels.discord.execApprovals.approvers[0]", - "channels.discord.guilds.main.users[0]", - "channels.discord.guilds.main.roles[0]", - "channels.discord.guilds.main.channels.general.users[0]", - "channels.discord.guilds.main.channels.general.roles[0]", - ]); - expect(hits.every((hit) => hit.safe)).toBe(true); - }); - - it("marks unsafe numeric ids as not safe", () => { - const cfg = { - channels: { - discord: { - allowFrom: [106232522769186816, -1, 123.45, 42], - }, - }, - } as unknown as OpenClawConfig; - - const hits = scanDiscordNumericIdEntries(cfg); - - expect(hits).toEqual([ - { path: "channels.discord.allowFrom[0]", entry: 106232522769186816, safe: false }, - { path: "channels.discord.allowFrom[1]", entry: -1, safe: false }, - { path: "channels.discord.allowFrom[2]", entry: 123.45, safe: false }, - { path: "channels.discord.allowFrom[3]", entry: 42, safe: true }, - ]); - }); - - it("repairs numeric discord ids into strings", () => { - const cfg = { - channels: { - discord: { - allowFrom: [123], - accounts: { - work: { - allowFrom: [234], - dm: { allowFrom: [345], groupChannels: [456] }, - execApprovals: { approvers: [456] }, - guilds: { - ops: { - users: [567], - roles: [678], - channels: { - alerts: { - users: [789], - roles: [890], - }, - }, - }, - }, - }, - }, - guilds: { - main: { - users: [111], - roles: [222], - channels: { - general: { - users: [333], - roles: [444], - }, - }, - }, - }, - }, - }, - } as unknown as OpenClawConfig; - - const result = maybeRepairDiscordNumericIds(cfg); - - expect(result.changes).toEqual( - expect.arrayContaining([ - expect.stringContaining("channels.discord.allowFrom: converted 1 numeric entry to strings"), - expect.stringContaining( - "channels.discord.accounts.work.allowFrom: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.accounts.work.dm.allowFrom: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.accounts.work.dm.groupChannels: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.accounts.work.execApprovals.approvers: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.accounts.work.guilds.ops.users: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.accounts.work.guilds.ops.roles: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.accounts.work.guilds.ops.channels.alerts.users: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.accounts.work.guilds.ops.channels.alerts.roles: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.guilds.main.users: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.guilds.main.roles: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.guilds.main.channels.general.users: converted 1 numeric entry to strings", - ), - expect.stringContaining( - "channels.discord.guilds.main.channels.general.roles: converted 1 numeric entry to strings", - ), - ]), - ); - expect(result.config.channels?.discord?.allowFrom).toEqual(["123"]); - expect(result.config.channels?.discord?.guilds?.main?.users).toEqual(["111"]); - expect(result.config.channels?.discord?.guilds?.main?.roles).toEqual(["222"]); - expect(result.config.channels?.discord?.guilds?.main?.channels?.general?.users).toEqual([ - "333", - ]); - expect(result.config.channels?.discord?.guilds?.main?.channels?.general?.roles).toEqual([ - "444", - ]); - expect(result.config.channels?.discord?.accounts?.work?.allowFrom).toEqual(["234"]); - expect(result.config.channels?.discord?.accounts?.work?.dm?.allowFrom).toEqual(["345"]); - expect(result.config.channels?.discord?.accounts?.work?.dm?.groupChannels).toEqual(["456"]); - expect(result.config.channels?.discord?.accounts?.work?.execApprovals?.approvers).toEqual([ - "456", - ]); - expect(result.config.channels?.discord?.accounts?.work?.guilds?.ops?.users).toEqual(["567"]); - expect(result.config.channels?.discord?.accounts?.work?.guilds?.ops?.roles).toEqual(["678"]); - expect( - result.config.channels?.discord?.accounts?.work?.guilds?.ops?.channels?.alerts?.users, - ).toEqual(["789"]); - expect( - result.config.channels?.discord?.accounts?.work?.guilds?.ops?.channels?.alerts?.roles, - ).toEqual(["890"]); - }); - - it("skips entire list when it contains unsafe numeric ids", () => { - const cfg = { - channels: { - discord: { - allowFrom: [42, 106232522769186816, -1, 123.45], - dm: { allowFrom: [99] }, - }, - }, - } as unknown as OpenClawConfig; - - const result = maybeRepairDiscordNumericIds(cfg); - - expect(result.changes).toEqual([ - expect.stringContaining( - "channels.discord.dm.allowFrom: converted 1 numeric entry to strings", - ), - ]); - expect(result.config.channels?.discord?.allowFrom).toEqual([ - 42, 106232522769186816, -1, 123.45, - ]); - expect(result.config.channels?.discord?.dm?.allowFrom).toEqual(["99"]); - }); - - it("returns repair warnings when unsafe numeric ids block doctor fix", () => { - const cfg = { - channels: { - discord: { - allowFrom: [106232522769186816], - }, - }, - } as unknown as OpenClawConfig; - - const result = maybeRepairDiscordNumericIds(cfg, { - doctorFixCommand: "openclaw doctor --fix", - }); - - expect(result.changes).toEqual([]); - expect(result.warnings).toEqual([ - expect.stringContaining("could not be auto-repaired"), - expect.stringContaining('rerun "openclaw doctor --fix"'), - ]); - }); - - it("formats numeric id warnings for safe entries", () => { - const warnings = collectDiscordNumericIdWarnings({ - hits: [{ path: "channels.discord.allowFrom[0]", entry: 123, safe: true }], - doctorFixCommand: "openclaw doctor --fix", - }); - - expect(warnings).toEqual([ - expect.stringContaining("Discord allowlists contain 1 numeric entry"), - expect.stringContaining('run "openclaw doctor --fix"'), - ]); - }); - - it("formats numeric id warnings for unsafe entries", () => { - const warnings = collectDiscordNumericIdWarnings({ - hits: [{ path: "channels.discord.allowFrom[0]", entry: 106232522769186816, safe: false }], - doctorFixCommand: "openclaw doctor --fix", - }); - - expect(warnings).toEqual([ - expect.stringContaining("cannot be auto-repaired"), - expect.stringContaining("manually quote the original values"), - ]); - }); - - it("formats warnings for mixed safe and unsafe entries", () => { - const warnings = collectDiscordNumericIdWarnings({ - hits: [ - { path: "channels.discord.allowFrom[0]", entry: 123, safe: true }, - { path: "channels.discord.dm.allowFrom[0]", entry: 456, safe: true }, - { path: "channels.discord.dm.allowFrom[1]", entry: 106232522769186816, safe: false }, - ], - doctorFixCommand: "openclaw doctor --fix", - }); - - expect(warnings).toHaveLength(4); - expect(warnings[0]).toContain("1 numeric entry"); - expect(warnings[1]).toContain('run "openclaw doctor --fix"'); - expect(warnings[2]).toContain("2 numeric entries in lists that cannot be auto-repaired"); - expect(warnings[2]).toContain("channels.discord.dm.allowFrom[0]"); - expect(warnings[3]).toContain('rerun "openclaw doctor --fix"'); - }); -}); diff --git a/src/commands/doctor/providers/discord.ts b/src/commands/doctor/providers/discord.ts deleted file mode 100644 index 8d4ffaf6dd3..00000000000 --- a/src/commands/doctor/providers/discord.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { OpenClawConfig } from "../../../config/config.js"; -import { sanitizeForLog } from "../../../terminal/ansi.js"; -import { asObjectRecord } from "../shared/object.js"; -import type { DoctorAccountRecord } from "../types.js"; - -type DiscordNumericIdHit = { path: string; entry: number; safe: boolean }; - -type DiscordIdListRef = { - pathLabel: string; - holder: Record; - key: string; -}; - -export function collectDiscordAccountScopes( - cfg: OpenClawConfig, -): Array<{ prefix: string; account: DoctorAccountRecord }> { - const scopes: Array<{ prefix: string; account: DoctorAccountRecord }> = []; - const discord = asObjectRecord(cfg.channels?.discord); - if (!discord) { - return scopes; - } - - scopes.push({ prefix: "channels.discord", account: discord }); - const accounts = asObjectRecord(discord.accounts); - if (!accounts) { - return scopes; - } - for (const key of Object.keys(accounts)) { - const account = asObjectRecord(accounts[key]); - if (!account) { - continue; - } - scopes.push({ prefix: `channels.discord.accounts.${key}`, account }); - } - - return scopes; -} - -export function collectDiscordIdLists( - prefix: string, - account: DoctorAccountRecord, -): DiscordIdListRef[] { - const refs: DiscordIdListRef[] = [ - { pathLabel: `${prefix}.allowFrom`, holder: account, key: "allowFrom" }, - ]; - const dm = asObjectRecord(account.dm); - if (dm) { - refs.push({ pathLabel: `${prefix}.dm.allowFrom`, holder: dm, key: "allowFrom" }); - refs.push({ pathLabel: `${prefix}.dm.groupChannels`, holder: dm, key: "groupChannels" }); - } - const execApprovals = asObjectRecord(account.execApprovals); - if (execApprovals) { - refs.push({ - pathLabel: `${prefix}.execApprovals.approvers`, - holder: execApprovals, - key: "approvers", - }); - } - const guilds = asObjectRecord(account.guilds); - if (!guilds) { - return refs; - } - - for (const guildId of Object.keys(guilds)) { - const guild = asObjectRecord(guilds[guildId]); - if (!guild) { - continue; - } - refs.push({ pathLabel: `${prefix}.guilds.${guildId}.users`, holder: guild, key: "users" }); - refs.push({ pathLabel: `${prefix}.guilds.${guildId}.roles`, holder: guild, key: "roles" }); - const channels = asObjectRecord(guild.channels); - if (!channels) { - continue; - } - for (const channelId of Object.keys(channels)) { - const channel = asObjectRecord(channels[channelId]); - if (!channel) { - continue; - } - refs.push({ - pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.users`, - holder: channel, - key: "users", - }); - refs.push({ - pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.roles`, - holder: channel, - key: "roles", - }); - } - } - return refs; -} - -export function scanDiscordNumericIdEntries(cfg: OpenClawConfig): DiscordNumericIdHit[] { - const hits: DiscordNumericIdHit[] = []; - const scanList = (pathLabel: string, list: unknown) => { - if (!Array.isArray(list)) { - return; - } - for (const [index, entry] of list.entries()) { - if (typeof entry !== "number") { - continue; - } - hits.push({ - path: `${pathLabel}[${index}]`, - entry, - safe: Number.isSafeInteger(entry) && entry >= 0, - }); - } - }; - - for (const scope of collectDiscordAccountScopes(cfg)) { - for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) { - scanList(ref.pathLabel, ref.holder[ref.key]); - } - } - - return hits; -} - -export function collectDiscordNumericIdWarnings(params: { - hits: DiscordNumericIdHit[]; - doctorFixCommand: string; -}): string[] { - if (params.hits.length === 0) { - return []; - } - const lines: string[] = []; - const hitsByListPath = new Map(); - for (const hit of params.hits) { - const listPath = hit.path.replace(/\[\d+\]$/, ""); - const existing = hitsByListPath.get(listPath); - if (existing) { - existing.push(hit); - continue; - } - hitsByListPath.set(listPath, [hit]); - } - - const repairableHits: DiscordNumericIdHit[] = []; - const blockedHits: DiscordNumericIdHit[] = []; - for (const hits of hitsByListPath.values()) { - if (hits.some((hit) => !hit.safe)) { - blockedHits.push(...hits); - continue; - } - repairableHits.push(...hits); - } - - if (repairableHits.length > 0) { - const sample = repairableHits[0]; - const samplePath = sanitizeForLog(sample.path); - const sampleEntry = sanitizeForLog(String(sample.entry)); - lines.push( - `- Discord allowlists contain ${repairableHits.length} numeric ${repairableHits.length === 1 ? "entry" : "entries"} (e.g. ${samplePath}=${sampleEntry}).`, - `- Discord IDs must be strings; run "${params.doctorFixCommand}" to convert numeric IDs to quoted strings.`, - ); - } - if (blockedHits.length > 0) { - const sample = blockedHits[0]; - const samplePath = sanitizeForLog(sample.path); - lines.push( - `- Discord allowlists contain ${blockedHits.length} numeric ${blockedHits.length === 1 ? "entry" : "entries"} in lists that cannot be auto-repaired (e.g. ${samplePath}).`, - `- These lists include invalid or precision-losing numeric IDs; manually quote the original values in your config file, then rerun "${params.doctorFixCommand}".`, - ); - } - return lines; -} - -function collectBlockedDiscordNumericIdRepairWarnings(params: { - hits: DiscordNumericIdHit[]; - doctorFixCommand: string; -}): string[] { - const hitsByListPath = new Map(); - for (const hit of params.hits) { - const listPath = hit.path.replace(/\[\d+\]$/, ""); - const existing = hitsByListPath.get(listPath); - if (existing) { - existing.push(hit); - continue; - } - hitsByListPath.set(listPath, [hit]); - } - - const blockedHits: DiscordNumericIdHit[] = []; - for (const hits of hitsByListPath.values()) { - if (hits.some((hit) => !hit.safe)) { - blockedHits.push(...hits); - } - } - if (blockedHits.length === 0) { - return []; - } - - const sample = blockedHits[0]; - const samplePath = sanitizeForLog(sample.path); - return [ - `- Discord allowlists contain ${blockedHits.length} numeric ${blockedHits.length === 1 ? "entry" : "entries"} in lists that could not be auto-repaired (e.g. ${samplePath}).`, - `- These lists include invalid or precision-losing numeric IDs; manually quote the original values in your config file, then rerun "${params.doctorFixCommand}".`, - ]; -} - -export function maybeRepairDiscordNumericIds( - cfg: OpenClawConfig, - params?: { doctorFixCommand?: string }, -): { - config: OpenClawConfig; - changes: string[]; - warnings?: string[]; -} { - const hits = scanDiscordNumericIdEntries(cfg); - if (hits.length === 0) { - return { config: cfg, changes: [] }; - } - - const next = structuredClone(cfg); - const changes: string[] = []; - - const repairList = (pathLabel: string, holder: Record, key: string) => { - const raw = holder[key]; - if (!Array.isArray(raw)) { - return; - } - const hasUnsafe = raw.some( - (entry) => typeof entry === "number" && (!Number.isSafeInteger(entry) || entry < 0), - ); - if (hasUnsafe) { - return; - } - let converted = 0; - const updated = raw.map((entry) => { - if (typeof entry === "number") { - converted += 1; - return String(entry); - } - return entry; - }); - if (converted === 0) { - return; - } - holder[key] = updated; - changes.push( - `- ${pathLabel}: converted ${converted} numeric ${converted === 1 ? "entry" : "entries"} to strings`, - ); - }; - - for (const scope of collectDiscordAccountScopes(next)) { - for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) { - repairList(ref.pathLabel, ref.holder, ref.key); - } - } - - const warnings = - params?.doctorFixCommand === undefined - ? [] - : collectBlockedDiscordNumericIdRepairWarnings({ - hits, - doctorFixCommand: params.doctorFixCommand, - }); - - if (changes.length === 0 && warnings.length === 0) { - return { config: cfg, changes: [] }; - } - return { config: next, changes, warnings }; -} diff --git a/src/commands/doctor/providers/matrix.test.ts b/src/commands/doctor/providers/matrix.test.ts deleted file mode 100644 index ab859d065b3..00000000000 --- a/src/commands/doctor/providers/matrix.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - applyMatrixDoctorRepair, - cleanStaleMatrixPluginConfig, - collectMatrixInstallPathWarnings, - formatMatrixLegacyCryptoPreview, - formatMatrixLegacyStatePreview, - runMatrixDoctorSequence, -} from "./matrix.js"; - -vi.mock("../../../infra/matrix-migration-snapshot.js", () => ({ - hasActionableMatrixMigration: vi.fn(() => false), - hasPendingMatrixMigration: vi.fn(() => false), - maybeCreateMatrixMigrationSnapshot: vi.fn(), -})); - -vi.mock("../../../infra/matrix-legacy-state.js", async () => { - const actual = await vi.importActual( - "../../../infra/matrix-legacy-state.js", - ); - return { - ...actual, - autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })), - }; -}); - -vi.mock("../../../infra/matrix-legacy-crypto.js", async () => { - const actual = await vi.importActual( - "../../../infra/matrix-legacy-crypto.js", - ); - return { - ...actual, - autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })), - }; -}); - -describe("doctor matrix provider helpers", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("formats the legacy state preview", () => { - const preview = formatMatrixLegacyStatePreview({ - accountId: "default", - legacyStoragePath: "/tmp/legacy-sync.json", - targetStoragePath: "/tmp/new-sync.json", - legacyCryptoPath: "/tmp/legacy-crypto.json", - targetCryptoPath: "/tmp/new-crypto.json", - selectionNote: "Picked the newest account.", - targetRootDir: "/tmp/account-root", - }); - - expect(preview).toContain("Matrix plugin upgraded in place."); - expect(preview).toContain("/tmp/legacy-sync.json -> /tmp/new-sync.json"); - expect(preview).toContain("Picked the newest account."); - }); - - it("formats encrypted-state migration previews", () => { - const previews = formatMatrixLegacyCryptoPreview({ - warnings: ["matrix warning"], - plans: [ - { - accountId: "default", - rootDir: "/tmp/account-root", - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "tok-123", - deviceId: "DEVICE123", - legacyCryptoPath: "/tmp/legacy-crypto.json", - recoveryKeyPath: "/tmp/recovery-key.txt", - statePath: "/tmp/state.json", - }, - ], - }); - - expect(previews[0]).toBe("- matrix warning"); - expect(previews[1]).toContain( - 'Matrix encrypted-state migration is pending for account "default".', - ); - expect(previews[1]).toContain("/tmp/recovery-key.txt"); - }); - - it("warns on stale custom Matrix plugin paths", async () => { - const missingPath = path.join(tmpdir(), "openclaw-matrix-missing-provider-test"); - await fs.rm(missingPath, { recursive: true, force: true }); - - const warnings = await collectMatrixInstallPathWarnings({ - plugins: { - installs: { - matrix: { - source: "path", - sourcePath: missingPath, - installPath: missingPath, - }, - }, - }, - }); - - expect(warnings[0]).toContain("custom path that no longer exists"); - expect(warnings[0]).toContain(missingPath); - expect(warnings[1]).toContain("openclaw plugins install @openclaw/matrix"); - expect(warnings[2]).toContain("openclaw plugins install "); - expect(warnings[2]).toContain(path.join("extensions", "matrix")); - }); - - it("summarizes matrix repair messaging", async () => { - const matrixSnapshotModule = await import("../../../infra/matrix-migration-snapshot.js"); - const matrixStateModule = await import("../../../infra/matrix-legacy-state.js"); - const matrixCryptoModule = await import("../../../infra/matrix-legacy-crypto.js"); - - vi.mocked(matrixSnapshotModule.hasActionableMatrixMigration).mockReturnValue(true); - vi.mocked(matrixSnapshotModule.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({ - archivePath: "/tmp/matrix-backup.tgz", - created: true, - markerPath: "/tmp/marker.json", - }); - vi.mocked(matrixStateModule.autoMigrateLegacyMatrixState).mockResolvedValue({ - migrated: true, - changes: ["Migrated legacy sync state"], - warnings: [], - }); - vi.mocked(matrixCryptoModule.autoPrepareLegacyMatrixCrypto).mockResolvedValue({ - migrated: true, - changes: ["Prepared recovery key export"], - warnings: [], - }); - - const result = await applyMatrixDoctorRepair({ - cfg: {}, - env: process.env, - }); - - expect(result.changes).toEqual([ - expect.stringContaining("Matrix migration snapshot created"), - expect.stringContaining("Matrix plugin upgraded in place."), - expect.stringContaining("Matrix encrypted-state migration prepared."), - ]); - expect(result.warnings).toEqual([]); - }); - - it("removes stale Matrix plugin config when install path is missing", async () => { - const missingPath = path.join(tmpdir(), "openclaw-matrix-stale-cleanup-test-" + Date.now()); - await fs.rm(missingPath, { recursive: true, force: true }); - - const cfg = { - plugins: { - installs: { - matrix: { - source: "path" as const, - sourcePath: missingPath, - installPath: missingPath, - }, - }, - load: { - paths: [missingPath, "/other/path"], - }, - allow: ["matrix", "other-plugin"], - }, - }; - - const result = await cleanStaleMatrixPluginConfig(cfg); - - expect(result.changes).toHaveLength(1); - expect(result.changes[0]).toContain("Removed stale Matrix plugin references"); - expect(result.changes[0]).toContain("install record"); - expect(result.changes[0]).toContain("load path"); - expect(result.changes[0]).toContain(missingPath); - // Config should have stale refs removed - expect(result.config.plugins?.installs?.matrix).toBeUndefined(); - expect(result.config.plugins?.load?.paths).toEqual(["/other/path"]); - // Allowlist should have matrix removed but keep other entries - expect(result.config.plugins?.allow).toEqual(["other-plugin"]); - }); - - it("returns no changes when Matrix install path exists", async () => { - const existingPath = tmpdir(); - const cfg = { - plugins: { - installs: { - matrix: { - source: "path" as const, - sourcePath: existingPath, - installPath: existingPath, - }, - }, - }, - }; - - const result = await cleanStaleMatrixPluginConfig(cfg); - expect(result.changes).toHaveLength(0); - expect(result.config).toBe(cfg); - }); - - it("returns no changes when no Matrix install record exists", async () => { - const result = await cleanStaleMatrixPluginConfig({}); - expect(result.changes).toHaveLength(0); - }); - - it("collects matrix preview and install warnings through the provider sequence", async () => { - const matrixStateModule = await import("../../../infra/matrix-legacy-state.js"); - const matrixCryptoModule = await import("../../../infra/matrix-legacy-crypto.js"); - - const stateSpy = vi.spyOn(matrixStateModule, "detectLegacyMatrixState").mockReturnValue({ - accountId: "default", - legacyStoragePath: "/tmp/legacy-sync.json", - targetStoragePath: "/tmp/new-sync.json", - legacyCryptoPath: "/tmp/legacy-crypto.json", - targetCryptoPath: "/tmp/new-crypto.json", - selectionNote: "Picked the newest account.", - targetRootDir: "/tmp/account-root", - }); - const cryptoSpy = vi.spyOn(matrixCryptoModule, "detectLegacyMatrixCrypto").mockReturnValue({ - warnings: ["matrix warning"], - plans: [], - }); - - try { - const result = await runMatrixDoctorSequence({ - cfg: { - channels: { - matrix: { - homeserver: "https://matrix.example.org", - accessToken: "tok-123", - }, - }, - }, - env: process.env, - shouldRepair: false, - }); - - expect(result.changeNotes).toEqual([]); - expect(result.warningNotes).toEqual([ - expect.stringContaining("Matrix plugin upgraded in place."), - "- matrix warning", - ]); - } finally { - stateSpy.mockRestore(); - cryptoSpy.mockRestore(); - } - }); - - it("skips Matrix migration probes for unrelated configs", async () => { - const matrixStateModule = await import("../../../infra/matrix-legacy-state.js"); - const matrixCryptoModule = await import("../../../infra/matrix-legacy-crypto.js"); - - const stateSpy = vi.spyOn(matrixStateModule, "detectLegacyMatrixState"); - const cryptoSpy = vi.spyOn(matrixCryptoModule, "detectLegacyMatrixCrypto"); - - try { - const result = await runMatrixDoctorSequence({ - cfg: { - gateway: { auth: { mode: "token", token: "123" } }, - agents: { list: [{ id: "pi" }] }, - }, - env: {}, - shouldRepair: false, - }); - - expect(result).toEqual({ changeNotes: [], warningNotes: [] }); - expect(stateSpy).not.toHaveBeenCalled(); - expect(cryptoSpy).not.toHaveBeenCalled(); - } finally { - stateSpy.mockRestore(); - cryptoSpy.mockRestore(); - } - }); -}); diff --git a/src/commands/doctor/providers/telegram.test.ts b/src/commands/doctor/providers/telegram.test.ts deleted file mode 100644 index 4bef704a797..00000000000 --- a/src/commands/doctor/providers/telegram.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; - -const resolveCommandSecretRefsViaGatewayMock = vi.hoisted(() => vi.fn()); -const listTelegramAccountIdsMock = vi.hoisted(() => vi.fn()); -const inspectTelegramAccountMock = vi.hoisted(() => vi.fn()); -const telegramResolverMock = vi.hoisted(() => vi.fn()); -const getChannelPluginMock = vi.hoisted(() => vi.fn()); - -vi.mock("../../../cli/command-secret-gateway.js", () => ({ - resolveCommandSecretRefsViaGateway: resolveCommandSecretRefsViaGatewayMock, -})); - -vi.mock("../../../channels/read-only-account-inspect.telegram.js", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("../../../channels/read-only-account-inspect.telegram.js") - >(); - return { - ...actual, - listTelegramAccountIds: listTelegramAccountIdsMock, - inspectTelegramAccount: inspectTelegramAccountMock, - }; -}); - -vi.mock("../../../channels/plugins/registry.js", () => ({ - getChannelPlugin: getChannelPluginMock, -})); - -type TelegramDoctorModule = typeof import("./telegram.js"); - -let telegramDoctorModule: Promise | undefined; - -async function loadTelegramDoctorModule(): Promise { - telegramDoctorModule ??= import("./telegram.js"); - return await telegramDoctorModule; -} - -describe("doctor telegram provider warnings", () => { - beforeEach(() => { - vi.resetModules(); - telegramDoctorModule = undefined; - resolveCommandSecretRefsViaGatewayMock.mockReset().mockImplementation(async ({ config }) => ({ - resolvedConfig: config, - diagnostics: [], - targetStatesByPath: {}, - hadUnresolvedTargets: false, - })); - listTelegramAccountIdsMock.mockReset().mockImplementation((cfg: OpenClawConfig) => { - const telegram = cfg.channels?.telegram; - const accountIds = Object.keys(telegram?.accounts ?? {}); - return accountIds.length > 0 ? ["default", ...accountIds] : ["default"]; - }); - inspectTelegramAccountMock - .mockReset() - .mockImplementation((_params: { cfg: OpenClawConfig; accountId: string }) => ({ - enabled: true, - token: "tok", - tokenSource: "config", - tokenStatus: "configured", - })); - telegramResolverMock.mockReset(); - getChannelPluginMock.mockReset().mockReturnValue({ - resolver: { - resolveTargets: telegramResolverMock, - }, - }); - }); - - it("shows first-run guidance when groups are not configured yet", async () => { - const { collectTelegramGroupPolicyWarnings } = await loadTelegramDoctorModule(); - const warnings = collectTelegramGroupPolicyWarnings({ - account: { - botToken: "123:abc", - groupPolicy: "allowlist", - }, - prefix: "channels.telegram", - dmPolicy: "pairing", - }); - - expect(warnings).toEqual([ - expect.stringContaining("channels.telegram: Telegram is in first-time setup mode."), - ]); - expect(warnings[0]).toContain("DMs use pairing mode"); - expect(warnings[0]).toContain("channels.telegram.groups"); - }); - - it("warns when configured groups still have no usable sender allowlist", async () => { - const { collectTelegramGroupPolicyWarnings } = await loadTelegramDoctorModule(); - const warnings = collectTelegramGroupPolicyWarnings({ - account: { - botToken: "123:abc", - groupPolicy: "allowlist", - groups: { - ops: { allow: true }, - }, - }, - prefix: "channels.telegram", - }); - - expect(warnings).toEqual([ - expect.stringContaining( - 'channels.telegram.groupPolicy is "allowlist" but groupAllowFrom (and allowFrom) is empty', - ), - ]); - }); - - it("stays quiet when allowFrom can satisfy group allowlist mode", async () => { - const { collectTelegramGroupPolicyWarnings } = await loadTelegramDoctorModule(); - const warnings = collectTelegramGroupPolicyWarnings({ - account: { - botToken: "123:abc", - groupPolicy: "allowlist", - groups: { - ops: { allow: true }, - }, - }, - prefix: "channels.telegram", - effectiveAllowFrom: ["123456"], - }); - - expect(warnings).toEqual([]); - }); - - it("returns extra empty-allowlist warnings only for telegram allowlist groups", async () => { - const { collectTelegramEmptyAllowlistExtraWarnings } = await loadTelegramDoctorModule(); - const warnings = collectTelegramEmptyAllowlistExtraWarnings({ - account: { - botToken: "123:abc", - groupPolicy: "allowlist", - groups: { - ops: { allow: true }, - }, - }, - channelName: "telegram", - prefix: "channels.telegram", - }); - - expect(warnings).toEqual([ - expect.stringContaining( - 'channels.telegram.groupPolicy is "allowlist" but groupAllowFrom (and allowFrom) is empty', - ), - ]); - expect( - collectTelegramEmptyAllowlistExtraWarnings({ - account: { groupPolicy: "allowlist" }, - channelName: "signal", - prefix: "channels.signal", - }), - ).toEqual([]); - }); - - it("finds non-numeric telegram allowFrom username entries across account scopes", async () => { - const { scanTelegramAllowFromUsernameEntries } = await loadTelegramDoctorModule(); - const hits = scanTelegramAllowFromUsernameEntries({ - channels: { - telegram: { - allowFrom: ["@top"], - groupAllowFrom: ["12345"], - accounts: { - work: { - allowFrom: ["tg:@work"], - groups: { - "-100123": { - allowFrom: ["topic-user"], - topics: { - "99": { - allowFrom: ["777", "@topic-user"], - }, - }, - }, - }, - }, - }, - }, - }, - }); - - expect(hits).toEqual([ - { path: "channels.telegram.allowFrom", entry: "@top" }, - { path: "channels.telegram.accounts.work.allowFrom", entry: "tg:@work" }, - { path: "channels.telegram.accounts.work.groups.-100123.allowFrom", entry: "topic-user" }, - { - path: "channels.telegram.accounts.work.groups.-100123.topics.99.allowFrom", - entry: "@topic-user", - }, - ]); - }); - - it("formats allowFrom username warnings", async () => { - const { collectTelegramAllowFromUsernameWarnings } = await loadTelegramDoctorModule(); - const warnings = collectTelegramAllowFromUsernameWarnings({ - hits: [{ path: "channels.telegram.allowFrom", entry: "@top" }], - doctorFixCommand: "openclaw doctor --fix", - }); - - expect(warnings).toEqual([ - expect.stringContaining("Telegram allowFrom contains 1 non-numeric entries"), - expect.stringContaining('Run "openclaw doctor --fix"'), - ]); - }); - - it("repairs Telegram @username allowFrom entries to numeric ids", async () => { - const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule(); - telegramResolverMock.mockImplementation(async ({ inputs }: { inputs: string[] }) => { - switch (inputs[0]?.toLowerCase()) { - case "@testuser": - return [{ input: inputs[0], resolved: true, id: "111" }]; - case "@groupuser": - return [{ input: inputs[0], resolved: true, id: "222" }]; - case "@topicuser": - return [{ input: inputs[0], resolved: true, id: "333" }]; - case "@accountuser": - return [{ input: inputs[0], resolved: true, id: "444" }]; - default: - return [{ input: inputs[0], resolved: false }]; - } - }); - - const result = await maybeRepairTelegramAllowFromUsernames({ - channels: { - telegram: { - botToken: "123:abc", - allowFrom: ["@testuser"], - groupAllowFrom: ["groupUser"], - groups: { - "-100123": { - allowFrom: ["tg:@topicUser"], - topics: { "99": { allowFrom: ["@accountUser"] } }, - }, - }, - accounts: { - alerts: { botToken: "456:def", allowFrom: ["@accountUser"] }, - }, - }, - }, - }); - - const cfg = result.config as { - channels: { - telegram: { - allowFrom?: string[]; - groupAllowFrom?: string[]; - groups: Record< - string, - { allowFrom: string[]; topics: Record } - >; - accounts: Record; - }; - }; - }; - expect(cfg.channels.telegram.allowFrom).toEqual(["111"]); - expect(cfg.channels.telegram.groupAllowFrom).toEqual(["222"]); - expect(cfg.channels.telegram.groups["-100123"].allowFrom).toEqual(["333"]); - expect(cfg.channels.telegram.groups["-100123"].topics["99"].allowFrom).toEqual(["444"]); - expect(cfg.channels.telegram.accounts.alerts.allowFrom).toEqual(["444"]); - }); - - it("sanitizes Telegram allowFrom repair change lines before logging", async () => { - const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule(); - telegramResolverMock.mockImplementation(async ({ inputs }: { inputs: string[] }) => { - if (inputs[0] === "@\u001b[31mtestuser") { - return [{ input: inputs[0], resolved: true, id: "12345" }]; - } - return [{ input: inputs[0], resolved: false }]; - }); - - const result = await maybeRepairTelegramAllowFromUsernames({ - channels: { - telegram: { - botToken: "123:abc", - allowFrom: ["@\u001b[31mtestuser"], - }, - }, - }); - - expect(result.config.channels?.telegram?.allowFrom).toEqual(["12345"]); - expect(result.changes.some((line) => line.includes("\u001b"))).toBe(false); - expect( - result.changes.some((line) => - line.includes("channels.telegram.allowFrom: resolved @testuser -> 12345"), - ), - ).toBe(true); - }); - - it("keeps Telegram allowFrom entries unchanged when configured credentials are unavailable", async () => { - const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule(); - inspectTelegramAccountMock.mockImplementation(() => ({ - enabled: true, - token: "", - tokenSource: "env", - tokenStatus: "configured_unavailable", - })); - - const result = await maybeRepairTelegramAllowFromUsernames({ - secrets: { - providers: { - default: { source: "env" }, - }, - }, - channels: { - telegram: { - botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" }, - allowFrom: ["@testuser"], - }, - }, - } as unknown as OpenClawConfig); - - const cfg = result.config as { - channels?: { - telegram?: { - allowFrom?: string[]; - }; - }; - }; - expect(cfg.channels?.telegram?.allowFrom).toEqual(["@testuser"]); - expect( - result.changes.some((line) => - line.includes("configured Telegram bot credentials are unavailable"), - ), - ).toBe(true); - expect(telegramResolverMock).not.toHaveBeenCalled(); - }); - - it("uses network settings for Telegram allowFrom repair but ignores apiRoot and proxy", async () => { - const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule(); - resolveCommandSecretRefsViaGatewayMock.mockResolvedValue({ - resolvedConfig: { - channels: { - telegram: { - accounts: { - work: { - botToken: "tok", - apiRoot: "https://custom.telegram.test/root/", - proxy: "http://127.0.0.1:8888", - network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, - allowFrom: ["@testuser"], - }, - }, - }, - }, - }, - diagnostics: [], - targetStatesByPath: {}, - hadUnresolvedTargets: false, - }); - listTelegramAccountIdsMock.mockImplementation(() => ["work"]); - telegramResolverMock.mockResolvedValue([{ input: "@testuser", resolved: true, id: "12345" }]); - - const result = await maybeRepairTelegramAllowFromUsernames({ - channels: { - telegram: { - accounts: { - work: { - botToken: "tok", - allowFrom: ["@testuser"], - }, - }, - }, - }, - }); - - const cfg = result.config as { - channels?: { - telegram?: { - accounts?: Record; - }; - }; - }; - expect(cfg.channels?.telegram?.accounts?.work?.allowFrom).toEqual(["12345"]); - expect(telegramResolverMock).toHaveBeenCalledWith({ - cfg: expect.any(Object), - accountId: "work", - inputs: ["@testuser"], - kind: "user", - runtime: expect.any(Object), - }); - }); -}); diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index f418e6b0d8f..3277e27ccb8 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -1,11 +1,10 @@ import { sanitizeForLog } from "../../terminal/ansi.js"; -import { maybeRepairDiscordNumericIds } from "./providers/discord.js"; -import { - collectTelegramEmptyAllowlistExtraWarnings, - maybeRepairTelegramAllowFromUsernames, -} from "./providers/telegram.js"; import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js"; import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js"; +import { + collectChannelDoctorEmptyAllowlistExtraWarnings, + collectChannelDoctorRepairMutations, +} from "./shared/channel-doctor.js"; import { applyDoctorConfigMutation, type DoctorConfigMutationState, @@ -47,12 +46,12 @@ export async function runDoctorRepairSequence(params: { } }; - applyMutation(await maybeRepairTelegramAllowFromUsernames(state.candidate)); - applyMutation( - maybeRepairDiscordNumericIds(state.candidate, { - doctorFixCommand: params.doctorFixCommand, - }), - ); + for (const mutation of await collectChannelDoctorRepairMutations({ + cfg: state.candidate, + doctorFixCommand: params.doctorFixCommand, + })) { + applyMutation(mutation); + } applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate)); applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, process.env)); applyMutation(maybeRepairStalePluginConfig(state.candidate, process.env)); @@ -60,7 +59,7 @@ export async function runDoctorRepairSequence(params: { const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, { doctorFixCommand: params.doctorFixCommand, - extraWarningsForAccount: collectTelegramEmptyAllowlistExtraWarnings, + extraWarningsForAccount: collectChannelDoctorEmptyAllowlistExtraWarnings, }); if (emptyAllowlistWarnings.length > 0) { warningNotes.push(sanitizeLines(emptyAllowlistWarnings)); diff --git a/src/commands/doctor/shared/channel-doctor.ts b/src/commands/doctor/shared/channel-doctor.ts new file mode 100644 index 00000000000..f562eafdbf3 --- /dev/null +++ b/src/commands/doctor/shared/channel-doctor.ts @@ -0,0 +1,144 @@ +import { listChannelPlugins } from "../../../channels/plugins/registry.js"; +import type { + ChannelDoctorAdapter, + ChannelDoctorConfigMutation, + ChannelDoctorEmptyAllowlistAccountContext, + ChannelDoctorSequenceResult, +} from "../../../channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../config/config.js"; + +type ChannelDoctorEntry = { + channelId: string; + doctor: ChannelDoctorAdapter; +}; + +function listChannelDoctorEntries(): ChannelDoctorEntry[] { + try { + return listChannelPlugins() + .flatMap((plugin) => (plugin.doctor ? [{ channelId: plugin.id, doctor: plugin.doctor }] : [])) + .filter((entry) => entry.doctor); + } catch { + return []; + } +} + +export async function runChannelDoctorConfigSequences(params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + shouldRepair: boolean; +}): Promise { + const changeNotes: string[] = []; + const warningNotes: string[] = []; + for (const entry of listChannelDoctorEntries()) { + const result = await entry.doctor.runConfigSequence?.(params); + if (!result) { + continue; + } + changeNotes.push(...result.changeNotes); + warningNotes.push(...result.warningNotes); + } + return { changeNotes, warningNotes }; +} + +export function collectChannelDoctorCompatibilityMutations( + cfg: OpenClawConfig, +): ChannelDoctorConfigMutation[] { + const mutations: ChannelDoctorConfigMutation[] = []; + let nextCfg = cfg; + for (const entry of listChannelDoctorEntries()) { + const mutation = entry.doctor.normalizeCompatibilityConfig?.({ cfg: nextCfg }); + if (!mutation || mutation.changes.length === 0) { + continue; + } + mutations.push(mutation); + nextCfg = mutation.config; + } + return mutations; +} + +export async function collectChannelDoctorStaleConfigMutations( + cfg: OpenClawConfig, +): Promise { + const mutations: ChannelDoctorConfigMutation[] = []; + let nextCfg = cfg; + for (const entry of listChannelDoctorEntries()) { + const mutation = await entry.doctor.cleanStaleConfig?.({ cfg: nextCfg }); + if (!mutation || mutation.changes.length === 0) { + continue; + } + mutations.push(mutation); + nextCfg = mutation.config; + } + return mutations; +} + +export async function collectChannelDoctorPreviewWarnings(params: { + cfg: OpenClawConfig; + doctorFixCommand: string; +}): Promise { + const warnings: string[] = []; + for (const entry of listChannelDoctorEntries()) { + const lines = await entry.doctor.collectPreviewWarnings?.(params); + if (lines?.length) { + warnings.push(...lines); + } + } + return warnings; +} + +export async function collectChannelDoctorMutableAllowlistWarnings(params: { + cfg: OpenClawConfig; +}): Promise { + const warnings: string[] = []; + for (const entry of listChannelDoctorEntries()) { + const lines = await entry.doctor.collectMutableAllowlistWarnings?.(params); + if (lines?.length) { + warnings.push(...lines); + } + } + return warnings; +} + +export async function collectChannelDoctorRepairMutations(params: { + cfg: OpenClawConfig; + doctorFixCommand: string; +}): Promise { + const mutations: ChannelDoctorConfigMutation[] = []; + let nextCfg = params.cfg; + for (const entry of listChannelDoctorEntries()) { + const mutation = await entry.doctor.repairConfig?.({ + cfg: nextCfg, + doctorFixCommand: params.doctorFixCommand, + }); + if (!mutation || mutation.changes.length === 0) { + if (mutation?.warnings?.length) { + mutations.push({ config: nextCfg, changes: [], warnings: mutation.warnings }); + } + continue; + } + mutations.push(mutation); + nextCfg = mutation.config; + } + return mutations; +} + +export function collectChannelDoctorEmptyAllowlistExtraWarnings( + params: ChannelDoctorEmptyAllowlistAccountContext, +): string[] { + const warnings: string[] = []; + for (const entry of listChannelDoctorEntries()) { + const lines = entry.doctor.collectEmptyAllowlistExtraWarnings?.(params); + if (lines?.length) { + warnings.push(...lines); + } + } + return warnings; +} + +export function shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning( + params: ChannelDoctorEmptyAllowlistAccountContext, +): boolean { + return listChannelDoctorEntries().some( + (entry) => entry.doctor.shouldSkipDefaultEmptyGroupAllowlistWarning?.(params) === true, + ); +} diff --git a/src/commands/doctor/shared/empty-allowlist-policy.ts b/src/commands/doctor/shared/empty-allowlist-policy.ts index e98a63113fc..b4f678fd0cf 100644 --- a/src/commands/doctor/shared/empty-allowlist-policy.ts +++ b/src/commands/doctor/shared/empty-allowlist-policy.ts @@ -1,6 +1,7 @@ import { getDoctorChannelCapabilities } from "../channel-capabilities.js"; import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js"; import { hasAllowFromEntries } from "./allowlist.js"; +import { shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning } from "./channel-doctor.js"; type CollectEmptyAllowlistPolicyWarningsParams = { account: DoctorAccountRecord; @@ -61,7 +62,17 @@ export function collectEmptyAllowlistPolicyWarningsForAccount( return warnings; } - if (params.channelName === "telegram") { + if ( + params.channelName && + shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning({ + account: params.account, + channelName: params.channelName, + dmPolicy, + effectiveAllowFrom, + parent: params.parent, + prefix: params.prefix, + }) + ) { return warnings; } diff --git a/src/commands/doctor/shared/mutable-allowlist.test.ts b/src/commands/doctor/shared/mutable-allowlist.test.ts index 3c5f2b1bb8a..308a51efab3 100644 --- a/src/commands/doctor/shared/mutable-allowlist.test.ts +++ b/src/commands/doctor/shared/mutable-allowlist.test.ts @@ -5,19 +5,9 @@ import { } from "./mutable-allowlist.js"; describe("doctor mutable allowlist scanner", () => { - it("finds mutable discord, irc, and zalouser entries when dangerous matching is disabled", () => { + it("finds mutable built-in allowlist entries when dangerous matching is disabled", () => { const hits = scanMutableAllowlistEntries({ channels: { - discord: { - allowFrom: ["alice"], - guilds: { - ops: { - users: ["bob"], - roles: [], - channels: {}, - }, - }, - }, irc: { allowFrom: ["charlie"], groups: { @@ -26,26 +16,14 @@ describe("doctor mutable allowlist scanner", () => { }, }, }, - zalouser: { - groups: { - "Ops Room": { allow: true }, - }, + googlechat: { + groupAllowFrom: ["engineering@example.com"], }, }, }); expect(hits).toEqual( expect.arrayContaining([ - expect.objectContaining({ - channel: "discord", - path: "channels.discord.allowFrom", - entry: "alice", - }), - expect.objectContaining({ - channel: "discord", - path: "channels.discord.guilds.ops.users", - entry: "bob", - }), expect.objectContaining({ channel: "irc", path: "channels.irc.allowFrom", @@ -57,9 +35,9 @@ describe("doctor mutable allowlist scanner", () => { entry: "dana", }), expect.objectContaining({ - channel: "zalouser", - path: "channels.zalouser.groups", - entry: "Ops Room", + channel: "googlechat", + path: "channels.googlechat.groupAllowFrom", + entry: "engineering@example.com", }), ]), ); @@ -68,9 +46,9 @@ describe("doctor mutable allowlist scanner", () => { it("skips scopes that explicitly allow dangerous name matching", () => { const hits = scanMutableAllowlistEntries({ channels: { - slack: { + googlechat: { dangerouslyAllowNameMatching: true, - allowFrom: ["alice"], + groupAllowFrom: ["engineering@example.com"], }, }, }); @@ -80,25 +58,25 @@ describe("doctor mutable allowlist scanner", () => { it("formats mutable allowlist warnings", () => { const warnings = collectMutableAllowlistWarnings([ - { - channel: "discord", - path: "channels.discord.allowFrom", - entry: "alice", - dangerousFlagPath: "channels.discord.dangerouslyAllowNameMatching", - }, { channel: "irc", path: "channels.irc.allowFrom", entry: "bob", dangerousFlagPath: "channels.irc.dangerouslyAllowNameMatching", }, + { + channel: "googlechat", + path: "channels.googlechat.groupAllowFrom", + entry: "engineering@example.com", + dangerousFlagPath: "channels.googlechat.dangerouslyAllowNameMatching", + }, ]); expect(warnings).toEqual( expect.arrayContaining([ - expect.stringContaining("mutable allowlist entries across discord, irc"), - expect.stringContaining("channels.discord.allowFrom: alice"), + expect.stringContaining("mutable allowlist entries across googlechat, irc"), expect.stringContaining("channels.irc.allowFrom: bob"), + expect.stringContaining("channels.googlechat.groupAllowFrom: engineering@example.com"), expect.stringContaining("Option A"), expect.stringContaining("Option B"), ]), diff --git a/src/commands/doctor/shared/mutable-allowlist.ts b/src/commands/doctor/shared/mutable-allowlist.ts index 74c4808e1dc..0a4930d210d 100644 --- a/src/commands/doctor/shared/mutable-allowlist.ts +++ b/src/commands/doctor/shared/mutable-allowlist.ts @@ -1,13 +1,10 @@ import type { OpenClawConfig } from "../../../config/config.js"; import { collectProviderDangerousNameMatchingScopes } from "../../../config/dangerous-name-matching.js"; import { - isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, isIrcMutableAllowEntry, isMSTeamsMutableAllowEntry, isMattermostMutableAllowEntry, - isSlackMutableAllowEntry, - isZalouserMutableGroupEntry, } from "../../../security/mutable-allowlist-detectors.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; import { asObjectRecord } from "./object.js"; @@ -50,110 +47,6 @@ function addMutableAllowlistHits(params: { export function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowlistHit[] { const hits: MutableAllowlistHit[] = []; - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "discord")) { - if (scope.dangerousNameMatchingEnabled) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.allowFrom`, - list: scope.account.allowFrom, - detector: isDiscordMutableAllowEntry, - channel: "discord", - dangerousFlagPath: scope.dangerousFlagPath, - }); - const dm = asObjectRecord(scope.account.dm); - if (dm) { - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.dm.allowFrom`, - list: dm.allowFrom, - detector: isDiscordMutableAllowEntry, - channel: "discord", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - const guilds = asObjectRecord(scope.account.guilds); - if (!guilds) { - continue; - } - for (const [guildId, guildRaw] of Object.entries(guilds)) { - const guild = asObjectRecord(guildRaw); - if (!guild) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.guilds.${guildId}.users`, - list: guild.users, - detector: isDiscordMutableAllowEntry, - channel: "discord", - dangerousFlagPath: scope.dangerousFlagPath, - }); - const channels = asObjectRecord(guild.channels); - if (!channels) { - continue; - } - for (const [channelId, channelRaw] of Object.entries(channels)) { - const channel = asObjectRecord(channelRaw); - if (!channel) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.guilds.${guildId}.channels.${channelId}.users`, - list: channel.users, - detector: isDiscordMutableAllowEntry, - channel: "discord", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - } - } - - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "slack")) { - if (scope.dangerousNameMatchingEnabled) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.allowFrom`, - list: scope.account.allowFrom, - detector: isSlackMutableAllowEntry, - channel: "slack", - dangerousFlagPath: scope.dangerousFlagPath, - }); - const dm = asObjectRecord(scope.account.dm); - if (dm) { - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.dm.allowFrom`, - list: dm.allowFrom, - detector: isSlackMutableAllowEntry, - channel: "slack", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - const channels = asObjectRecord(scope.account.channels); - if (!channels) { - continue; - } - for (const [channelKey, channelRaw] of Object.entries(channels)) { - const channel = asObjectRecord(channelRaw); - if (!channel) { - continue; - } - addMutableAllowlistHits({ - hits, - pathLabel: `${scope.prefix}.channels.${channelKey}.users`, - list: channel.users, - detector: isSlackMutableAllowEntry, - channel: "slack", - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - } - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "googlechat")) { if (scope.dangerousNameMatchingEnabled) { continue; @@ -281,27 +174,6 @@ export function scanMutableAllowlistEntries(cfg: OpenClawConfig): MutableAllowli } } - for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "zalouser")) { - if (scope.dangerousNameMatchingEnabled) { - continue; - } - const groups = asObjectRecord(scope.account.groups); - if (!groups) { - continue; - } - for (const entry of Object.keys(groups)) { - if (!isZalouserMutableGroupEntry(entry)) { - continue; - } - hits.push({ - channel: "zalouser", - path: `${scope.prefix}.groups`, - entry, - dangerousFlagPath: scope.dangerousFlagPath, - }); - } - } - return hits; } diff --git a/src/commands/doctor/shared/preview-warnings.test.ts b/src/commands/doctor/shared/preview-warnings.test.ts index d5822615e2c..d62e552f9e1 100644 --- a/src/commands/doctor/shared/preview-warnings.test.ts +++ b/src/commands/doctor/shared/preview-warnings.test.ts @@ -39,8 +39,8 @@ describe("doctor preview warnings", () => { vi.restoreAllMocks(); }); - it("collects provider and shared preview warnings", () => { - const warnings = collectDoctorPreviewWarnings({ + it("collects provider and shared preview warnings", async () => { + const warnings = await collectDoctorPreviewWarnings({ cfg: { channels: { telegram: { @@ -60,8 +60,8 @@ describe("doctor preview warnings", () => { ]); }); - it("sanitizes empty-allowlist warning paths before returning preview output", () => { - const warnings = collectDoctorPreviewWarnings({ + it("sanitizes empty-allowlist warning paths before returning preview output", async () => { + const warnings = await collectDoctorPreviewWarnings({ cfg: { channels: { signal: { @@ -83,8 +83,8 @@ describe("doctor preview warnings", () => { expect(warnings[0]).not.toContain("\r"); }); - it("includes stale plugin config warnings", () => { - const warnings = collectDoctorPreviewWarnings({ + it("includes stale plugin config warnings", async () => { + const warnings = await collectDoctorPreviewWarnings({ cfg: { plugins: { allow: ["acpx"], @@ -104,7 +104,7 @@ describe("doctor preview warnings", () => { expect(warnings[0]).not.toContain("Auto-removal is paused"); }); - it("includes bundled plugin load path migration warnings", () => { + it("includes bundled plugin load path migration warnings", async () => { const packageRoot = path.resolve("app-node-modules", "openclaw"); const legacyPath = path.join(packageRoot, "extensions", "feishu"); const bundledPath = path.join(packageRoot, "dist", "extensions", "feishu"); @@ -125,7 +125,7 @@ describe("doctor preview warnings", () => { ]), ); - const warnings = collectDoctorPreviewWarnings({ + const warnings = await collectDoctorPreviewWarnings({ cfg: { plugins: { load: { @@ -142,7 +142,7 @@ describe("doctor preview warnings", () => { expect(warnings[0]).toContain('Run "openclaw doctor --fix"'); }); - it("warns but skips auto-removal when plugin discovery has errors", () => { + it("warns but skips auto-removal when plugin discovery has errors", async () => { vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ plugins: [], diagnostics: [ @@ -150,7 +150,7 @@ describe("doctor preview warnings", () => { ], }); - const warnings = collectDoctorPreviewWarnings({ + const warnings = await collectDoctorPreviewWarnings({ cfg: { plugins: { allow: ["acpx"], @@ -169,13 +169,13 @@ describe("doctor preview warnings", () => { expect(warnings[0]).toContain('rerun "openclaw doctor --fix"'); }); - it("warns when a configured channel plugin is disabled explicitly", () => { + it("warns when a configured channel plugin is disabled explicitly", async () => { vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ plugins: [channelManifest("telegram", "telegram")], diagnostics: [], }); - const warnings = collectDoctorPreviewWarnings({ + const warnings = await collectDoctorPreviewWarnings({ cfg: { channels: { telegram: { @@ -202,13 +202,13 @@ describe("doctor preview warnings", () => { expect(warnings[0]).not.toContain("first-time setup mode"); }); - it("warns when channel plugins are blocked globally", () => { + it("warns when channel plugins are blocked globally", async () => { vi.spyOn(manifestRegistry, "loadPluginManifestRegistry").mockReturnValue({ plugins: [channelManifest("telegram", "telegram")], diagnostics: [], }); - const warnings = collectDoctorPreviewWarnings({ + const warnings = await collectDoctorPreviewWarnings({ cfg: { channels: { telegram: { diff --git a/src/commands/doctor/shared/preview-warnings.ts b/src/commands/doctor/shared/preview-warnings.ts index ce244b01a88..3f947a0757e 100644 --- a/src/commands/doctor/shared/preview-warnings.ts +++ b/src/commands/doctor/shared/preview-warnings.ts @@ -1,18 +1,13 @@ import type { OpenClawConfig } from "../../../config/config.js"; import { sanitizeForLog } from "../../../terminal/ansi.js"; -import { - collectDiscordNumericIdWarnings, - scanDiscordNumericIdEntries, -} from "../providers/discord.js"; -import { - collectTelegramAllowFromUsernameWarnings, - collectTelegramEmptyAllowlistExtraWarnings, - scanTelegramAllowFromUsernameEntries, -} from "../providers/telegram.js"; import { collectBundledPluginLoadPathWarnings, scanBundledPluginLoadPathMigrations, } from "./bundled-plugin-load-paths.js"; +import { + collectChannelDoctorEmptyAllowlistExtraWarnings, + collectChannelDoctorPreviewWarnings, +} from "./channel-doctor.js"; import { collectConfiguredChannelPluginBlockerWarnings, isWarningBlockedByChannelPlugin, @@ -39,10 +34,10 @@ import { scanStalePluginConfig, } from "./stale-plugin-config.js"; -export function collectDoctorPreviewWarnings(params: { +export async function collectDoctorPreviewWarnings(params: { cfg: OpenClawConfig; doctorFixCommand: string; -}): string[] { +}): Promise { const warnings: string[] = []; const channelPluginBlockerHits = scanConfiguredChannelPluginBlockers(params.cfg, process.env); @@ -52,24 +47,12 @@ export function collectDoctorPreviewWarnings(params: { ); } - const telegramHits = scanTelegramAllowFromUsernameEntries(params.cfg); - if (telegramHits.length > 0) { - warnings.push( - collectTelegramAllowFromUsernameWarnings({ - hits: telegramHits, - doctorFixCommand: params.doctorFixCommand, - }).join("\n"), - ); - } - - const discordHits = scanDiscordNumericIdEntries(params.cfg); - if (discordHits.length > 0) { - warnings.push( - collectDiscordNumericIdWarnings({ - hits: discordHits, - doctorFixCommand: params.doctorFixCommand, - }).join("\n"), - ); + const channelDoctorWarnings = await collectChannelDoctorPreviewWarnings({ + cfg: params.cfg, + doctorFixCommand: params.doctorFixCommand, + }); + if (channelDoctorWarnings.length > 0) { + warnings.push(...channelDoctorWarnings); } const allowFromScan = maybeRepairOpenPolicyAllowFrom(params.cfg); @@ -105,7 +88,7 @@ export function collectDoctorPreviewWarnings(params: { const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, { doctorFixCommand: params.doctorFixCommand, - extraWarningsForAccount: collectTelegramEmptyAllowlistExtraWarnings, + extraWarningsForAccount: collectChannelDoctorEmptyAllowlistExtraWarnings, }).filter((warning) => !isWarningBlockedByChannelPlugin(warning, channelPluginBlockerHits)); if (emptyAllowlistWarnings.length > 0) { warnings.push(emptyAllowlistWarnings.map((line) => sanitizeForLog(line)).join("\n")); diff --git a/src/config/commands.test.ts b/src/config/commands.test.ts index c96c8122ac4..2b9b9c432b1 100644 --- a/src/config/commands.test.ts +++ b/src/config/commands.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setDefaultChannelPluginRegistryForTests } from "../commands/channel-test-helpers.js"; import { isCommandFlagEnabled, isRestartEnabled, @@ -7,6 +8,10 @@ import { resolveNativeSkillsEnabled, } from "./commands.js"; +beforeEach(() => { + setDefaultChannelPluginRegistryForTests(); +}); + describe("resolveNativeSkillsEnabled", () => { it("uses provider defaults for auto", () => { expect( diff --git a/src/config/commands.ts b/src/config/commands.ts index 29992b3a1cd..e52f36b4044 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -1,3 +1,4 @@ +import { getChannelPlugin } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { normalizeChannelId } from "../channels/registry.js"; import { isPlainObject } from "../infra/plain-object.js"; @@ -7,18 +8,22 @@ export type CommandFlagKey = { [K in keyof CommandsConfig]-?: Exclude extends boolean ? K : never; }[keyof CommandsConfig]; -function resolveAutoDefault(providerId?: ChannelId): boolean { +function resolveAutoDefault( + providerId: ChannelId | undefined, + kind: "native" | "nativeSkills", +): boolean { const id = normalizeChannelId(providerId); if (!id) { return false; } - if (id === "discord" || id === "telegram") { - return true; - } - if (id === "slack") { + const plugin = getChannelPlugin(id); + if (!plugin) { return false; } - return false; + if (kind === "native") { + return plugin.commands?.nativeCommandsAutoEnabled === true; + } + return plugin.commands?.nativeSkillsAutoEnabled === true; } export function resolveNativeSkillsEnabled(params: { @@ -26,7 +31,7 @@ export function resolveNativeSkillsEnabled(params: { providerSetting?: NativeCommandsSetting; globalSetting?: NativeCommandsSetting; }): boolean { - return resolveNativeCommandSetting(params); + return resolveNativeCommandSetting({ ...params, kind: "nativeSkills" }); } export function resolveNativeCommandsEnabled(params: { @@ -34,15 +39,16 @@ export function resolveNativeCommandsEnabled(params: { providerSetting?: NativeCommandsSetting; globalSetting?: NativeCommandsSetting; }): boolean { - return resolveNativeCommandSetting(params); + return resolveNativeCommandSetting({ ...params, kind: "native" }); } function resolveNativeCommandSetting(params: { providerId: ChannelId; providerSetting?: NativeCommandsSetting; globalSetting?: NativeCommandsSetting; + kind?: "native" | "nativeSkills"; }): boolean { - const { providerId, providerSetting, globalSetting } = params; + const { providerId, providerSetting, globalSetting, kind = "native" } = params; const setting = providerSetting === undefined ? globalSetting : providerSetting; if (setting === true) { return true; @@ -50,7 +56,7 @@ function resolveNativeCommandSetting(params: { if (setting === false) { return false; } - return resolveAutoDefault(providerId); + return resolveAutoDefault(providerId, kind); } export function isNativeCommandsExplicitlyDisabled(params: { diff --git a/src/config/legacy.migrations.channels.ts b/src/config/legacy.migrations.channels.ts index 019769ef764..52d2d5decf5 100644 --- a/src/config/legacy.migrations.channels.ts +++ b/src/config/legacy.migrations.channels.ts @@ -61,6 +61,23 @@ function migrateThreadBindingsTtlHoursForPath(params: { return true; } +function hasLegacyThreadBindingTtlInAnyChannel(value: unknown): boolean { + const channels = getRecord(value); + if (!channels) { + return false; + } + return Object.values(channels).some((entry) => { + const channel = getRecord(entry); + if (!channel) { + return false; + } + return ( + hasLegacyThreadBindingTtl(channel.threadBindings) || + hasLegacyThreadBindingTtlInAccounts(channel.accounts) + ); + }); +} + function hasLegacyTelegramStreamingKeys(value: unknown): boolean { const entry = getRecord(value); if (!entry) { @@ -104,16 +121,10 @@ const THREAD_BINDING_RULES: LegacyConfigRule[] = [ match: (value) => hasLegacyThreadBindingTtl(value), }, { - path: ["channels", "discord", "threadBindings"], + path: ["channels"], message: - "channels.discord.threadBindings.ttlHours was renamed to channels.discord.threadBindings.idleHours (auto-migrated on load).", - match: (value) => hasLegacyThreadBindingTtl(value), - }, - { - path: ["channels", "discord", "accounts"], - message: - "channels.discord.accounts..threadBindings.ttlHours was renamed to channels.discord.accounts..threadBindings.idleHours (auto-migrated on load).", - match: (value) => hasLegacyThreadBindingTtlInAccounts(value), + "channels..threadBindings.ttlHours was renamed to channels..threadBindings.idleHours (auto-migrated on load).", + match: (value) => hasLegacyThreadBindingTtlInAnyChannel(value), }, ]; @@ -160,7 +171,7 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ defineLegacyConfigMigration({ id: "thread-bindings.ttlHours->idleHours", describe: - "Move legacy threadBindings.ttlHours keys to threadBindings.idleHours (session + channels.discord)", + "Move legacy threadBindings.ttlHours keys to threadBindings.idleHours (session + channel configs)", legacyRules: THREAD_BINDING_RULES, apply: (raw, changes) => { const session = getRecord(raw.session); @@ -174,35 +185,39 @@ export const LEGACY_CONFIG_MIGRATIONS_CHANNELS: LegacyConfigMigrationSpec[] = [ } const channels = getRecord(raw.channels); - const discord = getRecord(channels?.discord); - if (!channels || !discord) { + if (!channels) { return; } - migrateThreadBindingsTtlHoursForPath({ - owner: discord, - pathPrefix: "channels.discord", - changes, - }); - - const accounts = getRecord(discord.accounts); - if (accounts) { - for (const [accountId, accountRaw] of Object.entries(accounts)) { - const account = getRecord(accountRaw); - if (!account) { - continue; - } - migrateThreadBindingsTtlHoursForPath({ - owner: account, - pathPrefix: `channels.discord.accounts.${accountId}`, - changes, - }); - accounts[accountId] = account; + for (const [channelId, channelRaw] of Object.entries(channels)) { + const channel = getRecord(channelRaw); + if (!channel) { + continue; } - discord.accounts = accounts; - } + migrateThreadBindingsTtlHoursForPath({ + owner: channel, + pathPrefix: `channels.${channelId}`, + changes, + }); - channels.discord = discord; + const accounts = getRecord(channel.accounts); + if (accounts) { + for (const [accountId, accountRaw] of Object.entries(accounts)) { + const account = getRecord(accountRaw); + if (!account) { + continue; + } + migrateThreadBindingsTtlHoursForPath({ + owner: account, + pathPrefix: `channels.${channelId}.accounts.${accountId}`, + changes, + }); + accounts[accountId] = account; + } + channel.accounts = accounts; + } + channels[channelId] = channel; + } raw.channels = channels; }, }), diff --git a/src/config/zod-schema.agents.ts b/src/config/zod-schema.agents.ts index 5dddfc9813a..022eed3c920 100644 --- a/src/config/zod-schema.agents.ts +++ b/src/config/zod-schema.agents.ts @@ -70,42 +70,6 @@ const AcpBindingSchema = z }); return; } - const channel = value.match.channel.trim().toLowerCase(); - if (channel !== "discord" && channel !== "telegram" && channel !== "feishu") { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["match", "channel"], - message: - 'ACP bindings currently support only "discord", "telegram", and "feishu" channels.', - }); - return; - } - if (channel === "telegram" && !/^-\d+:topic:\d+$/.test(peerId)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["match", "peer", "id"], - message: - "Telegram ACP bindings require canonical topic IDs in the form -1001234567890:topic:42.", - }); - } - if (channel === "feishu") { - const peerKind = value.match.peer?.kind; - const isDirectId = - (peerKind === "direct" || peerKind === "dm") && - /^[^:]+$/.test(peerId) && - !peerId.startsWith("oc_") && - !peerId.startsWith("on_"); - const isTopicId = - peerKind === "group" && /^oc_[^:]+:topic:[^:]+(?::sender:ou_[^:]+)?$/.test(peerId); - if (!isDirectId && !isTopicId) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: ["match", "peer", "id"], - message: - "Feishu ACP bindings require direct peer IDs for DMs or topic IDs in the form oc_group:topic:om_root[:sender:ou_xxx].", - }); - } - } }); export const BindingsSchema = z.array(z.union([RouteBindingSchema, AcpBindingSchema])).optional(); diff --git a/src/cron/service.jobs.test.ts b/src/cron/service.jobs.test.ts index 68770bd8908..937258be992 100644 --- a/src/cron/service.jobs.test.ts +++ b/src/cron/service.jobs.test.ts @@ -389,16 +389,15 @@ describe("applyJobPatch", () => { expect(job.delivery?.failureDestination?.to).toBe("https://example.invalid/failure"); }); - it("rejects Telegram delivery with invalid target (chatId/topicId format)", () => { + it("preserves raw channel delivery targets for plugin-owned validation", () => { const job = createIsolatedAgentTurnJob("job-telegram-invalid", { mode: "announce", channel: "telegram", to: "-10012345/6789", }); - expect(() => applyJobPatch(job, { enabled: true })).toThrow( - 'Invalid Telegram delivery target "-10012345/6789". Use colon (:) as delimiter for topics, not slash. Valid formats: -1001234567890, -1001234567890:123, -1001234567890:topic:123, @username, https://t.me/username', - ); + expect(() => applyJobPatch(job, { enabled: true })).not.toThrow(); + expect(job.delivery?.to).toBe("-10012345/6789"); }); it.each([ diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 7dd9b5d3251..8c9a103730c 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -163,23 +163,6 @@ function assertMainSessionAgentId( } } -const TELEGRAM_TME_URL_REGEX = /^https?:\/\/t\.me\/|t\.me\//i; -const TELEGRAM_SLASH_TOPIC_REGEX = /^-?\d+\/\d+$/; - -function validateTelegramDeliveryTarget(to: string | undefined): string | undefined { - if (!to) { - return undefined; - } - const trimmed = to.trim(); - if (TELEGRAM_TME_URL_REGEX.test(trimmed)) { - return undefined; - } - if (TELEGRAM_SLASH_TOPIC_REGEX.test(trimmed)) { - return `Invalid Telegram delivery target "${to}". Use colon (:) as delimiter for topics, not slash. Valid formats: -1001234567890, -1001234567890:123, -1001234567890:topic:123, @username, https://t.me/username`; - } - return undefined; -} - function assertDeliverySupport(job: Pick) { // No delivery object or mode is "none" -- nothing to validate. if (!job.delivery || job.delivery.mode === "none") { @@ -201,12 +184,6 @@ function assertDeliverySupport(job: Pick) if (!isIsolatedLike) { throw new Error('cron channel delivery config is only supported for sessionTarget="isolated"'); } - if (job.delivery.channel === "telegram") { - const telegramError = validateTelegramDeliveryTarget(job.delivery.to); - if (telegramError) { - throw new Error(telegramError); - } - } } function assertFailureDestinationSupport(job: Pick) { diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index 7a67a7f890f..07dd3f9ab7b 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -7,6 +7,7 @@ import { resolveConfiguredModelRef, resolveHooksGmailModel, } from "../agents/model-selection.js"; +import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js"; import { formatCliCommand } from "../cli/command-format.js"; import { maybeRemoveDeprecatedCliAuthProfiles, @@ -52,7 +53,6 @@ import { resolveGatewayService } from "../daemon/service.js"; import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; -import { runStartupMatrixMigration } from "../gateway/server-startup-matrix-migration.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; @@ -284,11 +284,11 @@ async function runGatewayServicesHealth(ctx: DoctorHealthFlowContext): Promise { +async function runStartupChannelMaintenanceHealth(ctx: DoctorHealthFlowContext): Promise { if (!ctx.prompter.shouldRepair) { return; } - await runStartupMatrixMigration({ + await runChannelPluginStartupMaintenance({ cfg: ctx.cfg, env: process.env, log: { @@ -534,9 +534,9 @@ export function resolveDoctorHealthContributions(): DoctorHealthContribution[] { run: runGatewayServicesHealth, }), createDoctorHealthContribution({ - id: "doctor:startup-matrix", - label: "Startup matrix", - run: runStartupMatrixHealth, + id: "doctor:startup-channel-maintenance", + label: "Startup channel maintenance", + run: runStartupChannelMaintenanceHealth, }), createDoctorHealthContribution({ id: "doctor:security", diff --git a/src/gateway/channel-health-monitor.ts b/src/gateway/channel-health-monitor.ts index 809beb1abb8..3f6114d9772 100644 --- a/src/gateway/channel-health-monitor.ts +++ b/src/gateway/channel-health-monitor.ts @@ -1,3 +1,4 @@ +import { getChannelPlugin } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { @@ -129,6 +130,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann now, staleEventThresholdMs: timing.staleEventThresholdMs, channelConnectGraceMs: timing.channelConnectGraceMs, + skipStaleSocketCheck: getChannelPlugin(channelId)?.status?.skipStaleSocketHealthCheck, }; const health = evaluateChannelHealth(status, healthPolicy); if (health.healthy) { diff --git a/src/gateway/channel-health-policy.ts b/src/gateway/channel-health-policy.ts index 7fed6fe7dad..2153f047624 100644 --- a/src/gateway/channel-health-policy.ts +++ b/src/gateway/channel-health-policy.ts @@ -35,6 +35,7 @@ export type ChannelHealthPolicy = { now: number; staleEventThresholdMs: number; channelConnectGraceMs: number; + skipStaleSocketCheck?: boolean; }; export type ChannelRestartReason = @@ -106,12 +107,12 @@ export function evaluateChannelHealth( if (snapshot.connected === false) { return { healthy: false, reason: "disconnected" }; } - // Skip stale-socket check for Telegram (long-polling mode) and any channel - // explicitly operating in webhook mode. In these cases, there is no persistent - // outgoing socket that can go half-dead, so the lack of incoming events - // does not necessarily indicate a connection failure. + // Skip stale-socket checks for channels that declare this health policy and + // any channel explicitly operating in webhook mode. In these cases, there is + // no persistent outgoing socket that can go half-dead, so the lack of + // incoming events does not necessarily indicate a connection failure. if ( - policy.channelId !== "telegram" && + policy.skipStaleSocketCheck !== true && snapshot.mode !== "webhook" && snapshot.connected === true && snapshot.lastEventAt != null diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 6703b8347cf..706c8540673 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -382,22 +382,27 @@ export const sendHandlers: GatewayRequestHandlers = { return; } const { cfg, channel } = resolvedChannel; - if (typeof request.durationSeconds === "number" && channel !== "telegram") { + const plugin = resolveOutboundChannelPlugin({ channel, cfg }); + const outbound = plugin?.outbound; + if ( + typeof request.durationSeconds === "number" && + outbound?.supportsPollDurationSeconds !== true + ) { respond( false, undefined, errorShape( ErrorCodes.INVALID_REQUEST, - "durationSeconds is only supported for Telegram polls", + `durationSeconds is not supported for ${channel} polls`, ), ); return; } - if (typeof request.isAnonymous === "boolean" && channel !== "telegram") { + if (typeof request.isAnonymous === "boolean" && outbound?.supportsAnonymousPolls !== true) { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "isAnonymous is only supported for Telegram polls"), + errorShape(ErrorCodes.INVALID_REQUEST, `isAnonymous is not supported for ${channel} polls`), ); return; } @@ -417,8 +422,6 @@ export const sendHandlers: GatewayRequestHandlers = { ? request.accountId.trim() : undefined; try { - const plugin = resolveOutboundChannelPlugin({ channel, cfg }); - const outbound = plugin?.outbound; if (!outbound?.sendPoll) { respond( false, diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index a610733a7bc..4e51e9875cf 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -1,5 +1,5 @@ import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js"; -import { normalizeChannelId } from "../channels/plugins/index.js"; +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import type { CliDeps } from "../cli/deps.js"; import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; import { parseSessionThreadInfo } from "../config/sessions/delivery-info.js"; @@ -185,13 +185,19 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) { sessionThreadId ?? (origin?.threadId != null ? String(origin.threadId) : undefined); - // Slack uses replyToId (thread_ts) for threading, not threadId. - // The reply path does this mapping but deliverOutboundPayloads does not, - // so we must convert here to ensure post-restart notifications land in - // the originating Slack thread. See #17716. - const isSlack = channel === "slack"; - const replyToId = isSlack && threadId != null && threadId !== "" ? String(threadId) : undefined; - const resolvedThreadId = isSlack ? undefined : threadId; + const replyTransport = + getChannelPlugin(channel)?.threading?.resolveReplyTransport?.({ + cfg, + accountId: origin?.accountId, + threadId, + }) ?? null; + const replyToId = replyTransport?.replyToId ?? undefined; + const resolvedThreadId = + replyTransport && Object.hasOwn(replyTransport, "threadId") + ? replyTransport.threadId != null + ? String(replyTransport.threadId) + : undefined + : threadId; const outboundSession = buildOutboundSessionContext({ cfg, sessionKey, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6e349d0c16e..f46e6307691 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -6,6 +6,7 @@ import { initSubagentRegistry } from "../agents/subagent-registry.js"; import { getTotalPendingReplies } from "../auto-reply/reply/dispatcher-registry.js"; import type { CanvasHostServer } from "../canvas-host/server.js"; import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js"; +import { runChannelPluginStartupMaintenance } from "../channels/plugins/lifecycle-startup.js"; import { formatCliCommand } from "../cli/command-format.js"; import { createDefaultDeps } from "../cli/deps.js"; import { isRestartEnabled } from "../config/commands.js"; @@ -38,10 +39,6 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; -import { - detectPluginInstallPathIssue, - formatPluginInstallPathIssue, -} from "../infra/plugin-install-path-warnings.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { primeRemoteSkillsCache, @@ -52,7 +49,6 @@ import { enqueueSystemEvent } from "../infra/system-events.js"; import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js"; import { startDiagnosticHeartbeat, stopDiagnosticHeartbeat } from "../logging/diagnostic.js"; import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; -import { resolveBundledPluginInstallCommandHint } from "../plugins/bundled-sources.js"; import { resolveConfiguredDeferredChannelPluginIds, resolveGatewayStartupPluginIds, @@ -124,7 +120,6 @@ import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; import { createGatewayRuntimeState } from "./server-runtime-state.js"; import { resolveSessionKeyForRun } from "./server-session-key.js"; import { logGatewayStartup } from "./server-startup-log.js"; -import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js"; import { runStartupSessionMigration } from "./server-startup-session-migration.js"; import { startGatewaySidecars } from "./server-startup.js"; import { startGatewayTailscaleExposure } from "./server-tailscale.js"; @@ -554,7 +549,7 @@ export async function startGatewayServer( const startupSnapshot = await readConfigFileSnapshot(); startupInternalWriteHash = startupSnapshot.hash ?? null; } - await runStartupMatrixMigration({ + await runChannelPluginStartupMaintenance({ cfg: cfgAtStart, env: process.env, log, @@ -564,26 +559,6 @@ export async function startGatewayServer( env: process.env, log, }); - const matrixInstallPathIssue = await detectPluginInstallPathIssue({ - pluginId: "matrix", - install: cfgAtStart.plugins?.installs?.matrix, - }); - if (matrixInstallPathIssue) { - const lines = formatPluginInstallPathIssue({ - issue: matrixInstallPathIssue, - pluginLabel: "Matrix", - defaultInstallCommand: "openclaw plugins install @openclaw/matrix", - repoInstallCommand: resolveBundledPluginInstallCommandHint({ - pluginId: "matrix", - workspaceDir: process.cwd(), - }), - formatCommand: formatCliCommand, - }); - log.warn( - `gateway: matrix install path warning:\n${lines.map((entry) => `- ${entry}`).join("\n")}`, - ); - } - initSubagentRegistry(); const gatewayPluginConfigAtStart = applyPluginAutoEnable({ config: cfgAtStart, diff --git a/src/gateway/server.startup-matrix-migration.integration.test.ts b/src/gateway/server.startup-matrix-migration.integration.test.ts index 3757a311ff3..afd858bfedb 100644 --- a/src/gateway/server.startup-matrix-migration.integration.test.ts +++ b/src/gateway/server.startup-matrix-migration.integration.test.ts @@ -1,9 +1,9 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -const runStartupMatrixMigrationMock = vi.fn().mockResolvedValue(undefined); +const runChannelPluginStartupMaintenanceMock = vi.fn().mockResolvedValue(undefined); -vi.mock("./server-startup-matrix-migration.js", () => ({ - runStartupMatrixMigration: runStartupMatrixMigrationMock, +vi.mock("../channels/plugins/lifecycle-startup.js", () => ({ + runChannelPluginStartupMaintenance: runChannelPluginStartupMaintenanceMock, })); import { @@ -15,7 +15,7 @@ import { installGatewayTestHooks({ scope: "suite" }); -describe("gateway startup Matrix migration wiring", () => { +describe("gateway startup channel maintenance wiring", () => { let server: Awaited> | undefined; beforeAll(async () => { @@ -33,9 +33,9 @@ describe("gateway startup Matrix migration wiring", () => { await server?.close(); }); - it("runs startup Matrix migration with the resolved startup config", () => { - expect(runStartupMatrixMigrationMock).toHaveBeenCalledTimes(1); - expect(runStartupMatrixMigrationMock).toHaveBeenCalledWith( + it("runs startup channel maintenance with the resolved startup config", () => { + expect(runChannelPluginStartupMaintenanceMock).toHaveBeenCalledTimes(1); + expect(runChannelPluginStartupMaintenanceMock).toHaveBeenCalledWith( expect.objectContaining({ cfg: expect.objectContaining({ channels: expect.objectContaining({ diff --git a/src/gateway/server/readiness.ts b/src/gateway/server/readiness.ts index 527dad24949..1090b5a9cec 100644 --- a/src/gateway/server/readiness.ts +++ b/src/gateway/server/readiness.ts @@ -1,3 +1,4 @@ +import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js"; import { DEFAULT_CHANNEL_CONNECT_GRACE_MS, @@ -64,6 +65,7 @@ export function createReadinessChecker(deps: { staleEventThresholdMs: DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, channelConnectGraceMs: DEFAULT_CHANNEL_CONNECT_GRACE_MS, channelId, + skipStaleSocketCheck: getChannelPlugin(channelId)?.status?.skipStaleSocketHealthCheck, }; const health = evaluateChannelHealth(accountSnapshot, policy); if (!health.healthy && !shouldIgnoreReadinessFailure(accountSnapshot, health)) { diff --git a/src/hooks/message-hook-mappers.ts b/src/hooks/message-hook-mappers.ts index d8341adfa55..e178474e8e7 100644 --- a/src/hooks/message-hook-mappers.ts +++ b/src/hooks/message-hook-mappers.ts @@ -1,4 +1,5 @@ import type { FinalizedMsgContext } from "../auto-reply/templating.js"; +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginHookInboundClaimContext, @@ -177,74 +178,42 @@ function stripChannelPrefix(value: string | undefined, channelId: string): strin return value.startsWith(prefix) ? value.slice(prefix.length) : value; } -function deriveParentConversationId( - canonical: CanonicalInboundMessageHookContext, -): string | undefined { - if (canonical.channelId !== "telegram") { - return undefined; - } - if (typeof canonical.threadId !== "number" && typeof canonical.threadId !== "string") { - return undefined; - } - return stripChannelPrefix( - canonical.to ?? canonical.originatingTo ?? canonical.conversationId, - "telegram", - ); -} - -function deriveConversationId(canonical: CanonicalInboundMessageHookContext): string | undefined { - if (canonical.channelId === "discord") { - const rawTarget = canonical.to ?? canonical.originatingTo ?? canonical.conversationId; - const rawSender = canonical.from; - const senderUserId = rawSender?.startsWith("discord:user:") - ? rawSender.slice("discord:user:".length) - : rawSender?.startsWith("discord:") - ? rawSender.slice("discord:".length) - : undefined; - if (!canonical.isGroup && senderUserId) { - return `user:${senderUserId}`; - } - if (!rawTarget) { - return undefined; - } - if (rawTarget.startsWith("discord:channel:")) { - return `channel:${rawTarget.slice("discord:channel:".length)}`; - } - if (rawTarget.startsWith("discord:user:")) { - return `user:${rawTarget.slice("discord:user:".length)}`; - } - if (rawTarget.startsWith("discord:")) { - return `user:${rawTarget.slice("discord:".length)}`; - } - if (rawTarget.startsWith("channel:") || rawTarget.startsWith("user:")) { - return rawTarget; - } +function resolveInboundConversation(canonical: CanonicalInboundMessageHookContext): { + conversationId?: string; + parentConversationId?: string; +} { + const channelId = normalizeChannelId(canonical.channelId); + const pluginResolved = channelId + ? getChannelPlugin(channelId)?.messaging?.resolveInboundConversation?.({ + from: canonical.from, + to: canonical.to ?? canonical.originatingTo, + conversationId: canonical.conversationId, + threadId: canonical.threadId, + isGroup: canonical.isGroup, + }) + : null; + if (pluginResolved) { + return { + conversationId: pluginResolved.conversationId?.trim() || undefined, + parentConversationId: pluginResolved.parentConversationId?.trim() || undefined, + }; } const baseConversationId = stripChannelPrefix( canonical.to ?? canonical.originatingTo ?? canonical.conversationId, canonical.channelId, ); - if (canonical.channelId === "telegram" && baseConversationId) { - const threadId = - typeof canonical.threadId === "number" || typeof canonical.threadId === "string" - ? String(canonical.threadId).trim() - : ""; - if (threadId) { - return `${baseConversationId}:topic:${threadId}`; - } - } - return baseConversationId; + return { conversationId: baseConversationId }; } export function toPluginInboundClaimContext( canonical: CanonicalInboundMessageHookContext, ): PluginHookInboundClaimContext { - const conversationId = deriveConversationId(canonical); + const conversation = resolveInboundConversation(canonical); return { channelId: canonical.channelId, accountId: canonical.accountId, - conversationId, - parentConversationId: deriveParentConversationId(canonical), + conversationId: conversation.conversationId, + parentConversationId: conversation.parentConversationId, senderId: canonical.senderId, messageId: canonical.messageId, }; diff --git a/src/infra/exec-approval-surface.ts b/src/infra/exec-approval-surface.ts index aefe88c0ef3..f3ad892b84f 100644 --- a/src/infra/exec-approval-surface.ts +++ b/src/infra/exec-approval-surface.ts @@ -17,18 +17,16 @@ export type ExecApprovalInitiatingSurfaceState = | { kind: "unsupported"; channel: string; channelLabel: string }; function labelForChannel(channel?: string): string { - switch (channel) { - case "discord": - return "Discord"; - case "telegram": - return "Telegram"; - case "tui": - return "terminal UI"; - case INTERNAL_MESSAGE_CHANNEL: - return "Web UI"; - default: - return channel ? channel[0]?.toUpperCase() + channel.slice(1) : "this platform"; + if (channel === "tui") { + return "terminal UI"; } + if (channel === INTERNAL_MESSAGE_CHANNEL) { + return "Web UI"; + } + return ( + getChannelPlugin(channel ?? "")?.meta.label ?? + (channel ? channel[0]?.toUpperCase() + channel.slice(1) : "this platform") + ); } export function resolveExecApprovalInitiatingSurfaceState(params: { diff --git a/src/plugins/command-registration.ts b/src/plugins/command-registration.ts index d9c8ec6cabb..4b096dbfa94 100644 --- a/src/plugins/command-registration.ts +++ b/src/plugins/command-registration.ts @@ -112,12 +112,12 @@ export function validatePluginCommandDefinition( return `Native command alias "${label}" invalid: ${aliasError}`; } } - if ("telegramNativeProgressMessage" in command) { - if (typeof command.telegramNativeProgressMessage !== "string") { - return "telegramNativeProgressMessage must be a string"; + for (const [label, message] of Object.entries(command.nativeProgressMessages ?? {})) { + if (typeof message !== "string") { + return `Native progress message "${label}" must be a string`; } - if (!command.telegramNativeProgressMessage.trim()) { - return "telegramNativeProgressMessage cannot be empty"; + if (!message.trim()) { + return `Native progress message "${label}" cannot be empty`; } } return null; @@ -134,9 +134,11 @@ export function listPluginInvocationKeys(command: OpenClawPluginCommandDefinitio }; push(command.name); - push(command.nativeNames?.default); - push(command.nativeNames?.telegram); - push(command.nativeNames?.discord); + for (const alias of Object.values(command.nativeNames ?? {})) { + if (typeof alias === "string") { + push(alias); + } + } return [...keys]; } diff --git a/src/plugins/command-registry-state.ts b/src/plugins/command-registry-state.ts index 290e49b72d2..0ecd835502d 100644 --- a/src/plugins/command-registry-state.ts +++ b/src/plugins/command-registry-state.ts @@ -1,3 +1,4 @@ +import { getChannelPlugin } from "../channels/plugins/index.js"; import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import type { OpenClawPluginCommandDefinition } from "./types.js"; @@ -71,7 +72,10 @@ export function getPluginCommandSpecs(provider?: string): Array<{ acceptsArgs: boolean; }> { const providerName = provider?.trim().toLowerCase(); - if (providerName && providerName !== "telegram" && providerName !== "discord") { + if ( + providerName && + getChannelPlugin(providerName)?.commands?.nativeCommandsAutoEnabled !== true + ) { return []; } return Array.from(pluginCommands.values()).map((cmd) => ({ diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index 0a675f318a0..f491cd4e08d 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import { __testing, clearPluginCommands, @@ -98,7 +98,96 @@ function expectBindingConversationCase( } beforeEach(() => { - setActivePluginRegistry(createTestRegistry([])); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + commands: { + nativeCommandsAutoEnabled: true, + }, + bindings: { + selfParentConversationByDefault: true, + resolveCommandConversation: ({ + threadId, + originatingTo, + commandTo, + fallbackTo, + }: { + threadId?: string; + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; + }) => { + const rawTarget = [commandTo, originatingTo, fallbackTo].find(Boolean)?.trim(); + if (!rawTarget || rawTarget.startsWith("slash:")) { + return null; + } + const normalized = rawTarget.replace(/^telegram:/i, ""); + const topicMatch = /^(.*?):topic:(\d+)$/i.exec(normalized); + if (topicMatch?.[1]) { + return { + conversationId: `${topicMatch[1]}:topic:${threadId ?? topicMatch[2]}`, + parentConversationId: topicMatch[1], + }; + } + return { conversationId: normalized }; + }, + }, + }, + }, + { + pluginId: "discord", + source: "test", + plugin: { + ...createChannelTestPluginBase({ id: "discord", label: "Discord" }), + commands: { + nativeCommandsAutoEnabled: true, + }, + bindings: { + resolveCommandConversation: ({ + threadId, + threadParentId, + originatingTo, + commandTo, + fallbackTo, + }: { + threadId?: string; + threadParentId?: string; + originatingTo?: string; + commandTo?: string; + fallbackTo?: string; + }) => { + const rawTarget = [originatingTo, commandTo, fallbackTo].find(Boolean)?.trim(); + if (!rawTarget || rawTarget.startsWith("slash:")) { + return null; + } + const normalized = rawTarget.replace(/^discord:/i, ""); + if (/^\d+$/.test(normalized)) { + return { conversationId: `user:${normalized}` }; + } + if (threadId) { + const baseConversationId = + originatingTo?.trim()?.replace(/^discord:/i, "") || + commandTo?.trim()?.replace(/^discord:/i, "") || + fallbackTo?.trim()?.replace(/^discord:/i, ""); + return { + conversationId: baseConversationId || threadId, + ...(threadParentId ? { parentConversationId: threadParentId } : {}), + }; + } + if (normalized.startsWith("channel:") || normalized.startsWith("user:")) { + return { conversationId: normalized }; + } + return null; + }, + }, + }, + }, + ]), + ); }); afterEach(() => { @@ -177,29 +266,29 @@ describe("registerPluginCommand", () => { ]); }); - it("accepts Telegram native progress metadata on plugin commands", () => { + it("accepts native progress metadata on plugin commands", () => { const result = registerVoiceCommandForTest({ - telegramNativeProgressMessage: "Running voice command...", + nativeProgressMessages: { telegram: "Running voice command..." }, description: "Demo command", }); expect(result).toEqual({ ok: true }); expect(matchPluginCommand("/voice")).toMatchObject({ command: expect.objectContaining({ - telegramNativeProgressMessage: "Running voice command...", + nativeProgressMessages: { telegram: "Running voice command..." }, }), }); }); - it("rejects empty Telegram native progress metadata", () => { + it("rejects empty native progress metadata", () => { const result = registerVoiceCommandForTest({ - telegramNativeProgressMessage: " ", + nativeProgressMessages: { telegram: " " }, description: "Demo command", }); expect(result).toEqual({ ok: false, - error: "telegramNativeProgressMessage cannot be empty", + error: 'Native progress message "telegram" cannot be empty', }); }); diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 3857e5fa918..89b9a4a4a1e 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -5,7 +5,7 @@ * These commands are processed before built-in commands and before agent invocation. */ -import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js"; +import { resolveConversationBindingContext } from "../channels/conversation-binding-context.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; import { @@ -27,6 +27,7 @@ import { getCurrentPluginConversationBinding, requestPluginConversationBinding, } from "./conversation-binding.js"; +import { getActivePluginChannelRegistry } from "./runtime.js"; import type { OpenClawPluginCommandDefinition, PluginCommandContext, @@ -111,37 +112,8 @@ function sanitizeArgs(args: string | undefined): string | undefined { return sanitized; } -function stripPrefix(raw: string | undefined, prefix: string): string | undefined { - if (!raw) { - return undefined; - } - return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw; -} - -function parseDiscordBindingTarget(raw: string | undefined): { - conversationId: string; -} | null { - if (!raw) { - return null; - } - if (raw.startsWith("slash:")) { - return null; - } - const normalized = raw.startsWith("discord:") ? raw.slice("discord:".length) : raw; - if (!normalized) { - return null; - } - if (normalized.startsWith("channel:")) { - const id = normalized.slice("channel:".length).trim(); - return id ? { conversationId: `channel:${id}` } : null; - } - if (normalized.startsWith("user:")) { - const id = normalized.slice("user:".length).trim(); - return id ? { conversationId: `user:${id}` } : null; - } - return /^\d+$/.test(normalized.trim()) ? { conversationId: `user:${normalized.trim()}` } : null; -} function resolveBindingConversationFromCommand(params: { + config?: OpenClawConfig; channel: string; from?: string; to?: string; @@ -155,54 +127,22 @@ function resolveBindingConversationFromCommand(params: { parentConversationId?: string; threadId?: string | number; } | null { - const accountId = params.accountId?.trim() || "default"; - if (params.channel === "telegram") { - // Native Telegram slash commands use a synthetic `To: slash:` value. - // Prefer `from` so binding resolution parses the real chat/topic peer. - const rawTarget = - params.to && params.to.startsWith("slash:") - ? (params.from ?? params.to) - : (params.to ?? params.from); - if (!rawTarget) { - return null; - } - const target = parseExplicitTargetForChannel("telegram", rawTarget); - if (!target) { - return null; - } - return { - channel: "telegram", - accountId, - conversationId: target.to, - threadId: params.messageThreadId ?? target.threadId, - }; + const channelPlugin = getActivePluginChannelRegistry()?.channels.find( + (entry) => entry.plugin.id === params.channel, + )?.plugin; + if (!channelPlugin?.bindings?.resolveCommandConversation) { + return null; } - if (params.channel === "discord") { - const source = - params.to?.startsWith("slash:") || !params.to?.trim() - ? (params.from ?? params.to) - : params.to; - const rawTarget = source?.startsWith("discord:") ? stripPrefix(source, "discord:") : source; - if (!rawTarget || rawTarget.startsWith("slash:")) { - return null; - } - const target = - parseExplicitTargetForChannel("discord", rawTarget) ?? parseDiscordBindingTarget(rawTarget); - if (!target) { - return null; - } - return { - channel: "discord", - accountId, - conversationId: - "conversationId" in target - ? target.conversationId - : `${target.chatType === "direct" ? "user" : "channel"}:${target.to}`, - parentConversationId: params.threadParentId?.trim() || undefined, - threadId: params.messageThreadId, - }; - } - return null; + return resolveConversationBindingContext({ + cfg: params.config ?? ({} as OpenClawConfig), + channel: params.channel, + accountId: params.accountId, + threadId: params.messageThreadId, + threadParentId: params.threadParentId, + originatingTo: params.from, + commandTo: params.to, + fallbackTo: params.to ?? params.from, + }); } /** @@ -243,6 +183,7 @@ export async function executePluginCommand(params: { // Sanitize args before passing to handler const sanitizedArgs = sanitizeArgs(args); const bindingConversation = resolveBindingConversationFromCommand({ + config, channel, from: params.from, to: params.to, diff --git a/src/plugins/conversation-binding.ts b/src/plugins/conversation-binding.ts index f0c353f272b..409a463a961 100644 --- a/src/plugins/conversation-binding.ts +++ b/src/plugins/conversation-binding.ts @@ -7,6 +7,7 @@ import { resolveConversationBindingRecord, unbindConversationBindingRecord, } from "../bindings/records.js"; +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import { expandHomePrefix } from "../infra/home-dir.js"; import { writeJsonAtomic } from "../infra/json-files.js"; import { type ConversationRef } from "../infra/outbound/session-binding-service.js"; @@ -154,19 +155,24 @@ function normalizeConversation(params: PluginBindingConversation): PluginBinding function toConversationRef(params: PluginBindingConversation): ConversationRef { const normalized = normalizeConversation(params); - if (normalized.channel === "telegram") { - const threadId = - typeof normalized.threadId === "number" || typeof normalized.threadId === "string" - ? String(normalized.threadId).trim() - : ""; - if (threadId) { - const parent = normalized.parentConversationId?.trim() || normalized.conversationId; - return { - channel: "telegram", + const channelId = normalizeChannelId(normalized.channel); + const resolvedConversationRef = channelId + ? getChannelPlugin(channelId)?.conversationBindings?.resolveConversationRef?.({ accountId: normalized.accountId, - conversationId: `${parent}:topic:${threadId}`, - }; - } + conversationId: normalized.conversationId, + parentConversationId: normalized.parentConversationId, + threadId: normalized.threadId, + }) + : null; + if (resolvedConversationRef?.conversationId?.trim()) { + return { + channel: normalized.channel, + accountId: normalized.accountId, + conversationId: resolvedConversationRef.conversationId.trim(), + ...(resolvedConversationRef.parentConversationId?.trim() + ? { parentConversationId: resolvedConversationRef.parentConversationId.trim() } + : {}), + }; } return { channel: normalized.channel, diff --git a/src/plugins/interactive-binding-helpers.ts b/src/plugins/interactive-binding-helpers.ts new file mode 100644 index 00000000000..a6790550d55 --- /dev/null +++ b/src/plugins/interactive-binding-helpers.ts @@ -0,0 +1,62 @@ +import { + detachPluginConversationBinding, + getCurrentPluginConversationBinding, + requestPluginConversationBinding, +} from "./conversation-binding.js"; +import type { PluginConversationBindingRequestParams } from "./types.js"; + +type RegisteredInteractiveMetadata = { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +type PluginBindingConversation = Parameters< + typeof requestPluginConversationBinding +>[0]["conversation"]; + +export function createInteractiveConversationBindingHelpers(params: { + registration: RegisteredInteractiveMetadata; + senderId?: string; + conversation: PluginBindingConversation; +}) { + const { registration, senderId, conversation } = params; + const pluginRoot = registration.pluginRoot; + + return { + requestConversationBinding: async (binding: PluginConversationBindingRequestParams = {}) => { + if (!pluginRoot) { + return { + status: "error" as const, + message: "This interaction cannot bind the current conversation.", + }; + } + return requestPluginConversationBinding({ + pluginId: registration.pluginId, + pluginName: registration.pluginName, + pluginRoot, + requestedBySenderId: senderId, + conversation, + binding, + }); + }, + detachConversationBinding: async () => { + if (!pluginRoot) { + return { removed: false }; + } + return detachPluginConversationBinding({ + pluginRoot, + conversation, + }); + }, + getCurrentConversationBinding: async () => { + if (!pluginRoot) { + return null; + } + return getCurrentPluginConversationBinding({ + pluginRoot, + conversation, + }); + }, + }; +} diff --git a/src/plugins/interactive-dispatch-adapters.ts b/src/plugins/interactive-dispatch-adapters.ts deleted file mode 100644 index 4050e707958..00000000000 --- a/src/plugins/interactive-dispatch-adapters.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { - detachPluginConversationBinding, - getCurrentPluginConversationBinding, - requestPluginConversationBinding, -} from "./conversation-binding.js"; -import type { - PluginConversationBindingRequestParams, - PluginInteractiveDiscordHandlerContext, - PluginInteractiveDiscordHandlerRegistration, - PluginInteractiveSlackHandlerContext, - PluginInteractiveSlackHandlerRegistration, - PluginInteractiveTelegramHandlerContext, - PluginInteractiveTelegramHandlerRegistration, -} from "./types.js"; - -type RegisteredInteractiveMetadata = { - pluginId: string; - pluginName?: string; - pluginRoot?: string; -}; - -type PluginBindingConversation = Parameters< - typeof requestPluginConversationBinding ->[0]["conversation"]; - -export type TelegramInteractiveDispatchContext = Omit< - PluginInteractiveTelegramHandlerContext, - | "callback" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - callbackMessage: { - messageId: number; - chatId: string; - messageText?: string; - }; -}; - -export type DiscordInteractiveDispatchContext = Omit< - PluginInteractiveDiscordHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - interaction: Omit< - PluginInteractiveDiscordHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; -}; - -export type SlackInteractiveDispatchContext = Omit< - PluginInteractiveSlackHandlerContext, - | "interaction" - | "respond" - | "channel" - | "requestConversationBinding" - | "detachConversationBinding" - | "getCurrentConversationBinding" -> & { - interaction: Omit< - PluginInteractiveSlackHandlerContext["interaction"], - "data" | "namespace" | "payload" - >; -}; - -function createConversationBindingHelpers(params: { - registration: RegisteredInteractiveMetadata; - senderId?: string; - conversation: PluginBindingConversation; -}) { - const { registration, senderId, conversation } = params; - const pluginRoot = registration.pluginRoot; - - return { - requestConversationBinding: async (binding: PluginConversationBindingRequestParams = {}) => { - if (!pluginRoot) { - return { - status: "error" as const, - message: "This interaction cannot bind the current conversation.", - }; - } - return requestPluginConversationBinding({ - pluginId: registration.pluginId, - pluginName: registration.pluginName, - pluginRoot, - requestedBySenderId: senderId, - conversation, - binding, - }); - }, - detachConversationBinding: async () => { - if (!pluginRoot) { - return { removed: false }; - } - return detachPluginConversationBinding({ - pluginRoot, - conversation, - }); - }, - getCurrentConversationBinding: async () => { - if (!pluginRoot) { - return null; - } - return getCurrentPluginConversationBinding({ - pluginRoot, - conversation, - }); - }, - }; -} - -export function dispatchTelegramInteractiveHandler(params: { - registration: PluginInteractiveTelegramHandlerRegistration & RegisteredInteractiveMetadata; - data: string; - namespace: string; - payload: string; - ctx: TelegramInteractiveDispatchContext; - respond: PluginInteractiveTelegramHandlerContext["respond"]; -}) { - const { callbackMessage, ...handlerContext } = params.ctx; - - return params.registration.handler({ - ...handlerContext, - channel: "telegram", - callback: { - data: params.data, - namespace: params.namespace, - payload: params.payload, - messageId: callbackMessage.messageId, - chatId: callbackMessage.chatId, - messageText: callbackMessage.messageText, - }, - respond: params.respond, - ...createConversationBindingHelpers({ - registration: params.registration, - senderId: handlerContext.senderId, - conversation: { - channel: "telegram", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }), - }); -} - -export function dispatchDiscordInteractiveHandler(params: { - registration: PluginInteractiveDiscordHandlerRegistration & RegisteredInteractiveMetadata; - data: string; - namespace: string; - payload: string; - ctx: DiscordInteractiveDispatchContext; - respond: PluginInteractiveDiscordHandlerContext["respond"]; -}) { - const handlerContext = params.ctx; - - return params.registration.handler({ - ...handlerContext, - channel: "discord", - interaction: { - ...handlerContext.interaction, - data: params.data, - namespace: params.namespace, - payload: params.payload, - }, - respond: params.respond, - ...createConversationBindingHelpers({ - registration: params.registration, - senderId: handlerContext.senderId, - conversation: { - channel: "discord", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - }, - }), - }); -} - -export function dispatchSlackInteractiveHandler(params: { - registration: PluginInteractiveSlackHandlerRegistration & RegisteredInteractiveMetadata; - data: string; - namespace: string; - payload: string; - ctx: SlackInteractiveDispatchContext; - respond: PluginInteractiveSlackHandlerContext["respond"]; -}) { - const handlerContext = params.ctx; - - return params.registration.handler({ - ...handlerContext, - channel: "slack", - interaction: { - ...handlerContext.interaction, - data: params.data, - namespace: params.namespace, - payload: params.payload, - }, - respond: params.respond, - ...createConversationBindingHelpers({ - registration: params.registration, - senderId: handlerContext.senderId, - conversation: { - channel: "slack", - accountId: handlerContext.accountId, - conversationId: handlerContext.conversationId, - parentConversationId: handlerContext.parentConversationId, - threadId: handlerContext.threadId, - }, - }), - }); -} diff --git a/src/plugins/interactive.test.ts b/src/plugins/interactive.test.ts index b9106c551c2..09e883015e4 100644 --- a/src/plugins/interactive.test.ts +++ b/src/plugins/interactive.test.ts @@ -1,20 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; -import * as conversationBinding from "./conversation-binding.js"; import type { - DiscordInteractiveDispatchContext, - SlackInteractiveDispatchContext, - TelegramInteractiveDispatchContext, -} from "./interactive-dispatch-adapters.js"; + DiscordInteractiveHandlerContext, + DiscordInteractiveHandlerRegistration, +} from "../../extensions/discord/src/interactive-dispatch.js"; +import type { + SlackInteractiveHandlerContext, + SlackInteractiveHandlerRegistration, +} from "../../extensions/slack/src/interactive-dispatch.js"; +import type { + TelegramInteractiveHandlerContext, + TelegramInteractiveHandlerRegistration, +} from "../../extensions/telegram/src/interactive-dispatch.js"; +import * as conversationBinding from "./conversation-binding.js"; +import { createInteractiveConversationBindingHelpers } from "./interactive-binding-helpers.js"; import { clearPluginInteractiveHandlers, dispatchPluginInteractiveHandler, registerPluginInteractiveHandler, } from "./interactive.js"; -import type { - PluginInteractiveDiscordHandlerContext, - PluginInteractiveSlackHandlerContext, - PluginInteractiveTelegramHandlerContext, -} from "./types.js"; let requestPluginConversationBindingMock: MockInstance< typeof conversationBinding.requestPluginConversationBinding @@ -30,23 +33,66 @@ type InteractiveDispatchParams = | { channel: "telegram"; data: string; - callbackId: string; - ctx: TelegramInteractiveDispatchContext; - respond: PluginInteractiveTelegramHandlerContext["respond"]; + dedupeId: string; + onMatched?: () => Promise | void; + ctx: Omit< + TelegramInteractiveHandlerContext, + | "callback" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" + > & { + callbackMessage: { + messageId: number; + chatId: string; + messageText?: string; + }; + }; + respond: TelegramInteractiveHandlerContext["respond"]; } | { channel: "discord"; data: string; - interactionId: string; - ctx: DiscordInteractiveDispatchContext; - respond: PluginInteractiveDiscordHandlerContext["respond"]; + dedupeId: string; + onMatched?: () => Promise | void; + ctx: Omit< + DiscordInteractiveHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" + > & { + interaction: Omit< + DiscordInteractiveHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; + }; + respond: DiscordInteractiveHandlerContext["respond"]; } | { channel: "slack"; data: string; - interactionId: string; - ctx: SlackInteractiveDispatchContext; - respond: PluginInteractiveSlackHandlerContext["respond"]; + dedupeId: string; + onMatched?: () => Promise | void; + ctx: Omit< + SlackInteractiveHandlerContext, + | "interaction" + | "respond" + | "channel" + | "requestConversationBinding" + | "detachConversationBinding" + | "getCurrentConversationBinding" + > & { + interaction: Omit< + SlackInteractiveHandlerContext["interaction"], + "data" | "namespace" | "payload" + >; + }; + respond: SlackInteractiveHandlerContext["respond"]; }; type InteractiveModule = typeof import("./interactive.js"); @@ -64,7 +110,7 @@ function createTelegramDispatchParams(params: { return { channel: "telegram", data: params.data, - callbackId: params.callbackId, + dedupeId: params.callbackId, ctx: { accountId: "default", callbackId: params.callbackId, @@ -95,12 +141,14 @@ function createTelegramDispatchParams(params: { function createDiscordDispatchParams(params: { data: string; interactionId: string; - interaction?: Partial; + interaction?: Partial< + Extract["ctx"]["interaction"] + >; }): Extract { return { channel: "discord", data: params.data, - interactionId: params.interactionId, + dedupeId: params.interactionId, ctx: { accountId: "default", interactionId: params.interactionId, @@ -130,12 +178,14 @@ function createDiscordDispatchParams(params: { function createSlackDispatchParams(params: { data: string; interactionId: string; - interaction?: Partial; + interaction?: Partial< + Extract["ctx"]["interaction"] + >; }): Extract { return { channel: "slack", data: params.data, - interactionId: params.interactionId, + dedupeId: params.interactionId, ctx: { accountId: "default", interactionId: params.interactionId, @@ -183,13 +233,113 @@ async function expectDedupedInteractiveDispatch(params: { } async function dispatchInteractive(params: InteractiveDispatchParams) { + return await dispatchInteractiveWith({ dispatchPluginInteractiveHandler }, params); +} + +async function dispatchInteractiveWith( + interactiveModule: Pick, + params: InteractiveDispatchParams, +) { if (params.channel === "telegram") { - return await dispatchPluginInteractiveHandler(params); + return await interactiveModule.dispatchPluginInteractiveHandler( + { + channel: "telegram", + data: params.data, + dedupeId: params.dedupeId, + onMatched: params.onMatched, + invoke: ({ registration, namespace, payload }) => { + const { callbackMessage, ...handlerContext } = params.ctx; + return registration.handler({ + ...handlerContext, + channel: "telegram", + callback: { + data: params.data, + namespace, + payload, + messageId: callbackMessage.messageId, + chatId: callbackMessage.chatId, + messageText: callbackMessage.messageText, + }, + respond: params.respond, + ...createInteractiveConversationBindingHelpers({ + registration, + senderId: handlerContext.senderId, + conversation: { + channel: "telegram", + accountId: handlerContext.accountId, + conversationId: handlerContext.conversationId, + parentConversationId: handlerContext.parentConversationId, + threadId: handlerContext.threadId, + }, + }), + }); + }, + }, + ); } if (params.channel === "discord") { - return await dispatchPluginInteractiveHandler(params); + return await interactiveModule.dispatchPluginInteractiveHandler( + { + channel: "discord", + data: params.data, + dedupeId: params.dedupeId, + onMatched: params.onMatched, + invoke: ({ registration, namespace, payload }) => + registration.handler({ + ...params.ctx, + channel: "discord", + interaction: { + ...params.ctx.interaction, + data: params.data, + namespace, + payload, + }, + respond: params.respond, + ...createInteractiveConversationBindingHelpers({ + registration, + senderId: params.ctx.senderId, + conversation: { + channel: "discord", + accountId: params.ctx.accountId, + conversationId: params.ctx.conversationId, + parentConversationId: params.ctx.parentConversationId, + }, + }), + }), + }, + ); } - return await dispatchPluginInteractiveHandler(params); + return await interactiveModule.dispatchPluginInteractiveHandler( + { + channel: "slack", + data: params.data, + dedupeId: params.dedupeId, + onMatched: params.onMatched, + invoke: ({ registration, namespace, payload }) => + registration.handler({ + ...params.ctx, + channel: "slack", + interaction: { + ...params.ctx.interaction, + data: params.data, + namespace, + payload, + }, + respond: params.respond, + ...createInteractiveConversationBindingHelpers({ + registration, + senderId: params.ctx.senderId, + conversation: { + channel: "slack", + accountId: params.ctx.accountId, + conversationId: params.ctx.conversationId, + parentConversationId: params.ctx.parentConversationId, + threadId: params.ctx.threadId, + }, + }), + }), + }, + ); } function registerInteractiveHandler(params: { @@ -417,7 +567,8 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - second.dispatchPluginInteractiveHandler( + dispatchInteractiveWith( + second, createTelegramDispatchParams({ data: "codexapp:resume:thread-1", callbackId: "cb-shared-1", @@ -473,7 +624,7 @@ describe("plugin interactive handlers", () => { ).toEqual({ ok: true }); await expect( - dispatchPluginInteractiveHandler({ + dispatchInteractive({ ...createDiscordDispatchParams({ data: "codex:approve:thread-1", interactionId: "ix-ack-1", @@ -611,8 +762,8 @@ describe("plugin interactive handlers", () => { callbackId: "cb-throw", }); - await expect(dispatchPluginInteractiveHandler(baseParams)).rejects.toThrow("boom"); - await expect(dispatchPluginInteractiveHandler(baseParams)).resolves.toEqual({ + await expect(dispatchInteractive(baseParams)).rejects.toThrow("boom"); + await expect(dispatchInteractive(baseParams)).resolves.toEqual({ matched: true, handled: true, duplicate: false, diff --git a/src/plugins/interactive.ts b/src/plugins/interactive.ts index 8f92fc290b3..bb96949e49a 100644 --- a/src/plugins/interactive.ts +++ b/src/plugins/interactive.ts @@ -1,143 +1,168 @@ -import { - dispatchDiscordInteractiveHandler, - dispatchSlackInteractiveHandler, - dispatchTelegramInteractiveHandler, - type DiscordInteractiveDispatchContext, - type SlackInteractiveDispatchContext, - type TelegramInteractiveDispatchContext, -} from "./interactive-dispatch-adapters.js"; -import { resolvePluginInteractiveNamespaceMatch } from "./interactive-registry.js"; -import { - getPluginInteractiveCallbackDedupeState, - type RegisteredInteractiveHandler, -} from "./interactive-state.js"; -import type { - PluginInteractiveDiscordHandlerContext, - PluginInteractiveButtons, - PluginInteractiveDiscordHandlerRegistration, - PluginInteractiveSlackHandlerContext, - PluginInteractiveSlackHandlerRegistration, - PluginInteractiveTelegramHandlerRegistration, - PluginInteractiveTelegramHandlerContext, -} from "./types.js"; +import { createDedupeCache, resolveGlobalDedupeCache } from "../infra/dedupe.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; +import type { PluginInteractiveHandlerRegistration } from "./types.js"; -export { - clearPluginInteractiveHandlers, - clearPluginInteractiveHandlersForPlugin, - registerPluginInteractiveHandler, -} from "./interactive-registry.js"; -export type { InteractiveRegistrationResult } from "./interactive-registry.js"; +type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration & { + pluginId: string; + pluginName?: string; + pluginRoot?: string; +}; + +type InteractiveRegistrationResult = { + ok: boolean; + error?: string; +}; type InteractiveDispatchResult = | { matched: false; handled: false; duplicate: false } | { matched: true; handled: boolean; duplicate: boolean }; -const getCallbackDedupe = () => getPluginInteractiveCallbackDedupeState(); +type PluginInteractiveDispatchRegistration = { + channel: string; + namespace: string; +}; -export async function dispatchPluginInteractiveHandler(params: { - channel: "telegram"; - data: string; - callbackId: string; - ctx: TelegramInteractiveDispatchContext; - respond: { - reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; - editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; - editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; - clearButtons: () => Promise; - deleteMessage: () => Promise; +export type PluginInteractiveMatch = { + registration: RegisteredInteractiveHandler & TRegistration; + namespace: string; + payload: string; +}; + +type InteractiveState = { + interactiveHandlers: Map; + callbackDedupe: ReturnType; +}; + +const PLUGIN_INTERACTIVE_STATE_KEY = Symbol.for("openclaw.pluginInteractiveState"); + +const getState = () => + resolveGlobalSingleton(PLUGIN_INTERACTIVE_STATE_KEY, () => ({ + interactiveHandlers: new Map(), + callbackDedupe: resolveGlobalDedupeCache( + Symbol.for("openclaw.pluginInteractiveCallbackDedupe"), + { + ttlMs: 5 * 60_000, + maxSize: 4096, + }, + ), + })); + +const getInteractiveHandlers = () => getState().interactiveHandlers; +const getCallbackDedupe = () => getState().callbackDedupe; + +function toRegistryKey(channel: string, namespace: string): string { + return `${channel.trim().toLowerCase()}:${namespace.trim()}`; +} + +function normalizeNamespace(namespace: string): string { + return namespace.trim(); +} + +function validateNamespace(namespace: string): string | null { + if (!namespace.trim()) { + return "Interactive handler namespace cannot be empty"; + } + if (!/^[A-Za-z0-9._-]+$/.test(namespace.trim())) { + return "Interactive handler namespace must contain only letters, numbers, dots, underscores, and hyphens"; + } + return null; +} + +function resolveNamespaceMatch( + channel: string, + data: string, +): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null { + const interactiveHandlers = getInteractiveHandlers(); + const trimmedData = data.trim(); + if (!trimmedData) { + return null; + } + + const separatorIndex = trimmedData.indexOf(":"); + const namespace = + separatorIndex >= 0 ? trimmedData.slice(0, separatorIndex) : normalizeNamespace(trimmedData); + const registration = interactiveHandlers.get(toRegistryKey(channel, namespace)); + if (!registration) { + return null; + } + + return { + registration, + namespace, + payload: separatorIndex >= 0 ? trimmedData.slice(separatorIndex + 1) : "", }; - onMatched?: () => Promise | void; -}): Promise; -export async function dispatchPluginInteractiveHandler(params: { - channel: "discord"; +} + +export function registerPluginInteractiveHandler( + pluginId: string, + registration: PluginInteractiveHandlerRegistration, + opts?: { pluginName?: string; pluginRoot?: string }, +): InteractiveRegistrationResult { + const interactiveHandlers = getInteractiveHandlers(); + const namespace = normalizeNamespace(registration.namespace); + const validationError = validateNamespace(namespace); + if (validationError) { + return { ok: false, error: validationError }; + } + const key = toRegistryKey(registration.channel, namespace); + const existing = interactiveHandlers.get(key); + if (existing) { + return { + ok: false, + error: `Interactive handler namespace "${namespace}" already registered by plugin "${existing.pluginId}"`, + }; + } + interactiveHandlers.set(key, { + ...registration, + namespace, + pluginId, + pluginName: opts?.pluginName, + pluginRoot: opts?.pluginRoot, + }); + return { ok: true }; +} + +export function clearPluginInteractiveHandlers(): void { + const interactiveHandlers = getInteractiveHandlers(); + const callbackDedupe = getCallbackDedupe(); + interactiveHandlers.clear(); + callbackDedupe.clear(); +} + +export function clearPluginInteractiveHandlersForPlugin(pluginId: string): void { + const interactiveHandlers = getInteractiveHandlers(); + for (const [key, value] of interactiveHandlers.entries()) { + if (value.pluginId === pluginId) { + interactiveHandlers.delete(key); + } + } +} + +export async function dispatchPluginInteractiveHandler< + TRegistration extends PluginInteractiveDispatchRegistration, +>(params: { + channel: TRegistration["channel"]; data: string; - interactionId: string; - ctx: DiscordInteractiveDispatchContext; - respond: PluginInteractiveDiscordHandlerContext["respond"]; - onMatched?: () => Promise | void; -}): Promise; -export async function dispatchPluginInteractiveHandler(params: { - channel: "slack"; - data: string; - interactionId: string; - ctx: SlackInteractiveDispatchContext; - respond: PluginInteractiveSlackHandlerContext["respond"]; - onMatched?: () => Promise | void; -}): Promise; -export async function dispatchPluginInteractiveHandler(params: { - channel: "telegram" | "discord" | "slack"; - data: string; - callbackId?: string; - interactionId?: string; - ctx: - | TelegramInteractiveDispatchContext - | DiscordInteractiveDispatchContext - | SlackInteractiveDispatchContext; - respond: - | { - reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; - editMessage: (params: { - text: string; - buttons?: PluginInteractiveButtons; - }) => Promise; - editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; - clearButtons: () => Promise; - deleteMessage: () => Promise; - } - | PluginInteractiveDiscordHandlerContext["respond"] - | PluginInteractiveSlackHandlerContext["respond"]; + dedupeId?: string; onMatched?: () => Promise | void; + invoke: ( + match: PluginInteractiveMatch, + ) => Promise<{ handled?: boolean } | void> | { handled?: boolean } | void; }): Promise { const callbackDedupe = getCallbackDedupe(); - const match = resolvePluginInteractiveNamespaceMatch(params.channel, params.data); + const match = resolveNamespaceMatch(params.channel, params.data); if (!match) { return { matched: false, handled: false, duplicate: false }; } - const dedupeKey = - params.channel === "telegram" ? params.callbackId?.trim() : params.interactionId?.trim(); + const dedupeKey = params.dedupeId?.trim(); if (dedupeKey && callbackDedupe.peek(dedupeKey)) { return { matched: true, handled: true, duplicate: true }; } await params.onMatched?.(); - let result: - | ReturnType - | ReturnType - | ReturnType; - if (params.channel === "telegram") { - result = dispatchTelegramInteractiveHandler({ - registration: match.registration as RegisteredInteractiveHandler & - PluginInteractiveTelegramHandlerRegistration, - data: params.data, - namespace: match.namespace, - payload: match.payload, - ctx: params.ctx as TelegramInteractiveDispatchContext, - respond: params.respond as PluginInteractiveTelegramHandlerContext["respond"], - }); - } else if (params.channel === "discord") { - result = dispatchDiscordInteractiveHandler({ - registration: match.registration as RegisteredInteractiveHandler & - PluginInteractiveDiscordHandlerRegistration, - data: params.data, - namespace: match.namespace, - payload: match.payload, - ctx: params.ctx as DiscordInteractiveDispatchContext, - respond: params.respond as PluginInteractiveDiscordHandlerContext["respond"], - }); - } else { - result = dispatchSlackInteractiveHandler({ - registration: match.registration as RegisteredInteractiveHandler & - PluginInteractiveSlackHandlerRegistration, - data: params.data, - namespace: match.namespace, - payload: match.payload, - ctx: params.ctx as SlackInteractiveDispatchContext, - respond: params.respond as PluginInteractiveSlackHandlerContext["respond"], - }); - } - const resolved = await result; + const resolved = await params.invoke(match as PluginInteractiveMatch); if (dedupeKey) { callbackDedupe.check(dedupeKey); } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 51c59b0e9a4..cc55beb5c3a 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -375,6 +375,7 @@ export type PluginPackageInstall = { localPath?: string; defaultChoice?: "npm" | "local"; minHostVersion?: string; + allowInvalidConfigRecovery?: boolean; }; export type OpenClawPackageStartup = { diff --git a/src/plugins/runtime/runtime-registry-loader.ts b/src/plugins/runtime/runtime-registry-loader.ts new file mode 100644 index 00000000000..42060e67e24 --- /dev/null +++ b/src/plugins/runtime/runtime-registry-loader.ts @@ -0,0 +1,117 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import type { OpenClawConfig } from "../../config/config.js"; +import { loadConfig } from "../../config/config.js"; +import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; +import { createSubsystemLogger } from "../../logging.js"; +import { + resolveChannelPluginIds, + resolveConfiguredChannelPluginIds, +} from "../channel-plugin-ids.js"; +import { loadOpenClawPlugins } from "../loader.js"; +import { getActivePluginRegistry } from "../runtime.js"; +import type { PluginLogger } from "../types.js"; + +const log = createSubsystemLogger("plugins"); +let pluginRegistryLoaded: "none" | "configured-channels" | "channels" | "all" = "none"; + +export type PluginRegistryScope = "configured-channels" | "channels" | "all"; + +function scopeRank(scope: typeof pluginRegistryLoaded): number { + switch (scope) { + case "none": + return 0; + case "configured-channels": + return 1; + case "channels": + return 2; + case "all": + return 3; + } +} + +function activeRegistrySatisfiesScope( + scope: PluginRegistryScope, + active: ReturnType, + expectedChannelPluginIds: readonly string[], +): boolean { + if (!active) { + return false; + } + const activeChannelPluginIds = new Set(active.channels.map((entry) => entry.plugin.id)); + switch (scope) { + case "configured-channels": + case "channels": + return ( + active.channels.length > 0 && + expectedChannelPluginIds.every((pluginId) => activeChannelPluginIds.has(pluginId)) + ); + case "all": + return false; + } +} + +export function ensurePluginRegistryLoaded(options?: { + scope?: PluginRegistryScope; + config?: OpenClawConfig; + activationSourceConfig?: OpenClawConfig; + env?: NodeJS.ProcessEnv; +}): void { + const scope = options?.scope ?? "all"; + if (scopeRank(pluginRegistryLoaded) >= scopeRank(scope)) { + return; + } + const env = options?.env ?? process.env; + const baseConfig = options?.config ?? loadConfig(); + const autoEnabled = applyPluginAutoEnable({ config: baseConfig, env }); + const resolvedConfig = autoEnabled.config; + const workspaceDir = resolveAgentWorkspaceDir( + resolvedConfig, + resolveDefaultAgentId(resolvedConfig), + ); + const expectedChannelPluginIds = + scope === "configured-channels" + ? resolveConfiguredChannelPluginIds({ + config: resolvedConfig, + workspaceDir, + env, + }) + : scope === "channels" + ? resolveChannelPluginIds({ + config: resolvedConfig, + workspaceDir, + env, + }) + : []; + const active = getActivePluginRegistry(); + if ( + pluginRegistryLoaded === "none" && + activeRegistrySatisfiesScope(scope, active, expectedChannelPluginIds) + ) { + pluginRegistryLoaded = scope; + return; + } + const logger: PluginLogger = { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }; + loadOpenClawPlugins({ + config: resolvedConfig, + activationSourceConfig: options?.activationSourceConfig ?? baseConfig, + autoEnabledReasons: autoEnabled.autoEnabledReasons, + workspaceDir, + logger, + throwOnLoadError: true, + ...(scope === "configured-channels" || scope === "channels" + ? { onlyPluginIds: expectedChannelPluginIds } + : {}), + }); + pluginRegistryLoaded = scope; +} + +export const __testing = { + resetPluginRegistryLoadedForTests(): void { + pluginRegistryLoaded = "none"; + }, +}; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 41d13a40c52..66e7fe28a18 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -15,11 +15,7 @@ import type { ProviderRequestTransportOverrides } from "../agents/provider-reque import type { AnyAgentTool } from "../agents/tools/common.js"; import type { ThinkLevel } from "../auto-reply/thinking.js"; import type { ReplyPayload } from "../auto-reply/types.js"; -import type { - ChannelId, - ChannelPlugin, - ChannelStructuredComponents, -} from "../channels/plugins/types.js"; +import type { ChannelId, ChannelPlugin } from "../channels/plugins/types.js"; import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig, @@ -1661,12 +1657,11 @@ export type OpenClawPluginCommandDefinition = { */ nativeNames?: Partial> & { default?: string }; /** - * Optional Telegram-native progress placeholder text. - * When set, Telegram native/plugin command delivery sends this text - * immediately, then edits that same message in place if the final reply - * is a simple text-only payload. + * Optional native progress placeholder text for native command surfaces. + * `default` applies to all native providers unless a provider-specific + * override exists. */ - telegramNativeProgressMessage?: string; + nativeProgressMessages?: Partial> & { default?: string }; /** Description shown in /help and command menus */ description: string; /** Whether this command accepts arguments */ @@ -1677,169 +1672,25 @@ export type OpenClawPluginCommandDefinition = { handler: PluginCommandHandler; }; -export type PluginInteractiveChannel = "telegram" | "discord" | "slack"; - -export type PluginInteractiveButtons = Array< - Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }> ->; - -export type PluginInteractiveTelegramHandlerResult = { +export type PluginInteractiveHandlerResult = { handled?: boolean; } | void; -export type PluginInteractiveTelegramHandlerContext = { - channel: "telegram"; - accountId: string; - callbackId: string; - conversationId: string; - parentConversationId?: string; - senderId?: string; - senderUsername?: string; - threadId?: number; - isGroup: boolean; - isForum: boolean; - auth: { - isAuthorizedSender: boolean; - }; - callback: { - data: string; - namespace: string; - payload: string; - messageId: number; - chatId: string; - messageText?: string; - }; - respond: { - reply: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; - editMessage: (params: { text: string; buttons?: PluginInteractiveButtons }) => Promise; - editButtons: (params: { buttons: PluginInteractiveButtons }) => Promise; - clearButtons: () => Promise; - deleteMessage: () => Promise; - }; - requestConversationBinding: ( - params?: PluginConversationBindingRequestParams, - ) => Promise; - detachConversationBinding: () => Promise<{ removed: boolean }>; - getCurrentConversationBinding: () => Promise; -}; +type BivariantInteractiveHandler = { + bivarianceHack: (ctx: TContext) => Promise | TResult; +}["bivarianceHack"]; -export type PluginInteractiveDiscordHandlerResult = { - handled?: boolean; -} | void; - -export type PluginInteractiveDiscordHandlerContext = { - channel: "discord"; - accountId: string; - interactionId: string; - conversationId: string; - parentConversationId?: string; - guildId?: string; - senderId?: string; - senderUsername?: string; - auth: { - isAuthorizedSender: boolean; - }; - interaction: { - kind: "button" | "select" | "modal"; - data: string; - namespace: string; - payload: string; - messageId?: string; - values?: string[]; - fields?: Array<{ id: string; name: string; values: string[] }>; - }; - respond: { - acknowledge: () => Promise; - reply: (params: { text: string; ephemeral?: boolean }) => Promise; - followUp: (params: { text: string; ephemeral?: boolean }) => Promise; - editMessage: (params: { - text?: string; - components?: ChannelStructuredComponents; - }) => Promise; - clearComponents: (params?: { text?: string }) => Promise; - }; - requestConversationBinding: ( - params?: PluginConversationBindingRequestParams, - ) => Promise; - detachConversationBinding: () => Promise<{ removed: boolean }>; - getCurrentConversationBinding: () => Promise; -}; - -export type PluginInteractiveSlackHandlerResult = { - handled?: boolean; -} | void; - -export type PluginInteractiveSlackHandlerContext = { - channel: "slack"; - accountId: string; - interactionId: string; - conversationId: string; - parentConversationId?: string; - senderId?: string; - senderUsername?: string; - threadId?: string; - auth: { - isAuthorizedSender: boolean; - }; - interaction: { - kind: "button" | "select"; - data: string; - namespace: string; - payload: string; - actionId: string; - blockId?: string; - messageTs?: string; - threadTs?: string; - value?: string; - selectedValues?: string[]; - selectedLabels?: string[]; - triggerId?: string; - responseUrl?: string; - }; - respond: { - acknowledge: () => Promise; - reply: (params: { text: string; responseType?: "ephemeral" | "in_channel" }) => Promise; - followUp: (params: { - text: string; - responseType?: "ephemeral" | "in_channel"; - }) => Promise; - editMessage: (params: { text?: string; blocks?: unknown[] }) => Promise; - }; - requestConversationBinding: ( - params?: PluginConversationBindingRequestParams, - ) => Promise; - detachConversationBinding: () => Promise<{ removed: boolean }>; - getCurrentConversationBinding: () => Promise; -}; - -export type PluginInteractiveTelegramHandlerRegistration = { - channel: "telegram"; +export type PluginInteractiveRegistration< + TContext = unknown, + TChannel extends string = string, + TResult = PluginInteractiveHandlerResult, +> = { + channel: TChannel; namespace: string; - handler: ( - ctx: PluginInteractiveTelegramHandlerContext, - ) => Promise | PluginInteractiveTelegramHandlerResult; + handler: BivariantInteractiveHandler; }; -export type PluginInteractiveDiscordHandlerRegistration = { - channel: "discord"; - namespace: string; - handler: ( - ctx: PluginInteractiveDiscordHandlerContext, - ) => Promise | PluginInteractiveDiscordHandlerResult; -}; - -export type PluginInteractiveSlackHandlerRegistration = { - channel: "slack"; - namespace: string; - handler: ( - ctx: PluginInteractiveSlackHandlerContext, - ) => Promise | PluginInteractiveSlackHandlerResult; -}; - -export type PluginInteractiveHandlerRegistration = - | PluginInteractiveTelegramHandlerRegistration - | PluginInteractiveDiscordHandlerRegistration - | PluginInteractiveSlackHandlerRegistration; +export type PluginInteractiveHandlerRegistration = PluginInteractiveRegistration; export type OpenClawPluginHttpRouteAuth = "gateway" | "plugin"; export type OpenClawPluginHttpRouteMatch = "exact" | "prefix"; diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index 7a28eb405ac..e74ffc8469f 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -1,3 +1,4 @@ +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; import { normalizeAccountId } from "./account-id.js"; import { normalizeMessageChannel } from "./message-channel.js"; @@ -72,19 +73,20 @@ export function formatConversationTarget(params: { if (!channel || !conversationId) { return undefined; } - if (channel === "matrix") { - const parentConversationId = - typeof params.parentConversationId === "number" && - Number.isFinite(params.parentConversationId) - ? String(Math.trunc(params.parentConversationId)) - : typeof params.parentConversationId === "string" - ? params.parentConversationId.trim() - : undefined; - const roomId = - parentConversationId && parentConversationId !== conversationId - ? parentConversationId - : conversationId; - return `room:${roomId}`; + const parentConversationId = + typeof params.parentConversationId === "number" && Number.isFinite(params.parentConversationId) + ? String(Math.trunc(params.parentConversationId)) + : typeof params.parentConversationId === "string" + ? params.parentConversationId.trim() + : undefined; + const pluginTarget = normalizeChannelId(channel) + ? getChannelPlugin(normalizeChannelId(channel)!)?.messaging?.resolveDeliveryTarget?.({ + conversationId, + parentConversationId, + }) + : null; + if (pluginTarget?.to?.trim()) { + return pluginTarget.to.trim(); } return `channel:${conversationId}`; } @@ -94,7 +96,6 @@ export function resolveConversationDeliveryTarget(params: { conversationId?: string | number; parentConversationId?: string | number; }): { to?: string; threadId?: string } { - const to = formatConversationTarget(params); const channel = typeof params.channel === "string" ? (normalizeMessageChannel(params.channel) ?? params.channel.trim()) @@ -111,15 +112,22 @@ export function resolveConversationDeliveryTarget(params: { : typeof params.parentConversationId === "string" ? params.parentConversationId.trim() : undefined; - if ( - channel === "matrix" && - to && - conversationId && - parentConversationId && - parentConversationId !== conversationId - ) { - return { to, threadId: conversationId }; + const pluginTarget = + channel && conversationId + ? getChannelPlugin( + normalizeChannelId(channel) ?? channel, + )?.messaging?.resolveDeliveryTarget?.({ + conversationId, + parentConversationId, + }) + : null; + if (pluginTarget) { + return { + ...(pluginTarget.to?.trim() ? { to: pluginTarget.to.trim() } : {}), + ...(pluginTarget.threadId?.trim() ? { threadId: pluginTarget.threadId.trim() } : {}), + }; } + const to = formatConversationTarget(params); return { to }; }