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)
This commit is contained in:
Matthew Schleder
2026-05-02 08:01:07 -04:00
committed by GitHub
parent 3980eaa1c2
commit 084c4beb2e
5 changed files with 145 additions and 6 deletions

View File

@@ -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 <id>` 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.

View File

@@ -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

View File

@@ -27,6 +27,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
>;
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[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<MatchPluginCommandFn>(() => null),
}));
const replyMocks = vi.hoisted(() => ({
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
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<void>;
type TelegramPluginCommandSpecs = ReturnType<
NonNullable<TelegramNativeCommandDeps["getPluginCommandSpecs"]>
>;
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<typeof vi.fn>;
@@ -246,6 +257,7 @@ function registerAndResolveCommandHandlerBase(params: {
useAccessGroups,
telegramCfg,
resolveTelegramGroupConfig,
pluginCommandSpecs,
} = params;
const commandHandlers = new Map<string, TelegramCommandHandler>();
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<typeof vi.fn>;
@@ -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,
}),
);
});
});

View File

@@ -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,

View File

@@ -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";