[codex] Allow reply_payload_sending to add portable buttons (#98922)

* fix(telegram): retry plugin callback submit text

* fix(telegram): harden plugin callback submit text

* fix(telegram): preserve plugin callback submit semantics

* fix(telegram): release callback dedupe after submit failure

* fix(telegram): settle skipped plugin callback submissions

* style(telegram): format public API exports

* chore: leave changelog to release generation

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Mark 2026-07-06 12:17:41 -07:00 committed by GitHub
parent 06efc2ae41
commit db38127c22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 549 additions and 7 deletions

View file

@ -187,6 +187,12 @@ guidance remain available to non-Codex prompt surfaces for compatibility.
| `api.registerNodeInvokePolicy(policy)` | Allowlist/approval policy for node-invoked commands |
| `api.registerSecurityAuditCollector(collector)` | Findings collector for `openclaw security audit` |
Telegram interactive handlers can return `{ submitText }` to route text through
Telegram's normal inbound agent path after the handler succeeds. OpenClaw keeps
the callback button when inbound policy skips the text or processing fails, so
the user can retry after the blocking condition changes. This result field is
Telegram-specific; other channels keep their own interactive result contracts.
### Host hooks for workflow plugins
Host hooks are the SDK seams for plugins that need to participate in the host

View file

@ -1,8 +1,5 @@
// Telegram API module exposes the plugin public contract.
export type {
Message as TelegramBotMessage,
Update as TelegramBotUpdate,
} from "grammy/types";
export type { Message as TelegramBotMessage, Update as TelegramBotUpdate } from "grammy/types";
export { telegramPlugin } from "./src/channel.js";
export { telegramSetupPlugin } from "./src/channel.setup.js";
export {
@ -109,6 +106,7 @@ export {
export type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
TelegramInteractiveHandlerResult,
} from "./src/interactive-dispatch.js";
export {
isTelegramInlineButtonsEnabled,

View file

@ -15,4 +15,5 @@ export {
export type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
TelegramInteractiveHandlerResult,
} from "./src/interactive-dispatch.js";

View file

@ -1743,6 +1743,75 @@ export const registerTelegramHandlers = ({
const isPermanentTelegramCallbackEditError = (err: unknown): boolean =>
isTelegramEditTargetMissingError(err) || isTelegramMessageHasNoTextError(err);
const TELEGRAM_PLUGIN_CALLBACK_SUBMIT_RETRY_DELAYS_MS = [250, 1000, 2500] as const;
const REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE = /reply session initialization conflicted for \S+/u;
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
const resolvePluginCallbackSubmitText = (submitText: unknown): string | undefined => {
if (typeof submitText !== "string") {
return undefined;
}
const trimmed = submitText.trim();
return trimmed ? trimmed : undefined;
};
const isReplySessionInitConflictError = (err: unknown): boolean =>
REPLY_SESSION_INIT_CONFLICT_MESSAGE_RE.test(String(err instanceof Error ? err.message : err));
const isReplySessionInitConflictResult = (result: TelegramMessageProcessingResult): boolean =>
result.kind === "failed-retryable" && isReplySessionInitConflictError(result.error);
const processPluginCallbackSubmitText = async (params: {
callbackId: string;
syntheticCtx: Parameters<typeof processMessageWithReplyChain>[0]["ctx"];
syntheticMessage: Parameters<typeof processMessageWithReplyChain>[0]["msg"];
storeAllowFrom: Parameters<typeof processMessageWithReplyChain>[0]["storeAllowFrom"];
}): Promise<"completed" | "skipped"> => {
for (let attempt = 0; ; attempt++) {
try {
const result = await processMessageWithReplyChain({
ctx: params.syntheticCtx,
msg: params.syntheticMessage,
allMedia: [],
storeAllowFrom: params.storeAllowFrom,
options: {
spooledReplay: true,
forceWasMentioned: true,
messageIdOverride: params.callbackId,
},
});
if (result.kind === "completed") {
return "completed";
}
if (result.kind === "skipped") {
return "skipped";
}
const retryDelayMs = TELEGRAM_PLUGIN_CALLBACK_SUBMIT_RETRY_DELAYS_MS[attempt];
if (!isReplySessionInitConflictResult(result) || retryDelayMs === undefined) {
throw new TelegramRetryableCallbackError(result.error);
}
logVerbose(
`telegram plugin callback submitText hit active reply session; retrying in ${retryDelayMs}ms`,
);
await sleep(retryDelayMs);
continue;
} catch (err) {
const retryDelayMs = TELEGRAM_PLUGIN_CALLBACK_SUBMIT_RETRY_DELAYS_MS[attempt];
if (!isReplySessionInitConflictError(err) || retryDelayMs === undefined) {
throw err;
}
logVerbose(
`telegram plugin callback submitText hit active reply session; retrying in ${retryDelayMs}ms`,
);
await sleep(retryDelayMs);
}
}
};
const resolveTelegramEventAuthorizationContext = async (params: {
chatId: number;
isGroup: boolean;
@ -2676,6 +2745,37 @@ export const registerTelegramHandlers = ({
await deleteCallbackMessage();
},
},
afterInvoke: async (result) => {
if (result?.handled === false) {
return;
}
const submitText = resolvePluginCallbackSubmitText(result?.submitText);
if (!submitText) {
return;
}
const { ctx: syntheticCtx, message: syntheticMessage } =
buildCallbackSyntheticTextContext({
ctx,
callbackMessage,
callback,
text: submitText,
isForum,
});
const submitOutcome = await processPluginCallbackSubmitText({
callbackId: callback.id,
syntheticCtx,
syntheticMessage,
storeAllowFrom,
});
if (submitOutcome === "skipped") {
return;
}
// The agent turn already completed. Cleanup failure must not release
// callback dedupe and replay the submitted turn.
await clearCallbackButtons().catch((err: unknown) => {
logVerbose(`telegram plugin callback button cleanup skipped: ${String(err)}`);
});
},
});
if (pluginCallback.handled) {
return;

View file

@ -18,7 +18,10 @@ import {
resolveTelegramConversationBaseSessionKey,
resolveTelegramConversationRoute,
} from "./conversation-route.js";
import type { TelegramInteractiveHandlerContext } from "./interactive-dispatch.js";
import type {
TelegramInteractiveHandlerContext,
TelegramInteractiveHandlerRegistration,
} from "./interactive-dispatch.js";
import { buildTelegramOpaqueCallbackData } from "./native-command-callback-data.js";
import { clearTelegramRuntime, setTelegramRuntime } from "./runtime.js";
import type { TelegramRuntime } from "./runtime.types.js";
@ -90,6 +93,28 @@ function waitForReplyCalls(count: number) {
return done.promise;
}
function setTelegramPluginStateRuntimeForTests() {
setTelegramRuntime({
state: {
openKeyedStore: ((options) =>
createPluginStateKeyedStoreForTests(
"telegram",
options,
)) as TelegramRuntime["state"]["openKeyedStore"],
openSyncKeyedStore: ((options) =>
createPluginStateSyncKeyedStoreForTests(
"telegram",
options,
)) as TelegramRuntime["state"]["openSyncKeyedStore"],
},
channel: {},
} as TelegramRuntime);
}
function getTelegramCallbackHandlerForTests() {
return getOnHandler("callback_query") as (ctx: Record<string, unknown>) => Promise<void>;
}
async function loadEnvelopeTimestampHelpers() {
return await import("openclaw/plugin-sdk/channel-test-helpers");
}
@ -3959,6 +3984,360 @@ describe("createTelegramBot", () => {
expect(replySpy).not.toHaveBeenCalled();
});
it("submits plugin-owned callback text through the Telegram inbound path", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
const replyDone = waitForReplyCalls(1);
registerPluginInteractiveHandler("smart-replies-plugin", {
channel: "telegram",
namespace: "openclaw-smart-replies",
handler: async () => ({ handled: true, submitText: "Fix a broken tool" }),
} satisfies TelegramInteractiveHandlerRegistration);
setTelegramPluginStateRuntimeForTests();
try {
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
});
const callbackHandler = getTelegramCallbackHandlerForTests();
await callbackHandler({
callbackQuery: {
id: "cbq-smart-reply-submit",
data: "openclaw-smart-replies:v1:Rm14IGEgYnJva2VuIHRvb2w",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 9, type: "private" },
date: 1736380800,
message_id: 11,
text: "What should I help you sharpen next?",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await replyDone;
} finally {
clearTelegramRuntime();
}
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, {
reply_markup: { inline_keyboard: [] },
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("Fix a broken tool");
expect(payload.SenderId).toBe("9");
expect(payload.SenderUsername).toBe("ada_bot");
});
it("does not submit plugin-owned callback text when the handler declines the callback", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
const handler = vi.fn(async () => ({ handled: false, submitText: "Ignore this" }));
registerPluginInteractiveHandler("smart-replies-plugin", {
channel: "telegram",
namespace: "openclaw-smart-replies",
handler,
} satisfies TelegramInteractiveHandlerRegistration);
setTelegramPluginStateRuntimeForTests();
try {
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
});
const callbackHandler = getTelegramCallbackHandlerForTests();
await callbackHandler({
callbackQuery: {
id: "cbq-smart-reply-declined-submit",
data: "openclaw-smart-replies:v1:SWdub3JlIHRoaXM",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 9, type: "private" },
date: 1736380800,
message_id: 11,
text: "Pick a direction",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
} finally {
clearTelegramRuntime();
}
expect(handler).toHaveBeenCalledTimes(1);
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("callback_data: openclaw-smart-replies");
expect(payload.Body).not.toContain("Ignore this");
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
});
it("does not retry plugin-owned callback text skipped by inbound policy", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
const handler = vi.fn(async () => ({ handled: true, submitText: "Do not submit this" }));
registerPluginInteractiveHandler("smart-replies-plugin", {
channel: "telegram",
namespace: "openclaw-smart-replies",
handler,
} satisfies TelegramInteractiveHandlerRegistration);
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "disabled",
capabilities: { inlineButtons: "dm" },
},
},
},
});
const callbackHandler = getTelegramCallbackHandlerForTests();
const callbackContext = {
callbackQuery: {
id: "cbq-smart-reply-policy-skip",
data: "openclaw-smart-replies:v1:RG8gbm90IHN1Ym1pdCB0aGlz",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 9, type: "private" },
date: 1736380800,
message_id: 11,
text: "Pick a direction",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
};
await expect(callbackHandler(callbackContext)).resolves.toBeUndefined();
await expect(callbackHandler(callbackContext)).resolves.toBeUndefined();
expect(handler).toHaveBeenCalledOnce();
expect(replySpy).not.toHaveBeenCalled();
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
});
it("submits plugin-owned callback text in mention-required group topics", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
const replyDone = waitForReplyCalls(1);
registerPluginInteractiveHandler("smart-replies-plugin", {
channel: "telegram",
namespace: "openclaw-smart-replies",
handler: async () => ({ handled: true, submitText: "Investigate topic callback" }),
} satisfies TelegramInteractiveHandlerRegistration);
setTelegramPluginStateRuntimeForTests();
try {
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
capabilities: { inlineButtons: "group" },
groupPolicy: "open",
groups: { "*": { requireMention: true } },
},
},
},
});
const callbackHandler = getTelegramCallbackHandlerForTests();
await callbackHandler({
callbackQuery: {
id: "cbq-smart-reply-topic-submit",
data: "openclaw-smart-replies:v1:SW52ZXN0aWdhdGUgdG9waWMgY2FsbGJhY2s",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: -100987654321, type: "supergroup", title: "Forum Group", is_forum: true },
date: 1736380800,
is_topic_message: true,
message_id: 11,
message_thread_id: 99,
text: "What should I help you sharpen next?",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await replyDone;
} finally {
clearTelegramRuntime();
}
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(-100987654321, 11, {
reply_markup: { inline_keyboard: [] },
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = mockMsgContextArg(replySpy as unknown as MockCallSource, 0, 0, "replySpy call");
expect(payload.Body).toContain("Investigate topic callback");
expect(payload.MessageSid).toBe("cbq-smart-reply-topic-submit");
expect(payload.WasMentioned).toBe(true);
expect(payload.SenderId).toBe("9");
expect(payload.SenderUsername).toBe("ada_bot");
});
it("retries plugin-owned callback text when the previous reply session is still closing", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
let calls = 0;
replySpy.mockImplementation(async (_ctx, opts) => {
calls += 1;
await opts?.onReplyStart?.();
if (calls === 1) {
throw new Error("reply session initialization conflicted for agent:main:telegram:9");
}
return undefined;
});
registerPluginInteractiveHandler("smart-replies-plugin", {
channel: "telegram",
namespace: "openclaw-smart-replies",
handler: async () => ({ handled: true, submitText: "Make Alice funnier" }),
} satisfies TelegramInteractiveHandlerRegistration);
setTelegramPluginStateRuntimeForTests();
try {
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
});
const callbackHandler = getTelegramCallbackHandlerForTests();
await callbackHandler({
callbackQuery: {
id: "cbq-smart-reply-submit-retry",
data: "openclaw-smart-replies:v1:TWFrZSBBbGljZSBmdW5uaWVy",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 9, type: "private" },
date: 1736380800,
message_id: 11,
text: "Pick a direction",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
} finally {
clearTelegramRuntime();
}
expect(replySpy).toHaveBeenCalledTimes(2);
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, {
reply_markup: { inline_keyboard: [] },
});
const payload = mockMsgContextArg(
replySpy as unknown as MockCallSource,
1,
0,
"replySpy retry call",
);
expect(payload.Body).toContain("Make Alice funnier");
});
it("releases plugin-owned callback dedupe when submitted text processing fails", async () => {
onSpy.mockClear();
replySpy.mockClear();
editMessageReplyMarkupSpy.mockClear();
let calls = 0;
replySpy.mockImplementation(async (_ctx, opts) => {
calls += 1;
await opts?.onReplyStart?.();
if (calls === 1) {
throw new Error("transient submit failure");
}
return undefined;
});
const handler = vi.fn(async () => ({ handled: true, submitText: "Try this later" }));
registerPluginInteractiveHandler("smart-replies-plugin", {
channel: "telegram",
namespace: "openclaw-smart-replies",
handler,
} satisfies TelegramInteractiveHandlerRegistration);
setTelegramPluginStateRuntimeForTests();
try {
createTelegramBot({
token: "tok",
config: {
channels: {
telegram: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
});
const callbackHandler = getTelegramCallbackHandlerForTests();
const createCallbackUpdate = (updateId: number) => ({
update_id: updateId,
callbackQuery: {
id: "cbq-smart-reply-submit-fail",
data: "openclaw-smart-replies:v1:VHJ5IHRoaXMgbGF0ZXI",
from: { id: 9, first_name: "Ada", username: "ada_bot" },
message: {
chat: { id: 9, type: "private" },
date: 1736380800,
message_id: 11,
text: "Pick a direction",
},
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await expect(callbackHandler(createCallbackUpdate(401))).rejects.toThrow(
"transient submit failure",
);
expect(editMessageReplyMarkupSpy).not.toHaveBeenCalled();
await callbackHandler(createCallbackUpdate(402));
} finally {
clearTelegramRuntime();
}
expect(handler).toHaveBeenCalledTimes(2);
expect(replySpy).toHaveBeenCalledTimes(2);
expect(editMessageReplyMarkupSpy).toHaveBeenCalledWith(9, 11, {
reply_markup: { inline_keyboard: [] },
});
});
it("passes false command auth to Telegram plugin callbacks for non-allowlisted group senders", async () => {
onSpy.mockClear();
let observedAuth: TelegramInteractiveHandlerContext["auth"] | undefined;

View file

@ -48,9 +48,19 @@ export type TelegramInteractiveHandlerContext = {
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
};
export type TelegramInteractiveHandlerResult = {
handled?: boolean;
/**
* Submit text through Telegram's normal inbound path after the callback handler
* returns, so plugin buttons can act like user-authored replies.
*/
submitText?: string;
} | void;
export type TelegramInteractiveHandlerRegistration = PluginInteractiveRegistration<
TelegramInteractiveHandlerContext,
"telegram"
"telegram",
TelegramInteractiveHandlerResult
>;
type TelegramInteractiveDispatchContext = Omit<
@ -81,12 +91,17 @@ export async function dispatchTelegramPluginInteractiveHandler(params: {
deleteMessage: () => Promise<void>;
};
onMatched?: () => Promise<void> | void;
afterInvoke?: (result: TelegramInteractiveHandlerResult) => Promise<void> | void;
}) {
return await dispatchPluginInteractiveHandler<TelegramInteractiveHandlerRegistration>({
return await dispatchPluginInteractiveHandler<
TelegramInteractiveHandlerRegistration,
TelegramInteractiveHandlerResult
>({
channel: "telegram",
data: params.data,
dedupeId: params.callbackId,
onMatched: params.onMatched,
afterInvoke: params.afterInvoke,
invoke: ({ registration, namespace, payload }) => {
const { callbackMessage, ...handlerContext } = params.ctx;
return registration.handler({

View file

@ -35,6 +35,7 @@ type InteractiveDispatchParams =
data: string;
dedupeId: string;
onMatched?: () => Promise<void> | void;
afterInvoke?: (result: { handled?: boolean } | void) => Promise<void> | void;
ctx: Omit<
TelegramInteractiveHandlerContext,
| "callback"
@ -57,6 +58,7 @@ type InteractiveDispatchParams =
data: string;
dedupeId: string;
onMatched?: () => Promise<void> | void;
afterInvoke?: (result: { handled?: boolean } | void) => Promise<void> | void;
ctx: Omit<
DiscordInteractiveHandlerContext,
| "interaction"
@ -78,6 +80,7 @@ type InteractiveDispatchParams =
data: string;
dedupeId: string;
onMatched?: () => Promise<void> | void;
afterInvoke?: (result: { handled?: boolean } | void) => Promise<void> | void;
ctx: Omit<
SlackInteractiveHandlerContext,
| "interaction"
@ -247,6 +250,7 @@ async function dispatchInteractiveWith(
data: params.data,
dedupeId: params.dedupeId,
onMatched: params.onMatched,
afterInvoke: params.afterInvoke,
invoke: ({ registration, namespace, payload }) => {
const { callbackMessage, ...handlerContext } = params.ctx;
return registration.handler({
@ -284,6 +288,7 @@ async function dispatchInteractiveWith(
data: params.data,
dedupeId: params.dedupeId,
onMatched: params.onMatched,
afterInvoke: params.afterInvoke,
invoke: ({ registration, namespace, payload }) =>
registration.handler({
...params.ctx,
@ -315,6 +320,7 @@ async function dispatchInteractiveWith(
data: params.data,
dedupeId: params.dedupeId,
onMatched: params.onMatched,
afterInvoke: params.afterInvoke,
invoke: ({ registration, namespace, payload }) =>
registration.handler({
...params.ctx,
@ -901,6 +907,39 @@ describe("plugin interactive handlers", () => {
expect(handler).toHaveBeenCalledTimes(2);
});
it("does not consume dedupe keys when post-handler processing throws", async () => {
const handler = vi.fn(async () => ({ handled: true }));
const afterInvoke = vi
.fn(async () => {})
.mockRejectedValueOnce(new Error("post-handler failure"))
.mockResolvedValueOnce(undefined);
expect(
registerPluginInteractiveHandler("codex-plugin", {
channel: "telegram",
namespace: "codex",
handler,
}),
).toEqual({ ok: true });
const baseParams = {
...createTelegramDispatchParams({
data: "codex:resume:thread-1",
callbackId: "cb-post-handler-failure",
}),
afterInvoke,
};
await expect(dispatchInteractive(baseParams)).rejects.toThrow("post-handler failure");
await expect(dispatchInteractive(baseParams)).resolves.toEqual({
matched: true,
handled: true,
duplicate: false,
});
expect(handler).toHaveBeenCalledTimes(2);
expect(afterInvoke).toHaveBeenCalledTimes(2);
expect(afterInvoke).toHaveBeenLastCalledWith({ handled: true });
});
it("dedupes concurrent interactive dispatches while a handler is still running", async () => {
let releaseHandler: (() => void) | undefined;
const handlerGate = new Promise<void>((resolve) => {

View file

@ -65,6 +65,7 @@ export async function dispatchPluginInteractiveHandler<
dedupeId?: string;
onMatched?: () => Promise<void> | void;
invoke: (match: PluginInteractiveMatch<TRegistration>) => Promise<TResult> | TResult;
afterInvoke?: (result: TResult) => Promise<void> | void;
}): Promise<InteractiveDispatchResult<TResult>> {
const match = resolveLivePluginInteractiveNamespaceMatch(params.channel, params.data);
if (!match) {
@ -79,6 +80,9 @@ export async function dispatchPluginInteractiveHandler<
try {
await params.onMatched?.();
const resolved = await params.invoke(match as PluginInteractiveMatch<TRegistration>);
// Channel post-processing stays inside the dedupe claim. Committing first
// would swallow a retry after a retryable post-handler failure.
await params.afterInvoke?.(resolved);
if (dedupeKey) {
commitPluginInteractiveCallbackDedupe(dedupeKey);
}