Skip to content

Commit 5db7c37

Browse files
goldmarsteipete
andauthored
Fix Telegram plugin callback routing (#97174)
* fix telegram plugin callback routing * Add Telegram callback proof coverage * Fix proof script lint * Fix Telegram proof CI regressions * test: fix telegram callback proof CI regressions * fix: retire registry-owned callbacks * fix: retire registry-owned callbacks --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent d0b30e0 commit 5db7c37

12 files changed

Lines changed: 223 additions & 9 deletions

docs/channels/telegram.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,8 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
598598
Telegram `web_app` buttons work only in private chats between a user and the
599599
bot.
600600

601-
Callback clicks are passed to the agent as text:
601+
Callback clicks that are not claimed by a registered plugin interactive
602+
handler are passed to the agent as text:
602603
`callback_data: <value>`
603604

604605
</Accordion>

extensions/telegram/src/bot.create-telegram-bot.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { TelegramGetChat } from "./bot/types.js";
1313
import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
1414
const harness = await import("./bot.create-telegram-bot.test-harness.js");
1515
const pluginStateTestRuntime = await import("openclaw/plugin-sdk/plugin-state-test-runtime");
16+
const pluginRuntime = await import("openclaw/plugin-sdk/plugin-runtime");
1617
const conversationRuntime = await import("openclaw/plugin-sdk/conversation-runtime");
1718
const configMutation = await import("openclaw/plugin-sdk/config-mutation");
1819
const sessionStoreRuntime = await import("openclaw/plugin-sdk/session-store-runtime");
@@ -233,6 +234,7 @@ describe("createTelegramBot", () => {
233234
});
234235
afterEach(() => {
235236
pluginStateTestRuntime.resetPluginStateStoreForTests();
237+
pluginRuntime.clearPluginInteractiveHandlers();
236238
if (previousStateDir === undefined) {
237239
delete process.env.OPENCLAW_STATE_DIR;
238240
} else {
@@ -246,6 +248,7 @@ describe("createTelegramBot", () => {
246248
previousStateDir = process.env.OPENCLAW_STATE_DIR;
247249
process.env.OPENCLAW_STATE_DIR = createTelegramBotTestStateDir();
248250
resetTelegramForumFlagCacheForTest();
251+
pluginRuntime.clearPluginInteractiveHandlers();
249252
clearAccountThrottlersForTest();
250253
throttlerSpy.mockReset();
251254
setTelegramBotRuntimeForTest(
@@ -1280,6 +1283,56 @@ describe("createTelegramBot", () => {
12801283
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-1");
12811284
});
12821285

1286+
it("routes plugin callback_query payloads to plugin handlers without fallback callback_data text", async () => {
1287+
const pluginHandler = vi.fn(async (ctx) => {
1288+
expect(ctx.callback.namespace).toBe("code-agent");
1289+
expect(ctx.callback.payload).toBe("approve-123");
1290+
await ctx.respond.clearButtons();
1291+
return { handled: true };
1292+
});
1293+
expect(
1294+
pluginRuntime.registerPluginInteractiveHandler("openclaw-code-agent", {
1295+
channel: "telegram",
1296+
namespace: "code-agent",
1297+
handler: pluginHandler,
1298+
}),
1299+
).toEqual({ ok: true });
1300+
1301+
createTelegramBot({ token: "tok" });
1302+
const callbackHandler = requireValue(
1303+
onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as
1304+
| ((ctx: Record<string, unknown>) => Promise<void>)
1305+
| undefined,
1306+
"callback_query handler",
1307+
);
1308+
1309+
await callbackHandler({
1310+
callbackQuery: {
1311+
id: "cbq-plugin-1",
1312+
data: "code-agent:approve-123",
1313+
from: { id: 9, first_name: "Ada", username: "ada_bot" },
1314+
message: {
1315+
chat: { id: 1234, type: "private" },
1316+
date: 1736380800,
1317+
message_id: 10,
1318+
reply_markup: {
1319+
inline_keyboard: [[{ text: "Approve", callback_data: "code-agent:approve-123" }]],
1320+
},
1321+
text: "Approve this code-agent action?",
1322+
},
1323+
},
1324+
me: { username: "openclaw_bot" },
1325+
getFile: async () => ({ download: async () => new Uint8Array() }),
1326+
});
1327+
1328+
expect(pluginHandler).toHaveBeenCalledTimes(1);
1329+
expect(replySpy).not.toHaveBeenCalled();
1330+
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(1234, 10, {
1331+
reply_markup: { inline_keyboard: [] },
1332+
});
1333+
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-plugin-1");
1334+
});
1335+
12831336
it("preserves raw slash callback_query payloads as command text", async () => {
12841337
createTelegramBot({ token: "tok" });
12851338
const callbackHandler = requireValue(

src/plugins/interactive-registry.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,30 @@ export function resolvePluginInteractiveNamespaceMatch(
3232
});
3333
}
3434

35+
/** Resolves a handler from registry-owned registrations without changing global state. */
36+
export function resolvePluginInteractiveRegistrationsMatch(
37+
registrations: readonly RegisteredInteractiveHandler[],
38+
channel: string,
39+
data: string,
40+
): { registration: RegisteredInteractiveHandler; namespace: string; payload: string } | null {
41+
return resolvePluginInteractiveMatch({
42+
interactiveHandlers: {
43+
get: (key) =>
44+
registrations.find(
45+
(registration) =>
46+
toPluginInteractiveRegistryKey(registration.channel, registration.namespace) === key,
47+
),
48+
},
49+
channel,
50+
data,
51+
});
52+
}
53+
3554
/** Registers one plugin interactive namespace for a channel. */
36-
export function registerPluginInteractiveHandler(
55+
function registerPluginInteractiveHandlerWithOptions(
3756
pluginId: string,
3857
registration: PluginInteractiveHandlerRegistration,
39-
opts?: { pluginName?: string; pluginRoot?: string },
58+
opts?: { pluginName?: string; pluginRoot?: string; registryOwned?: true },
4059
): InteractiveRegistrationResult {
4160
const interactiveHandlers = getPluginInteractiveHandlersState();
4261
const namespace = normalizePluginInteractiveNamespace(registration.namespace);
@@ -59,10 +78,32 @@ export function registerPluginInteractiveHandler(
5978
pluginId,
6079
pluginName: opts?.pluginName,
6180
pluginRoot: opts?.pluginRoot,
81+
registryOwned: opts?.registryOwned,
6282
});
6383
return { ok: true };
6484
}
6585

86+
/** Registers one process-global interactive handler. */
87+
export function registerPluginInteractiveHandler(
88+
pluginId: string,
89+
registration: PluginInteractiveHandlerRegistration,
90+
opts?: { pluginName?: string; pluginRoot?: string },
91+
): InteractiveRegistrationResult {
92+
return registerPluginInteractiveHandlerWithOptions(pluginId, registration, opts);
93+
}
94+
95+
/** Registers one handler whose lifetime follows its owning plugin registry. */
96+
export function registerRegistryPluginInteractiveHandler(
97+
pluginId: string,
98+
registration: PluginInteractiveHandlerRegistration,
99+
opts?: { pluginName?: string; pluginRoot?: string },
100+
): InteractiveRegistrationResult {
101+
return registerPluginInteractiveHandlerWithOptions(pluginId, registration, {
102+
...opts,
103+
registryOwned: true,
104+
});
105+
}
106+
66107
/** Clears all active plugin interactive handlers. */
67108
export function clearPluginInteractiveHandlers(): void {
68109
clearPluginInteractiveHandlersState();

src/plugins/interactive-shared.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function validatePluginInteractiveNamespace(namespace: string): string |
2020
}
2121

2222
export function resolvePluginInteractiveMatch<TRegistration>(params: {
23-
interactiveHandlers: Map<string, TRegistration>;
23+
interactiveHandlers: Pick<ReadonlyMap<string, TRegistration>, "get">;
2424
channel: string;
2525
data: string;
2626
}): { registration: TRegistration; namespace: string; payload: string } | null {

src/plugins/interactive-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type RegisteredInteractiveHandler = PluginInteractiveHandlerRegistration
88
pluginId: string;
99
pluginName?: string;
1010
pluginRoot?: string;
11+
registryOwned?: true;
1112
};
1213

1314
type InteractiveState = {

src/plugins/interactive.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,14 @@ import type {
1010
TelegramInteractiveHandlerContext,
1111
TelegramInteractiveHandlerRegistration,
1212
} from "./interactive-contract.test-helpers.js";
13+
import { registerRegistryPluginInteractiveHandler } from "./interactive-registry.js";
1314
import {
1415
clearPluginInteractiveHandlers,
1516
dispatchPluginInteractiveHandler,
1617
registerPluginInteractiveHandler,
1718
} from "./interactive.js";
19+
import { createEmptyPluginRegistry } from "./registry-empty.js";
20+
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
1821

1922
let requestPluginConversationBindingMock: MockInstance<
2023
typeof conversationBinding.requestPluginConversationBinding
@@ -483,6 +486,7 @@ describe("plugin interactive handlers", () => {
483486

484487
afterEach(() => {
485488
vi.restoreAllMocks();
489+
resetPluginRuntimeStateForTest();
486490
});
487491

488492
it("hydrates legacy interactive state shapes before clearing handlers", async () => {
@@ -645,6 +649,66 @@ describe("plugin interactive handlers", () => {
645649
second.clearPluginInteractiveHandlers();
646650
});
647651

652+
it("resolves active registry handlers without retaining them after retirement", async () => {
653+
const handler = vi.fn(async () => ({ handled: true }));
654+
const registry = createEmptyPluginRegistry();
655+
registry.plugins.push({
656+
id: "openclaw-code-agent",
657+
name: "OpenClaw Code Agent",
658+
status: "loaded",
659+
} as never);
660+
registry.interactiveHandlers = [
661+
{
662+
channel: "telegram",
663+
namespace: "code-agent",
664+
pluginId: "openclaw-code-agent",
665+
pluginName: "OpenClaw Code Agent",
666+
pluginRoot: "/plugins/openclaw-code-agent",
667+
handler: handler as never,
668+
},
669+
];
670+
expect(
671+
registerRegistryPluginInteractiveHandler(
672+
"openclaw-code-agent",
673+
{
674+
channel: "telegram",
675+
namespace: "code-agent",
676+
handler: handler as never,
677+
},
678+
{
679+
pluginName: "OpenClaw Code Agent",
680+
pluginRoot: "/plugins/openclaw-code-agent",
681+
},
682+
),
683+
).toEqual({ ok: true });
684+
setActivePluginRegistry(registry);
685+
686+
await expect(
687+
dispatchInteractive(
688+
createTelegramDispatchParams({
689+
data: "code-agent:7506a349-84c8-4c56-8558-ce315bed2588",
690+
callbackId: "cb-code-agent-restored",
691+
}),
692+
),
693+
).resolves.toEqual({ matched: true, handled: true, duplicate: false });
694+
695+
expect(handler).toHaveBeenCalledTimes(1);
696+
const ctx = requireHandlerCall(handler) as TelegramInteractiveHandlerContext;
697+
expect(ctx.callback.namespace).toBe("code-agent");
698+
expect(ctx.callback.payload).toBe("7506a349-84c8-4c56-8558-ce315bed2588");
699+
700+
setActivePluginRegistry(createEmptyPluginRegistry());
701+
await expect(
702+
dispatchInteractive(
703+
createTelegramDispatchParams({
704+
data: "code-agent:7506a349-84c8-4c56-8558-ce315bed2588",
705+
callbackId: "cb-code-agent-retired",
706+
}),
707+
),
708+
).resolves.toEqual({ matched: false, handled: false, duplicate: false });
709+
expect(handler).toHaveBeenCalledTimes(1);
710+
});
711+
648712
it("rejects duplicate namespace registrations", () => {
649713
const first = registerPluginInteractiveHandler("plugin-a", {
650714
channel: "telegram",

src/plugins/interactive.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
// Resolves interactive plugin entries from registry metadata.
2-
import { resolvePluginInteractiveNamespaceMatch } from "./interactive-registry.js";
2+
import {
3+
resolvePluginInteractiveNamespaceMatch,
4+
resolvePluginInteractiveRegistrationsMatch,
5+
} from "./interactive-registry.js";
36
import {
47
claimPluginInteractiveCallbackDedupe,
58
commitPluginInteractiveCallbackDedupe,
69
releasePluginInteractiveCallbackDedupe,
710
type RegisteredInteractiveHandler,
811
} from "./interactive-state.js";
12+
import { collectLivePluginRegistries } from "./runtime.js";
913

1014
type InteractiveDispatchResult<TResult = unknown> =
1115
| { matched: false; handled: false; duplicate: false }
@@ -30,6 +34,27 @@ export {
3034
} from "./interactive-registry.js";
3135
export type { InteractiveRegistrationResult } from "./interactive-registry.js";
3236

37+
function resolveLivePluginInteractiveNamespaceMatch(channel: string, data: string) {
38+
const existing = resolvePluginInteractiveNamespaceMatch(channel, data);
39+
if (existing && existing.registration.registryOwned !== true) {
40+
return existing;
41+
}
42+
43+
// Registry membership is lifecycle-owned. Resolve registry registrations only
44+
// through live owners so a replaced or released registry cannot keep executing.
45+
for (const registry of collectLivePluginRegistries()) {
46+
const match = resolvePluginInteractiveRegistrationsMatch(
47+
registry.interactiveHandlers ?? [],
48+
channel,
49+
data,
50+
);
51+
if (match) {
52+
return match;
53+
}
54+
}
55+
return null;
56+
}
57+
3358
/** Dispatches one interactive callback payload to a matching plugin handler. */
3459
export async function dispatchPluginInteractiveHandler<
3560
TRegistration extends PluginInteractiveDispatchRegistration,
@@ -41,7 +66,7 @@ export async function dispatchPluginInteractiveHandler<
4166
onMatched?: () => Promise<void> | void;
4267
invoke: (match: PluginInteractiveMatch<TRegistration>) => Promise<TResult> | TResult;
4368
}): Promise<InteractiveDispatchResult<TResult>> {
44-
const match = resolvePluginInteractiveNamespaceMatch(params.channel, params.data);
69+
const match = resolveLivePluginInteractiveNamespaceMatch(params.channel, params.data);
4570
if (!match) {
4671
return { matched: false, handled: false, duplicate: false };
4772
}

src/plugins/loader.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3208,6 +3208,7 @@ module.exports = { id: "throws-after-import", register() {} };`,
32083208
expect(registry.nodeHostCommands).toStrictEqual([]);
32093209
expect(registry.nodeInvokePolicies).toStrictEqual([]);
32103210
expect(registry.securityAuditCollectors).toStrictEqual([]);
3211+
expect(registry.interactiveHandlers).toStrictEqual([]);
32113212
expect(resolvePluginInteractiveNamespaceMatch("slack", "failme:payload")).toBeNull();
32123213
expect(getContextEngineFactory("failme-context")).toBeUndefined();
32133214
expect(listContextEngineIds()).not.toContain("failme-context");
@@ -3828,10 +3829,17 @@ module.exports = { id: "throws-after-import", register() {} };`,
38283829
onlyPluginIds: ["cached-command-interactive"],
38293830
} satisfies Parameters<typeof loadOpenClawPlugins>[0];
38303831

3831-
loadOpenClawPlugins(loadOptions);
3832+
const registry = loadOpenClawPlugins(loadOptions);
38323833
expect(getPluginCommandSpecs()).toEqual([
38333834
{ name: "hue", description: "Control Hue lights", acceptsArgs: false },
38343835
]);
3836+
expect(registry.interactiveHandlers).toEqual([
3837+
expect.objectContaining({
3838+
channel: "telegram",
3839+
namespace: "hue",
3840+
pluginId: "cached-command-interactive",
3841+
}),
3842+
]);
38353843
const match = resolvePluginInteractiveNamespaceMatch("telegram", "hue:on");
38363844
expect(match?.namespace).toBe("hue");
38373845
expect(match?.payload).toBe("on");

src/plugins/loader.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ type PluginRegistrySnapshot = {
489489
securityAuditCollectors: NonNullable<PluginRegistry["securityAuditCollectors"]>;
490490
services: PluginRegistry["services"];
491491
commands: PluginRegistry["commands"];
492+
interactiveHandlers: NonNullable<PluginRegistry["interactiveHandlers"]>;
492493
sessionActions: NonNullable<PluginRegistry["sessionActions"]>;
493494
conversationBindingResolvedHandlers: PluginRegistry["conversationBindingResolvedHandlers"];
494495
diagnostics: PluginRegistry["diagnostics"];
@@ -535,6 +536,7 @@ function snapshotPluginRegistry(registry: PluginRegistry): PluginRegistrySnapsho
535536
securityAuditCollectors: [...(registry.securityAuditCollectors ?? [])],
536537
services: [...registry.services],
537538
commands: [...registry.commands],
539+
interactiveHandlers: [...(registry.interactiveHandlers ?? [])],
538540
sessionActions: [...(registry.sessionActions ?? [])],
539541
conversationBindingResolvedHandlers: [...registry.conversationBindingResolvedHandlers],
540542
diagnostics: [...registry.diagnostics],
@@ -580,6 +582,7 @@ function restorePluginRegistry(registry: PluginRegistry, snapshot: PluginRegistr
580582
registry.securityAuditCollectors = snapshot.arrays.securityAuditCollectors;
581583
registry.services = snapshot.arrays.services;
582584
registry.commands = snapshot.arrays.commands;
585+
registry.interactiveHandlers = snapshot.arrays.interactiveHandlers;
583586
registry.sessionActions = snapshot.arrays.sessionActions;
584587
registry.conversationBindingResolvedHandlers =
585588
snapshot.arrays.conversationBindingResolvedHandlers;

src/plugins/registry-empty.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
4242
services: [],
4343
gatewayDiscoveryServices: [],
4444
commands: [],
45+
interactiveHandlers: [],
4546
sessionExtensions: [],
4647
trustedToolPolicies: [],
4748
toolMetadata: [],

0 commit comments

Comments
 (0)