mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
[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:
parent
06efc2ae41
commit
db38127c22
8 changed files with 549 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -15,4 +15,5 @@ export {
|
|||
export type {
|
||||
TelegramInteractiveHandlerContext,
|
||||
TelegramInteractiveHandlerRegistration,
|
||||
TelegramInteractiveHandlerResult,
|
||||
} from "./src/interactive-dispatch.js";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue