Skip to content

Commit dbec502

Browse files
committed
fix(telegram): stop DM topic threadless fallback
1 parent 7442355 commit dbec502

7 files changed

Lines changed: 68 additions & 83 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ Docs: https://docs.openclaw.ai
301301
- CLI/completion: guard the shell-profile source line written by `openclaw completion --install` with a file existence check (`[ -f ... ] && source ...` for bash/zsh, `test -f ...; and source ...` for fish) so uninstalling OpenClaw no longer makes new login shells error on a missing completion cache. (#78659) Thanks @sjf.
302302
- Cron/doctor: repair persisted cron jobs whose `payload.model` was stored as `"default"`, `"null"`, blank, or JSON `null` by removing the bad override during `openclaw doctor --fix` while keeping cron runtime model validation strict. Fixes #78549. Thanks @bizzle12368239.
303303
- Telegram: honor `accessGroup:*` sender allowlists for DMs, groups, native commands, and callback authorization before applying Telegram's numeric sender-ID checks. Fixes #78660. Thanks @manugc.
304+
- Telegram: fail private-topic sends instead of retrying them as plain DMs when Telegram rejects the topic id, keeping private-topic `message_thread_id` routing intact. Fixes #79455. Thanks @tmimmanuel.
304305
- Agent delivery: report `deliverySucceeded=false` when outbound delivery returns no adapter result, so claimed/empty delivery paths no longer masquerade as successful sends. Fixes #78532. Thanks @joeyfrasier.
305306
- Cron/isolated runs: fail implicit announce delivery before model execution when `delivery.channel=last` has no previous route, so recurring jobs do not spend tokens before hitting a permanent delivery-target error. Fixes #78608. Thanks @sallyom.
306307
- Gateway/sessions: persist a new generated transcript file when daily gateway-agent session rollover changes the session id, while preserving custom transcript paths. Fixes #78607. Thanks @nailujac, @zerone0x, and @sallyom.

extensions/telegram/src/bot/delivery.send.ts

Lines changed: 2 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -17,42 +17,17 @@ export { buildTelegramSendParams } from "../reply-parameters.js";
1717

1818
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
1919
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
20-
const THREAD_NOT_FOUND_RE = /message thread not found/i;
2120
const QUOTE_PARAM_RE = /\bquote not found\b|\bQUOTE_TEXT_INVALID\b|\bquote text invalid\b/i;
2221
const GrammyErrorCtor: typeof GrammyError | undefined =
2322
typeof GrammyError === "function" ? GrammyError : undefined;
2423

25-
function isTelegramThreadNotFoundError(err: unknown): boolean {
26-
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
27-
return THREAD_NOT_FOUND_RE.test(err.description);
28-
}
29-
return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err));
30-
}
31-
3224
function isTelegramQuoteParamError(err: unknown): boolean {
3325
if (GrammyErrorCtor && err instanceof GrammyErrorCtor) {
3426
return QUOTE_PARAM_RE.test(err.description);
3527
}
3628
return QUOTE_PARAM_RE.test(formatErrorMessage(err));
3729
}
3830

