Fix Telegram stop debounce bypass

This commit is contained in:
clawsweeper
2026-05-17 20:52:32 +00:00
parent 8a3de3261d
commit 19245a341d
7 changed files with 129 additions and 2 deletions

View File

@@ -710,6 +710,7 @@ describe("Feishu inbound debounce regressions", () => {
params.onError?.(new Error("dispatch failed"), [item]);
},
flushKey: async () => {},
cancelKey: () => false,
}),
}),
);

View File

@@ -34,6 +34,7 @@ export function createFeishuRuntimeMockModule(): {
createInboundDebouncer: () => ({
enqueue: async () => {},
flushKey: async () => {},
cancelKey: () => false,
}),
},
text: {

View File

@@ -88,6 +88,7 @@ function createImmediateInboundDebounce() {
}
},
flushKey: async () => {},
cancelKey: () => false,
}),
};
}

View File

@@ -47,6 +47,8 @@ function createRuntimeStub(readAllowFromStore: ReturnType<typeof vi.fn>): Plugin
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: () => ({
enqueue: async () => {},
flushKey: async () => {},
cancelKey: () => false,
}),
},
pairing: {

View File

@@ -39,6 +39,8 @@ function createRuntimeStub(stateDir?: string): PluginRuntime {
resolveInboundDebounceMs: () => 0,
createInboundDebouncer: () => ({
enqueue: async () => {},
flushKey: async () => {},
cancelKey: () => false,
}),
},
},

View File

@@ -1507,6 +1507,7 @@ export const registerTelegramHandlers = ({
isForum: boolean;
resolvedThreadId?: number;
dmThreadId?: number;
dmPolicy: DmPolicy;
storeAllowFrom: string[];
senderId: string;
effectiveGroupAllow: NormalizedAllowFrom;
@@ -1525,6 +1526,7 @@ export const registerTelegramHandlers = ({
isForum,
resolvedThreadId,
dmThreadId,
dmPolicy,
storeAllowFrom,
senderId,
effectiveGroupAllow,
@@ -1539,6 +1541,30 @@ export const registerTelegramHandlers = ({
const messageText = getTelegramTextParts(msg).text;
const botUsername = ctx.me?.username;
const isAbortControlMessage = isAbortRequestText(messageText, { botUsername });
let abortControlAuthorized: Promise<boolean> | undefined;
const isAuthorizedAbortControlMessage = () => {
if (!isAbortControlMessage || !senderId) {
return Promise.resolve(false);
}
abortControlAuthorized ??= resolveTelegramCommandIngressAuthorization({
accountId,
cfg,
dmPolicy,
isGroup,
chatId,
resolvedThreadId,
senderId,
effectiveDmAllow,
effectiveGroupAllow,
ownerAccess: { ownerList: [], senderIsOwner: false },
eventKind: "message",
allowTextCommands: true,
hasControlCommand: true,
modeWhenAccessGroupsOff: "allow",
includeDmAllowForGroupCommands: false,
}).then((gate) => gate.authorized);
return abortControlAuthorized;
};
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
// We buffer “near-limit” messages and append immediately-following parts.
@@ -1607,7 +1633,7 @@ export const registerTelegramHandlers = ({
scheduleTextFragmentFlush(entry);
return;
}
} else if (text && isAbortControlMessage) {
} else if (text && isAbortControlMessage && (await isAuthorizedAbortControlMessage())) {
const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown";
const threadId = resolvedThreadId ?? dmThreadId;
const key = `text:${chatId}:${threadId ?? "main"}:${senderId}`;
@@ -1757,7 +1783,7 @@ export const registerTelegramHandlers = ({
debounceLane,
})
: null;
if (isAbortControlMessage && senderId) {
if (senderId && (await isAuthorizedAbortControlMessage())) {
for (const lane of ["default", "forward"] as const) {
inboundDebouncer.cancelKey(
buildTelegramInboundDebounceKey({
@@ -2682,6 +2708,7 @@ export const registerTelegramHandlers = ({
isForum: event.isForum,
resolvedThreadId,
dmThreadId,
dmPolicy,
storeAllowFrom,
senderId: event.senderId,
effectiveGroupAllow,

View File

@@ -921,6 +921,99 @@ describe("createTelegramBot", () => {
}
});
it("does not let unauthorized group stop cancel pending same-sender inbound debounce", async () => {
const DEBOUNCE_MS = 4321;
loadConfig.mockReturnValue({
agents: {
defaults: {
envelopeTimezone: "utc",
},
},
messages: {
inbound: {
debounceMs: DEBOUNCE_MS,
},
},
channels: {
telegram: {
dmPolicy: "pairing",
groupPolicy: "open",
groups: { "*": { requireMention: false } },
},
},
});
installPerKeySequentializer();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const startedBodies: string[] = [];
replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => {
await opts?.onReplyStart?.();
const body = ctx.Body ?? "";
startedBodies.push(body);
return { text: `reply:${body}` };
});
const extractLatestDebounceFlush = () => {
const debounceCallIndex = setTimeoutSpy.mock.calls.findLastIndex(
(call) => call[1] === DEBOUNCE_MS,
);
expect(debounceCallIndex).toBeGreaterThanOrEqual(0);
clearTimeout(
setTimeoutSpy.mock.results[debounceCallIndex]?.value as ReturnType<typeof setTimeout>,
);
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => Promise<void>) | undefined;
};
try {
createTelegramBot({ token: "tok" });
const messageHandler = getOnHandler("message") as (
ctx: TelegramMiddlewareTestContext,
) => Promise<void>;
await runTelegramMiddlewareChain({
ctx: {
update: { update_id: 104 },
message: {
chat: { id: -1007, type: "supergroup", title: "OpenClaw Ops" },
text: "first",
date: 1736380804,
message_id: 104,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
},
finalHandler: messageHandler,
});
const flushFirst = extractLatestDebounceFlush();
await runTelegramMiddlewareChain({
ctx: {
update: { update_id: 105 },
message: {
chat: { id: -1007, type: "supergroup", title: "OpenClaw Ops" },
text: "stop",
date: 1736380805,
message_id: 105,
from: { id: 42, first_name: "Ada" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({}),
},
finalHandler: messageHandler,
});
await flushFirst?.();
await vi.waitFor(() => {
expect(startedBodies.some((body) => body.includes("first"))).toBe(true);
});
} finally {
setTimeoutSpy.mockRestore();
}
});
it("routes callback_query payloads as messages and answers callbacks", async () => {
createTelegramBot({ token: "tok" });
const callbackHandler = requireValue(