diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb4b49b43b..124d75d84aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Channels/commands: make generated `/dock-*` commands switch the active session reply route through `session.identityLinks` instead of falling through to normal chat. Fixes #69206; carries forward #73033. Thanks @clawbones and @michaelatamuk. - Providers/Cloudflare AI Gateway: strip assistant prefill turns from Anthropic Messages payloads when thinking is enabled, so Claude requests through Cloudflare AI Gateway no longer fail Anthropic conversation-ending validation. Fixes #72905; carries forward #73005. Thanks @AaronFaby and @sahilsatralkar. - Gateway/startup: scope primary-model provider discovery during channel prewarm to the configured provider owner and add split startup trace timings, so boot avoids staging unrelated bundled provider dependencies while setup discovery remains broad. Fixes #73002. Thanks @Schnup03. - Channels/Microsoft Teams: unwrap staged CommonJS JWT runtime dependencies before Bot Connector token validation so inbound Teams messages no longer 401 after the bundled runtime-deps move. Fixes #73026. Thanks @kbrown10000. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 2632de49f46..c14ae13acd1 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -195,6 +195,8 @@ Dock commands are generated from channel plugins with native-command support. Cu - `/dock-slack` (alias: `/dock_slack`) - `/dock-telegram` (alias: `/dock_telegram`) +Use dock commands from a direct chat to switch the current session's reply route to another linked channel. The source sender and target peer must be in the same `session.identityLinks` group, for example `["telegram:123", "discord:456"]`. + ### Bundled plugin commands Bundled plugins can add more slash commands. Current bundled commands in this repo: diff --git a/src/auto-reply/reply/commands-dock.test.ts b/src/auto-reply/reply/commands-dock.test.ts new file mode 100644 index 00000000000..5f8c7cc9ea0 --- /dev/null +++ b/src/auto-reply/reply/commands-dock.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { + createChannelTestPluginBase, + createTestRegistry, +} from "../../test-utils/channel-plugins.js"; +import type { MsgContext } from "../templating.js"; +import { handleDockCommand } from "./commands-dock.js"; +import { buildCommandTestParams } from "./commands.test-harness.js"; + +function installDockCommandRegistry() { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "telegram", + plugin: createChannelTestPluginBase({ + id: "telegram", + capabilities: { nativeCommands: true, chatTypes: ["direct"] }, + config: { defaultAccountId: () => "primary" }, + }), + source: "test", + }, + { + pluginId: "discord", + plugin: createChannelTestPluginBase({ + id: "discord", + capabilities: { nativeCommands: true, chatTypes: ["direct"] }, + }), + source: "test", + }, + ]), + ); +} + +function buildDockParams(commandBody: string, ctxOverrides?: Partial) { + const sessionEntry = { + sessionId: "session-dock", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "42", + lastAccountId: "primary", + }; + const params = buildCommandTestParams( + commandBody, + { + commands: { text: true }, + session: { + identityLinks: { + alice: ["telegram:42", "discord:UserCase123"], + }, + }, + channels: { telegram: { allowFrom: ["*"] }, discord: { allowFrom: ["*"] } }, + }, + { + Provider: "telegram", + Surface: "telegram", + OriginatingChannel: "telegram", + SenderId: "42", + From: "42", + ...ctxOverrides, + }, + ); + params.sessionKey = "agent:main:main"; + params.sessionEntry = sessionEntry; + params.sessionStore = { [params.sessionKey]: sessionEntry }; + return params; +} + +describe("handleDockCommand", () => { + beforeEach(() => { + installDockCommandRegistry(); + }); + + afterEach(() => { + setActivePluginRegistry(createTestRegistry([])); + }); + + it("switches the current session route with the canonical dock command", async () => { + const params = buildDockParams("/dock-discord"); + + const result = await handleDockCommand(params, true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Docked replies to discord." }, + }); + expect(params.sessionStore?.[params.sessionKey]).toMatchObject({ + lastChannel: "discord", + lastTo: "UserCase123", + lastAccountId: "default", + }); + }); + + it("accepts generated underscore aliases such as Telegram native /dock_discord", async () => { + const params = buildDockParams("/dock_discord"); + + const result = await handleDockCommand(params, true); + + expect(result?.shouldContinue).toBe(false); + expect(params.sessionEntry?.lastChannel).toBe("discord"); + expect(params.sessionEntry?.lastTo).toBe("UserCase123"); + }); + + it("does not claim unrelated slash commands", async () => { + const result = await handleDockCommand(buildDockParams("/status"), true); + + expect(result).toBeNull(); + }); + + it("returns an identityLinks hint when no linked target exists", async () => { + const params = buildDockParams("/dock-discord", { SenderId: "404", From: "404" }); + + const result = await handleDockCommand(params, true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { + text: "Cannot dock to discord: add this sender and a discord:... peer to session.identityLinks.", + }, + }); + expect(params.sessionEntry?.lastChannel).toBe("telegram"); + }); + + it("fails closed when no session entry can be persisted", async () => { + const params = buildDockParams("/dock-discord"); + params.sessionEntry = undefined; + params.sessionStore = undefined; + + const result = await handleDockCommand(params, true); + + expect(result).toEqual({ + shouldContinue: false, + reply: { text: "Cannot dock to discord: no active session entry was found." }, + }); + }); + + it("ignores dock commands when text command handling is disabled", async () => { + const result = await handleDockCommand(buildDockParams("/dock-discord"), false); + + expect(result).toBeNull(); + }); +}); diff --git a/src/auto-reply/reply/commands-dock.ts b/src/auto-reply/reply/commands-dock.ts new file mode 100644 index 00000000000..ae972d8dca5 --- /dev/null +++ b/src/auto-reply/reply/commands-dock.ts @@ -0,0 +1,176 @@ +import { getActivePluginChannelRegistry } from "../../plugins/runtime.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../../shared/string-coerce.js"; +import { resolveTextCommand } from "../commands-registry.js"; +import { resolveCommandSurfaceChannel } from "./channel-context.js"; +import { persistSessionEntry } from "./commands-session-store.js"; +import type { CommandHandler, HandleCommandsParams } from "./commands-types.js"; + +const DOCK_KEY_PREFIX = "dock:"; + +type LinkedDockTarget = { + peerId: string; +}; + +function resolveDockCommandTarget(params: HandleCommandsParams): string | null { + const resolved = resolveTextCommand(params.command.commandBodyNormalized, params.cfg); + if (!resolved?.command.key.startsWith(DOCK_KEY_PREFIX)) { + return null; + } + if (resolved.command.category !== "docks") { + return null; + } + const target = normalizeLowercaseStringOrEmpty( + resolved.command.key.slice(DOCK_KEY_PREFIX.length), + ); + return target || null; +} + +function resolveTargetChannelAccountId( + params: HandleCommandsParams, + targetChannel: string, +): string { + const plugin = getActivePluginChannelRegistry()?.channels.find( + (entry) => normalizeLowercaseStringOrEmpty(entry.plugin.id) === targetChannel, + )?.plugin; + return normalizeOptionalString(plugin?.config.defaultAccountId?.(params.cfg)) || "default"; +} + +function collectSourcePeerCandidates(params: HandleCommandsParams): string[] { + return [ + params.ctx.NativeDirectUserId, + params.ctx.SenderId, + params.command.senderId, + params.ctx.SenderE164, + params.ctx.SenderUsername, + params.ctx.From, + params.command.from, + params.ctx.OriginatingTo, + params.ctx.To, + ] + .map((value) => normalizeOptionalString(value)) + .filter((value): value is string => Boolean(value)); +} + +function buildSourceIdentityCandidates( + params: HandleCommandsParams, + sourceChannel: string, +): Set { + const candidates = new Set(); + for (const peerId of collectSourcePeerCandidates(params)) { + const raw = normalizeLowercaseStringOrEmpty(peerId); + if (raw) { + candidates.add(raw); + } + if (sourceChannel) { + const scoped = normalizeLowercaseStringOrEmpty(`${sourceChannel}:${peerId}`); + if (scoped) { + candidates.add(scoped); + } + } + } + return candidates; +} + +function resolveLinkedDockTarget(params: { + identityLinks: Record | undefined; + sourceCandidates: Set; + targetChannel: string; +}): LinkedDockTarget | null { + if (!params.identityLinks || params.sourceCandidates.size === 0) { + return null; + } + const targetPrefix = `${params.targetChannel}:`; + for (const ids of Object.values(params.identityLinks)) { + if (!Array.isArray(ids)) { + continue; + } + const normalizedIds = ids.map((id) => normalizeLowercaseStringOrEmpty(id)).filter(Boolean); + if (!normalizedIds.some((id) => params.sourceCandidates.has(id))) { + continue; + } + for (const id of ids) { + const trimmed = normalizeOptionalString(id); + if (!trimmed) { + continue; + } + if (!normalizeLowercaseStringOrEmpty(trimmed).startsWith(targetPrefix)) { + continue; + } + return { + peerId: trimmed.slice(targetPrefix.length).trim(), + }; + } + } + return null; +} + +export const handleDockCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const targetChannel = resolveDockCommandTarget(params); + if (!targetChannel) { + return null; + } + if (!params.command.isAuthorizedSender) { + return { shouldContinue: false }; + } + + const sourceChannel = resolveCommandSurfaceChannel(params); + if (sourceChannel === targetChannel) { + return { + shouldContinue: false, + reply: { text: `Already docked to ${targetChannel}.` }, + }; + } + + const sourceCandidates = buildSourceIdentityCandidates(params, sourceChannel); + if (sourceCandidates.size === 0) { + return { + shouldContinue: false, + reply: { text: `Cannot dock to ${targetChannel}: sender id is unavailable.` }, + }; + } + + const target = resolveLinkedDockTarget({ + identityLinks: params.cfg.session?.identityLinks, + sourceCandidates, + targetChannel, + }); + if (!target?.peerId) { + return { + shouldContinue: false, + reply: { + text: `Cannot dock to ${targetChannel}: add this sender and a ${targetChannel}:... peer to session.identityLinks.`, + }, + }; + } + + const sessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry; + if (!sessionEntry || !params.sessionStore || !params.sessionKey) { + return { + shouldContinue: false, + reply: { text: `Cannot dock to ${targetChannel}: no active session entry was found.` }, + }; + } + + sessionEntry.lastChannel = targetChannel; + sessionEntry.lastTo = target.peerId; + sessionEntry.lastAccountId = resolveTargetChannelAccountId(params, targetChannel); + params.sessionEntry = sessionEntry; + const persisted = await persistSessionEntry(params); + if (!persisted) { + return { + shouldContinue: false, + reply: { text: `Cannot dock to ${targetChannel}: session route could not be saved.` }, + }; + } + + return { + shouldContinue: false, + reply: { text: `Docked replies to ${targetChannel}.` }, + }; +}; diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index d86a813194c..901cfdbbad9 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -7,6 +7,7 @@ import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; import { handleContextCommand } from "./commands-context-command.js"; import { handleCrestodianCommand } from "./commands-crestodian.js"; +import { handleDockCommand } from "./commands-dock.js"; import { handleCommandsListCommand, handleExportTrajectoryCommand, @@ -38,6 +39,7 @@ import { handleWhoamiCommand } from "./commands-whoami.js"; export function loadCommandHandlers(): CommandHandler[] { return [ handlePluginCommand, + handleDockCommand, handleBtwCommand, handleBashCommand, handleActivationCommand,