39-
function hasMessageThreadIdParam(params: Record<string, unknown> | undefined): boolean {
40-
if (!params) {
41-
return false;
42-
}
43-
return typeof params.message_thread_id === "number";
44-
}
45-
46-
function removeMessageThreadIdParam(
47-
params: Record<string, unknown> | undefined,
48-
): Record<string, unknown> {
49-
if (!params) {
50-
return {};
51-
}
52-
const { message_thread_id: _ignored, ...rest } = params;
53-
return rest;
54-
}
55-
5631
function createTelegramDeliverySendRetry() {
5732
return createTelegramRetryRunner({
5833
shouldRetry: (err) => isSafeToRetrySendError(err) || isTelegramRateLimitError(err),
@@ -68,12 +43,9 @@ export async function sendTelegramWithThreadFallback<T>(params: {
6843
send: (effectiveParams: Record<string, unknown>) => Promise<T>;
6944
shouldLog?: (err: unknown) => boolean;
7045
}): Promise<T> {
71-
const allowThreadlessRetry = params.thread?.scope === "dm";
72-
const hasThreadId = hasMessageThreadIdParam(params.requestParams);
7346
const hasNativeQuote = getTelegramNativeQuoteReplyMessageId(params.requestParams) != null;
7447
const shouldSuppressFirstErrorLog = (err: unknown) =>
75-
(allowThreadlessRetry && hasThreadId && isTelegramThreadNotFoundError(err)) ||
76-
(hasNativeQuote && isTelegramQuoteParamError(err));
48+
hasNativeQuote && isTelegramQuoteParamError(err);
7749
const mergedShouldLog = params.shouldLog
7850
? (err: unknown) => params.shouldLog!(err) && !shouldSuppressFirstErrorLog(err)
7951
: (err: unknown) => !shouldSuppressFirstErrorLog(err);
@@ -103,14 +75,7 @@ export async function sendTelegramWithThreadFallback<T>(params: {
10375
requestParams: removeTelegramNativeQuoteParam(params.requestParams),
10476
});
10577
}
106-
if (!allowThreadlessRetry || !hasThreadId || !isTelegramThreadNotFoundError(err)) {
107-
throw err;
108-
}
109-
const retryParams = removeMessageThreadIdParam(params.requestParams);
110-
params.runtime.log?.(
111-
`telegram ${params.operation}: message thread not found; retrying without message_thread_id`,
112-
);
113-
return await runLoggedSend(`${params.operation} (threadless retry)`, retryParams);
78+
throw err;
11479
}
11580
}
11681

extensions/telegram/src/bot/delivery.test.ts

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -730,32 +730,27 @@ describe("deliverReplies", () => {
730730
);
731731
});
732732

