From 084c4beb2e108182b4b3a2daa6474d5548288fba Mon Sep 17 00:00:00 2001 From: Matthew Schleder <107807123+MatthewSchleder@users.noreply.github.com> Date: Sat, 2 May 2026 08:01:07 -0400 Subject: [PATCH] fix(telegram): pass session files to native plugin commands Pass persisted session file context into Telegram native plugin commands so topic-scoped /codex bind can attach to the active OpenClaw session. Thanks @MatthewSchleder. Validation: - pnpm plugin-sdk:api:check - pnpm test extensions/telegram/src/bot-native-commands.session-meta.test.ts extensions/telegram/src/bot-native-commands.test.ts -- --reporter=verbose - OPENCLAW_TESTBOX=1 pnpm check:changed (tbx_01kqm8kzwkdxs2ntgck6vmyrgr) --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- .../bot-native-commands.session-meta.test.ts | 93 ++++++++++++++++++- .../telegram/src/bot-native-commands.ts | 50 ++++++++++ src/plugin-sdk/session-store-runtime.ts | 3 +- 5 files changed, 145 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc493178efb..8412a2ab15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/native commands: pass persisted session files into plugin commands for topic-bound sessions, so `/codex bind` works from Telegram forum topics. Refs #75845 and #76049. Thanks @MatthewSchleder. - Telegram: honor runtime conversation bindings for native slash commands in bound top-level groups, so commands like `/status@bot` route to the active non-`main` session instead of falling back to the default route. Fixes #75405; supersedes #75558. Thanks @ziptbm and @yfge. - Models CLI: restore `openclaw models list --provider ` catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji. - Gateway/macOS: write LaunchAgent services with a canonical system PATH and stop preserving old plist PATH entries, so Volta, asdf, fnm, and pnpm shell paths no longer affect gateway child-process Node resolution. Fixes #75233; supersedes #75246. Thanks @nphyde2. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index eda354d2b29..876fe9be0e5 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -1b91ea9cadcedacd0c7e7cf9ca2e48739bd8f99a107cb59ba8b0798d0729b374 plugin-sdk-api-baseline.json -f323d1b6e71b9e65555c13e22dcdad0cd9c9db24243dad4c7da27855d2b69888 plugin-sdk-api-baseline.jsonl +b0424fd44d888d28f7f4ab0f653e5ae37f6ae61aad298b759ea0531edccb4405 plugin-sdk-api-baseline.json +82a080f2ec0455f1496391dc35534545b07181655ef5d3845e8c86eda7979501 plugin-sdk-api-baseline.jsonl diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts index 1f211a56378..0ae9598d2aa 100644 --- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts +++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts @@ -27,6 +27,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited< >; type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies; type DeliverRepliesParams = Parameters[0]; +type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand; const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = { queuedFinal: false, @@ -45,11 +46,16 @@ const persistentBindingMocks = vi.hoisted(() => ({ const sessionMocks = vi.hoisted(() => ({ loadSessionStore: vi.fn(), recordSessionMetaFromInbound: vi.fn(), + resolveAndPersistSessionFile: vi.fn(), resolveStorePath: vi.fn(), })); const commandAuthMocks = vi.hoisted(() => ({ resolveCommandArgMenu: vi.fn(), })); +const pluginRuntimeMocks = vi.hoisted(() => ({ + executePluginCommand: vi.fn(async () => ({ text: "ok" })), + matchPluginCommand: vi.fn(() => null), +})); const replyMocks = vi.hoisted(() => ({ dispatchReplyWithBufferedBlockDispatcher: vi.fn( async () => dispatchReplyResult, @@ -148,6 +154,7 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => { return { ...actual, loadSessionStore: sessionMocks.loadSessionStore, + resolveAndPersistSessionFile: sessionMocks.resolveAndPersistSessionFile, resolveStorePath: sessionMocks.resolveStorePath, }; }); @@ -178,8 +185,8 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => { return { ...actual, getPluginCommandSpecs: vi.fn(() => []), - matchPluginCommand: vi.fn(() => null), - executePluginCommand: vi.fn(async () => ({ text: "ok" })), + matchPluginCommand: pluginRuntimeMocks.matchPluginCommand, + executePluginCommand: pluginRuntimeMocks.executePluginCommand, }; }); vi.mock("./bot/delivery.js", () => ({ @@ -192,6 +199,9 @@ vi.mock("./bot/delivery.replies.js", () => ({ let registerTelegramNativeCommands: typeof import("./bot-native-commands.js").registerTelegramNativeCommands; type TelegramCommandHandler = (ctx: unknown) => Promise; +type TelegramPluginCommandSpecs = ReturnType< + NonNullable +>; function registerAndResolveStatusHandler(params: { cfg: OpenClawConfig; @@ -233,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: { useAccessGroups: boolean; telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; + pluginCommandSpecs?: TelegramPluginCommandSpecs; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; @@ -246,6 +257,7 @@ function registerAndResolveCommandHandlerBase(params: { useAccessGroups, telegramCfg, resolveTelegramGroupConfig, + pluginCommandSpecs, } = params; const commandHandlers = new Map(); const sendMessage = vi.fn().mockResolvedValue(undefined); @@ -253,7 +265,7 @@ function registerAndResolveCommandHandlerBase(params: { getRuntimeConfig: vi.fn(() => cfg), readChannelAllowFromStore: vi.fn(async () => storeAllowFrom ?? []), dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher, - getPluginCommandSpecs: vi.fn(() => []), + getPluginCommandSpecs: vi.fn(() => pluginCommandSpecs ?? []), listSkillCommandsForAgents: vi.fn(() => []), syncTelegramMenuCommands: vi.fn(), }; @@ -292,6 +304,7 @@ function registerAndResolveCommandHandler(params: { useAccessGroups?: boolean; telegramCfg?: NativeCommandTestParams["telegramCfg"]; resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"]; + pluginCommandSpecs?: TelegramPluginCommandSpecs; }): { handler: TelegramCommandHandler; sendMessage: ReturnType; @@ -305,6 +318,7 @@ function registerAndResolveCommandHandler(params: { useAccessGroups, telegramCfg, resolveTelegramGroupConfig, + pluginCommandSpecs, } = params; return registerAndResolveCommandHandlerBase({ commandName, @@ -315,6 +329,7 @@ function registerAndResolveCommandHandler(params: { useAccessGroups: useAccessGroups ?? true, telegramCfg, resolveTelegramGroupConfig, + pluginCommandSpecs, }); } @@ -450,7 +465,22 @@ describe("registerTelegramNativeCommands — session metadata", () => { commandAuthMocks.resolveCommandArgMenu.mockClear(); sessionMocks.loadSessionStore.mockClear().mockReturnValue({}); sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined); + sessionMocks.resolveAndPersistSessionFile.mockClear().mockImplementation(async (params) => { + const sessionFile = + params.fallbackSessionFile ?? `/tmp/openclaw-sessions/${params.sessionId}.jsonl`; + return { + sessionFile, + sessionEntry: { + ...params.sessionEntry, + sessionId: params.sessionId, + sessionFile, + updatedAt: Date.now(), + }, + }; + }); sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json"); + pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" }); + pluginRuntimeMocks.matchPluginCommand.mockClear().mockReturnValue(null); replyMocks.dispatchReplyWithBufferedBlockDispatcher .mockClear() .mockResolvedValue(dispatchReplyResult); @@ -1019,4 +1049,61 @@ describe("registerTelegramNativeCommands — session metadata", () => { expectUnauthorizedNewCommandBlocked(sendMessage); }); + + it("passes a persisted topic session file to plugin commands", async () => { + sessionMocks.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions/sessions.json"); + sessionMocks.loadSessionStore.mockReturnValue({ + "agent:main:telegram:group:-1001234567890:topic:42": { + sessionId: "sess-topic", + updatedAt: 1, + }, + }); + + const { handler } = registerAndResolveCommandHandler({ + commandName: "codex", + cfg: { commands: { allowFrom: { telegram: ["200"] } } } as OpenClawConfig, + groupAllowFrom: ["-1001234567890"], + useAccessGroups: false, + pluginCommandSpecs: [ + { + name: "codex", + description: "Codex", + acceptsArgs: true, + }, + ] as TelegramPluginCommandSpecs, + }); + pluginRuntimeMocks.matchPluginCommand.mockReturnValue({ + command: { + name: "codex", + description: "Codex", + handler: vi.fn(), + pluginId: "openclaw-codex-app-server", + pluginName: "Codex", + requireAuth: true, + }, + args: "bind --cwd /tmp/work", + }); + + await handler( + createTelegramTopicCommandContext({ match: "bind --cwd /tmp/work", threadId: 42 }), + ); + + expect(sessionMocks.resolveAndPersistSessionFile).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sess-topic", + sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + storePath: "/tmp/openclaw-sessions/sessions.json", + sessionsDir: "/tmp/openclaw-sessions", + fallbackSessionFile: "/tmp/openclaw-sessions/sess-topic-topic-42.jsonl", + }), + ); + expect(pluginRuntimeMocks.executePluginCommand).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:telegram:group:-1001234567890:topic:42", + sessionId: "sess-topic", + sessionFile: "/tmp/openclaw-sessions/sess-topic-topic-42.jsonl", + messageThreadId: 42, + }), + ); + }); }); diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 5ea158d340d..52f6c14cf12 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -1,3 +1,5 @@ +import { randomUUID } from "node:crypto"; +import path from "node:path"; import type { Bot, Context } from "grammy"; import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime"; import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming"; @@ -34,7 +36,9 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { loadSessionStore, + resolveAndPersistSessionFile, resolveSessionStoreEntry, + resolveSessionTranscriptPathInDir, resolveStorePath, } from "openclaw/plugin-sdk/session-store-runtime"; import { @@ -157,6 +161,43 @@ function resolveTelegramProgressPlaceholder(command: { return text ? text : null; } +async function resolveTelegramCommandSessionFile(params: { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; + threadId?: string | number; +}): Promise<{ sessionId?: string; sessionFile?: string }> { + const sessionKey = params.sessionKey.trim(); + if (!sessionKey) { + return {}; + } + try { + const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }); + const store = loadSessionStore(storePath); + const resolved = resolveSessionStoreEntry({ store, sessionKey }); + const sessionId = resolved.existing?.sessionId?.trim() || randomUUID(); + const sessionsDir = path.dirname(storePath); + const fallbackSessionFile = resolveSessionTranscriptPathInDir( + sessionId, + sessionsDir, + params.threadId, + ); + const persisted = await resolveAndPersistSessionFile({ + sessionId, + sessionKey: resolved.normalizedKey, + sessionStore: store, + storePath, + sessionEntry: resolved.existing, + agentId: params.agentId, + sessionsDir, + fallbackSessionFile, + }); + return { sessionId, sessionFile: persisted.sessionFile }; + } catch { + return {}; + } +} + function resolveTelegramCommandMenuModelContext(params: { cfg: OpenClawConfig; agentId: string; @@ -1228,6 +1269,13 @@ export const registerTelegramNativeCommands = ({ } } + const sessionFileContext = await resolveTelegramCommandSessionFile({ + cfg: runtimeCfg, + agentId: route.agentId, + sessionKey: route.sessionKey, + threadId: threadSpec.id, + }); + const result = await nativeCommandRuntime.executePluginCommand({ command: match.command, args: match.args, @@ -1236,6 +1284,8 @@ export const registerTelegramNativeCommands = ({ isAuthorizedSender: commandAuthorized, senderIsOwner, sessionKey: route.sessionKey, + sessionId: sessionFileContext.sessionId, + sessionFile: sessionFileContext.sessionFile, commandBody, config: runtimeCfg, from, diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 177964ea54b..cae9de10352 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -2,7 +2,8 @@ export { loadSessionStore } from "../config/sessions/store-load.js"; export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js"; -export { resolveStorePath } from "../config/sessions/paths.js"; +export { resolveSessionTranscriptPathInDir, resolveStorePath } from "../config/sessions/paths.js"; +export { resolveAndPersistSessionFile } from "../config/sessions/session-file.js"; export { resolveSessionKey } from "../config/sessions/session-key.js"; export { resolveGroupSessionKey } from "../config/sessions/group.js"; export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";