733-
it("retries DM topic sends without message_thread_id when thread is missing", async () => {
733+
it("does not retry DM topic sends without the topic id when the topic is missing", async () => {
734734
const runtime = createRuntime();
735-
const sendMessage = vi
736-
.fn()
737-
.mockRejectedValueOnce(createThreadNotFoundError("sendMessage"))
738-
.mockResolvedValueOnce({
739-
message_id: 7,
740-
chat: { id: "123" },
741-
});
735+
const sendMessage = vi.fn().mockRejectedValueOnce(createThreadNotFoundError("sendMessage"));
742736
const bot = createBot({ sendMessage });
743737

744-
await deliverWith({
745-
replies: [{ text: "hello" }],
746-
runtime,
747-
bot,
748-
thread: { id: 42, scope: "dm" },
749-
});
738+
await expect(
739+
deliverWith({
740+
replies: [{ text: "hello" }],
741+
runtime,
742+
bot,
743+
thread: { id: 42, scope: "dm" },
744+
}),
745+
).rejects.toThrow("message thread not found");
750746

751-
expect(sendMessage).toHaveBeenCalledTimes(2);
747+
expect(sendMessage).toHaveBeenCalledTimes(1);
752748
expect(sendMessage.mock.calls[0]?.[2]).toEqual(
753749
expect.objectContaining({
754750
message_thread_id: 42,
755751
}),
756752
);
757-
expect(sendMessage.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
758-
expect(runtime.error).not.toHaveBeenCalled();
753+
expect(runtime.error).toHaveBeenCalledTimes(1);
759754
});
760755

761756
it("does not retry forum sends without message_thread_id", async () => {
@@ -818,34 +813,29 @@ describe("deliverReplies", () => {
818813
expect(runtime.error).toHaveBeenCalledTimes(1);
819814
});
820815

821-
it("retries media sends without message_thread_id for DM topics", async () => {
816+
it("does not retry DM topic media sends without the topic id", async () => {
822817
const runtime = createRuntime();
823-
const sendPhoto = vi
824-
.fn()
825-
.mockRejectedValueOnce(createThreadNotFoundError("sendPhoto"))
826-
.mockResolvedValueOnce({
827-
message_id: 8,
828-
chat: { id: "123" },
829-
});
818+
const sendPhoto = vi.fn().mockRejectedValueOnce(createThreadNotFoundError("sendPhoto"));
830819
const bot = createBot({ sendPhoto });
831820

832821
mockMediaLoad("photo.jpg", "image/jpeg", "image");
833822

834-
await deliverWith({
835-
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "caption" }],
836-
runtime,
837-
bot,
838-
thread: { id: 42, scope: "dm" },
839-
});
823+
await expect(
824+
deliverWith({
825+
replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "caption" }],
826+
runtime,
827+
bot,
828+
thread: { id: 42, scope: "dm" },
829+
}),
830+
).rejects.toThrow("message thread not found");
840831

841-
expect(sendPhoto).toHaveBeenCalledTimes(2);
832+
expect(sendPhoto).toHaveBeenCalledTimes(1);
842833
expect(sendPhoto.mock.calls[0]?.[2]).toEqual(
843834
expect.objectContaining({
844835
message_thread_id: 42,
845836
}),
846837
);
847-
expect(sendPhoto.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id");
848-
expect(runtime.error).not.toHaveBeenCalled();
838+
expect(runtime.error).toHaveBeenCalledTimes(1);
849839
});
850840

851841
it("does not include link_preview_options when linkPreview is true", async () => {

extensions/telegram/src/draft-stream.test.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -145,11 +145,9 @@ describe("createTelegramDraftStream", () => {
145145
}
146146
});
147147

148-
it("retries DM message preview send without thread when thread is not found", async () => {
148+
it("does not retry DM message preview sends without the topic id", async () => {
149149
const api = createMockDraftApi();
150-
api.sendMessage
151-
.mockRejectedValueOnce(new Error("400: Bad Request: message thread not found"))
152-
.mockResolvedValueOnce({ message_id: 17 });
150+
api.sendMessage.mockRejectedValueOnce(new Error("400: Bad Request: message thread not found"));
153151
const warn = vi.fn();
154152
const stream = createDraftStream(api, {
155153
thread: { id: 42, scope: "dm" },
@@ -159,11 +157,10 @@ describe("createTelegramDraftStream", () => {
159157
stream.update("Hello");
160158
await stream.flush();
161159

162-
expect(api.sendMessage).toHaveBeenNthCalledWith(1, 123, "Hello", { message_thread_id: 42 });
163-
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Hello", undefined);
164-
expect(warn).toHaveBeenCalledWith(
165-
"telegram stream preview send failed with message_thread_id, retrying without thread",
166-
);
160+
expect(api.sendMessage).toHaveBeenCalledTimes(1);
161+
expect(api.sendMessage).toHaveBeenCalledWith(123, "Hello", { message_thread_id: 42 });
162+
expect(warn).toHaveBeenCalledWith(expect.stringContaining("message thread not found"));
163+
expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("retrying without thread"));
167164
});
168165

169166
it("keeps allow_sending_without_reply on message previews that target a reply", async () => {

extensions/telegram/src/draft-stream.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export function createTelegramDraftStream(params: {
109109
const minInitialChars = params.minInitialChars;
110110
const chatId = params.chatId;
111111
const threadParams = buildTelegramThreadParams(params.thread);
112+
const allowThreadlessRetry = params.thread?.scope !== "dm";
112113
const replyToMessageId = normalizeTelegramReplyToMessageId(params.replyToMessageId);
113114
const replyParams =
114115
replyToMessageId != null
@@ -153,7 +154,7 @@ export function createTelegramDraftStream(params: {
153154
usedThreadParams,
154155
};
155156
} catch (err) {
156-
if (!usedThreadParams || !THREAD_NOT_FOUND_RE.test(String(err))) {
157+
if (!allowThreadlessRetry || !usedThreadParams || !THREAD_NOT_FOUND_RE.test(String(err))) {
157158
throw err;
158159
}
159160
const threadlessParams: TelegramSendMessageParams = { ...sendParams };

extensions/telegram/src/send.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1651,7 +1651,6 @@ describe("sendMessageTelegram", () => {
16511651
it("retries sends without message_thread_id on thread-not-found", async () => {
16521652
const cases = [
16531653
{ name: "forum", chatId: "-100123", text: "hello forum", messageId: 58 },
1654-
{ name: "private", chatId: "123456789", text: "hello private", messageId: 59 },
16551654
] as const;
16561655
const threadErr = new Error("400: Bad Request: message thread not found");
16571656

@@ -1695,6 +1694,29 @@ describe("sendMessageTelegram", () => {
16951694
}
16961695
});
16971696

1697+
it("does not retry private DM topic sends without the topic id", async () => {
1698+
const threadErr = new Error("400: Bad Request: message thread not found");
1699+
const sendMessage = vi.fn().mockRejectedValueOnce(threadErr);
1700+
const api = { sendMessage } as unknown as {
1701+
sendMessage: typeof sendMessage;
1702+
};
1703+
1704+
await expect(
1705+
sendMessageTelegram("123456789", "hello private", {
1706+
cfg: TELEGRAM_TEST_CFG,
1707+
token: "tok",
1708+
api,
1709+
messageThreadId: 271,
1710+
}),
1711+
).rejects.toThrow("message thread not found");
1712+
1713+
expect(sendMessage).toHaveBeenCalledTimes(1);
1714+
expect(sendMessage).toHaveBeenCalledWith("123456789", "hello private", {
1715+
parse_mode: "HTML",
1716+
message_thread_id: 271,
1717+
});
1718+
});
1719+
16981720
it("does not retry on non-retriable thread/chat errors", async () => {
16991721
const cases: Array<{
17001722
chatId: string;

extensions/telegram/src/send.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,14 +538,19 @@ async function withTelegramThreadFallback<
538538
params: TParams,
539539
label: string,
540540
verbose: boolean | undefined,
541+
allowThreadlessRetry: boolean,
541542
attempt: (effectiveParams: TParams, effectiveLabel: string) => Promise<T>,
542543
): Promise<T> {
543544
try {
544545
return await attempt(params, label);
545546
} catch (err) {
546547
// Do not widen this fallback to cover "chat not found".
547548
// chat-not-found is routing/auth/membership/token; stripping thread IDs hides root cause.
548-
if (!hasMessageThreadIdParam(params) || !isTelegramThreadNotFoundError(err)) {
549+
if (
550+
!allowThreadlessRetry ||
551+
!hasMessageThreadIdParam(params) ||
552+
!isTelegramThreadNotFoundError(err)
553+
) {
549554
throw err;
550555
}
551556
if (verbose) {
@@ -659,6 +664,7 @@ export async function sendMessageTelegram(
659664
params,
660665
"message",
661666
opts.verbose,
667+
target.chatType !== "direct",
662668
async (effectiveParams, label) => {
663669
const baseParams = effectiveParams ? { ...effectiveParams } : {};
664670
if (linkPreviewOptions) {
@@ -855,6 +861,7 @@ export async function sendMessageTelegram(
855861
mediaParams,
856862
label,
857863
opts.verbose,
864+
target.chatType !== "direct",
858865
async (effectiveParams, retryLabel) =>
859866
requestWithChatNotFound(() => sender(effectiveParams), retryLabel),
860867
);
@@ -1508,6 +1515,7 @@ export async function sendStickerTelegram(
15081515
stickerParams,
15091516
"sticker",
15101517
opts.verbose,
1518+
target.chatType !== "direct",
15111519
async (effectiveParams, label) =>
15121520
requestWithChatNotFound(() => api.sendSticker(chatId, fileId.trim(), effectiveParams), label),
15131521
);
@@ -1615,6 +1623,7 @@ export async function sendPollTelegram(
16151623
pollParams,
16161624
"poll",
16171625
opts.verbose,
1626+
target.chatType !== "direct",
16181627
async (effectiveParams, label) =>
16191628
requestWithChatNotFound(
16201629
() => api.sendPoll(chatId, normalizedPoll.question, pollOptions, effectiveParams),

0 commit comments

Comments
 (0)