Skip to content

Commit f9ea4e4

Browse files
Michael EttlingerMichael Ettlinger
authored andcommitted
fix(cron): announce delivery for Telegram forum topics
- Pre-parse forum topic targets in delivery-target.ts before plugin bootstrap - Add slash format support to parseTelegramTarget (chatId/topicId) - Pass cfg through resolveSessionDeliveryTarget for plugin bootstrapping - Propagate deliveryError from dispatch to run result for better diagnostics - Update cron-jobs.md to document all supported forum topic formats Fixes: cron announce delivery silently returning not-delivered for Telegram forum topics regardless of target format used.
1 parent 466510b commit f9ea4e4

14 files changed

Lines changed: 179 additions & 11 deletions

docs/automation/cron-jobs.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,16 @@ the topic/thread into the `to` field:
284284
- `-1001234567890` (chat id only)
285285
- `-1001234567890:topic:123` (preferred: explicit topic marker)
286286
- `-1001234567890:123` (shorthand: numeric suffix)
287+
- `-1001234567890/123` (legacy slash form — also accepted)
287288

288289
Prefixed targets like `telegram:...` / `telegram:group:...` are also accepted:
289290

290291
- `telegram:group:-1001234567890:topic:123`
291292

293+
> **Note:** Prior to this fix, `announce` delivery to Telegram forum topics silently produced
294+
> `deliveryStatus: "not-delivered"` because the channel plugin was not bootstrapped during
295+
> target resolution. All formats above now work correctly for cron `announce` delivery.
296+
292297
## JSON schema for tool calls
293298

294299
Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC).

extensions/telegram/src/targets.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ describe("parseTelegramTarget", () => {
4848
});
4949
});
5050

51+
it("parses legacy chatId/topicId format", () => {
52+
expect(parseTelegramTarget("-1001234567890/123")).toEqual({
53+
chatId: "-1001234567890",
54+
messageThreadId: 123,
55+
chatType: "group",
56+
});
57+
});
58+
5159
it("parses chatId:topic:topicId format", () => {
5260
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
5361
chatId: "-1001234567890",
@@ -78,6 +86,14 @@ describe("parseTelegramTarget", () => {
7886
chatType: "group",
7987
});
8088
});
89+
90+
it("parses slash targets after stripping telegram prefixes", () => {
91+
expect(parseTelegramTarget("telegram:-1001234567890/456")).toEqual({
92+
chatId: "-1001234567890",
93+
messageThreadId: 456,
94+
chatType: "group",
95+
});
96+
});
8197
});
8298

8399
describe("normalizeTelegramChatId", () => {

extensions/telegram/src/targets.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ export function normalizeTelegramLookupTarget(raw: string): string | undefined {
7474
*
7575
* Supported formats:
7676
* - `chatId` (plain chat ID, t.me link, @username, or internal prefixes like `telegram:...`)
77+
* - `chatId/topicId` (legacy slash topic/thread ID)
7778
* - `chatId:topicId` (numeric topic/thread ID)
7879
* - `chatId:topic:topicId` (explicit topic marker; preferred)
7980
*/
@@ -100,6 +101,16 @@ export function parseTelegramTarget(to: string): TelegramTarget {
100101
};
101102
}
102103

104+
// Legacy slash format: -1001234567890/123
105+
const slashMatch = /^(-?\d+)\/(\d+)$/.exec(normalized);
106+
if (slashMatch) {
107+
return {
108+
chatId: slashMatch[1],
109+
messageThreadId: Number.parseInt(slashMatch[2], 10),
110+
chatType: resolveTelegramChatType(slashMatch[1]),
111+
};
112+
}
113+
103114
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
104115
if (colonMatch) {
105116
return {

src/cron/isolated-agent.direct-delivery-forum-topics.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,27 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
6161
});
6262
});
6363
});
64+
65+
it("routes slash-encoded telegram forum-topic targets through the correct delivery path", async () => {
66+
await withTempCronHome(async (home) => {
67+
const storePath = await writeSessionStore(home, { lastProvider: "webchat", lastTo: "" });
68+
const deps = createCliDeps();
69+
mockAgentPayloads([{ text: "forum message" }]);
70+
71+
const res = await runTelegramAnnounceTurn({
72+
home,
73+
storePath,
74+
deps,
75+
delivery: { mode: "announce", channel: "telegram", to: "-1001234567890/42" },
76+
});
77+
78+
expect(res.status).toBe("ok");
79+
expect(res.delivered).toBe(true);
80+
expectDirectTelegramDelivery(deps, {
81+
chatId: "-1001234567890",
82+
text: "forum message",
83+
messageThreadId: 42,
84+
});
85+
});
86+
});
6487
});

src/cron/isolated-agent/delivery-dispatch.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export type DispatchCronDeliveryState = {
106106
result?: RunCronAgentTurnResult;
107107
delivered: boolean;
108108
deliveryAttempted: boolean;
109+
deliveryError?: string;
109110
summary?: string;
110111
outputText?: string;
111112
synthesizedText?: string;
@@ -293,10 +294,12 @@ export async function dispatchCronDelivery(
293294
// remains the only source of delivered state.
294295
let delivered = skipMessagingToolDelivery;
295296
let deliveryAttempted = skipMessagingToolDelivery;
297+
let deliveryError: string | undefined;
296298
const failDeliveryTarget = (error: string) =>
297299
params.withRunSession({
298300
status: "error",
299301
error,
302+
deliveryError: error,
300303
errorKind: "delivery-target",
301304
summary,
302305
outputText,
@@ -376,16 +379,19 @@ export async function dispatchCronDelivery(
376379
}
377380
return null;
378381
} catch (err) {
382+
const errorMessage = summarizeDirectCronDeliveryError(err);
379383
if (!params.deliveryBestEffort) {
380384
return params.withRunSession({
381385
status: "error",
382386
summary,
383387
outputText,
384-
error: String(err),
388+
error: errorMessage,
389+
deliveryError: errorMessage,
385390
deliveryAttempted,
386391
...params.telemetry,
387392
});
388393
}
394+
deliveryError = errorMessage;
389395
return null;
390396
}
391397
};
@@ -522,6 +528,7 @@ export async function dispatchCronDelivery(
522528
result: failDeliveryTarget(params.resolvedDelivery.error.message),
523529
delivered,
524530
deliveryAttempted,
531+
deliveryError,
525532
summary,
526533
outputText,
527534
synthesizedText,
@@ -534,11 +541,13 @@ export async function dispatchCronDelivery(
534541
status: "ok",
535542
summary,
536543
outputText,
544+
deliveryError,
537545
deliveryAttempted,
538546
...params.telemetry,
539547
}),
540548
delivered,
541549
deliveryAttempted,
550+
deliveryError,
542551
summary,
543552
outputText,
544553
synthesizedText,
@@ -558,6 +567,7 @@ export async function dispatchCronDelivery(
558567
result: directResult,
559568
delivered,
560569
deliveryAttempted,
570+
deliveryError,
561571
summary,
562572
outputText,
563573
synthesizedText,
@@ -571,6 +581,7 @@ export async function dispatchCronDelivery(
571581
result: finalizedTextResult,
572582
delivered,
573583
deliveryAttempted,
584+
deliveryError,
574585
summary,
575586
outputText,
576587
synthesizedText,
@@ -583,6 +594,7 @@ export async function dispatchCronDelivery(
583594
return {
584595
delivered,
585596
deliveryAttempted,
597+
deliveryError,
586598
summary,
587599
outputText,
588600
synthesizedText,

src/cron/isolated-agent/delivery-target.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,32 @@ describe("resolveDeliveryTarget", () => {
265265
expect(result.threadId).toBe("thread-2");
266266
});
267267

268+
it("parses telegram slash topic targets into chatId + threadId", async () => {
269+
setMainSessionEntry(undefined);
270+
271+
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
272+
channel: "telegram",
273+
to: "-1001234567890/77",
274+
});
275+
276+
expect(result.ok).toBe(true);
277+
expect(result.to).toBe("-1001234567890");
278+
expect(result.threadId).toBe(77);
279+
});
280+
281+
it("parses prefixed telegram topic targets into chatId + threadId", async () => {
282+
setMainSessionEntry(undefined);
283+
284+
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
285+
channel: "telegram",
286+
to: "telegram:group:-1001234567890:topic:77",
287+
});
288+
289+
expect(result.ok).toBe(true);
290+
expect(result.to).toBe("-1001234567890");
291+
expect(result.threadId).toBe(77);
292+
});
293+
268294
it("uses single configured channel when neither explicit nor session channel exists", async () => {
269295
setMainSessionEntry(undefined);
270296

src/cron/isolated-agent/delivery-target.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,33 @@
11
import type { ChannelId } from "../../channels/plugins/types.js";
22
import type { OpenClawConfig } from "../../config/config.js";
3+
4+
/**
5+
* Pre-parse well-known forum/topic target formats from a delivery `to` string.
6+
* Returns the base chatId and extracted threadId when a topic format is detected.
7+
*
8+
* Handles these Telegram forum topic formats before plugin bootstrapping:
9+
* - `-1001234567890/77` (legacy slash)
10+
* - `-1001234567890:topic:77` (explicit topic marker)
11+
* - `telegram:-1001234567890/77` (prefixed slash)
12+
* - `telegram:group:-1001234567890:topic:77` (fully-qualified)
13+
*/
14+
function extractForumTopicFromTarget(to: string): { to: string; threadId: number } | null {
15+
const stripped = to.replace(/^telegram:group:/, "").replace(/^telegram:/, "");
16+
17+
// chatId:topic:topicId
18+
const topicMatch = /^(-?\d+):topic:(\d+)$/.exec(stripped);
19+
if (topicMatch) {
20+
return { to: topicMatch[1], threadId: parseInt(topicMatch[2], 10) };
21+
}
22+
23+
// chatId/topicId (legacy slash)
24+
const slashMatch = /^(-?\d+)\/(\d+)$/.exec(stripped);
25+
if (slashMatch) {
26+
return { to: slashMatch[1], threadId: parseInt(slashMatch[2], 10) };
27+
}
28+
29+
return null;
30+
}
331
import {
432
loadSessionStore,
533
resolveAgentMainSessionKey,
@@ -49,7 +77,12 @@ export async function resolveDeliveryTarget(
4977
},
5078
): Promise<DeliveryTargetResolution> {
5179
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
52-
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
80+
const rawTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
81+
// Pre-parse forum/topic formats (e.g. telegram:-GROUP/TOPIC, telegram:group:-GROUP:topic:TOPIC)
82+
// before plugin bootstrapping so threadId is always extracted regardless of plugin load state.
83+
const preParseResult = rawTo ? extractForumTopicFromTarget(rawTo) : null;
84+
const explicitTo = preParseResult ? preParseResult.to : rawTo;
85+
const preExtractedThreadId = preParseResult?.threadId;
5386
const allowMismatchedLastTo = requestedChannel === "last";
5487

5588
const sessionCfg = cfg.session;
@@ -67,7 +100,9 @@ export async function resolveDeliveryTarget(
67100
entry: main,
68101
requestedChannel,
69102
explicitTo,
103+
explicitThreadId: preExtractedThreadId,
70104
allowMismatchedLastTo,
105+
cfg,
71106
});
72107

73108
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
@@ -93,9 +128,11 @@ export async function resolveDeliveryTarget(
93128
entry: main,
94129
requestedChannel,
95130
explicitTo,
131+
explicitThreadId: preExtractedThreadId,
96132
fallbackChannel,
97133
allowMismatchedLastTo,
98134
mode: preliminary.mode,
135+
cfg,
99136
})
100137
: preliminary;
101138

src/cron/isolated-agent/run.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -833,7 +833,11 @@ export async function runCronIsolatedAgentTurn(params: {
833833
const embeddedRunError = hasFatalErrorPayload
834834
? (lastErrorPayloadText ?? "cron isolated run returned an error payload")
835835
: undefined;
836-
const resolveRunOutcome = (params?: { delivered?: boolean; deliveryAttempted?: boolean }) =>
836+
const resolveRunOutcome = (params?: {
837+
delivered?: boolean;
838+
deliveryAttempted?: boolean;
839+
deliveryError?: string;
840+
}) =>
837841
withRunSession({
838842
status: hasFatalErrorPayload ? "error" : "ok",
839843
...(hasFatalErrorPayload
@@ -843,6 +847,7 @@ export async function runCronIsolatedAgentTurn(params: {
843847
outputText,
844848
delivered: params?.delivered,
845849
deliveryAttempted: params?.deliveryAttempted,
850+
deliveryError: params?.deliveryError,
846851
...telemetry,
847852
});
848853

@@ -892,19 +897,25 @@ export async function runCronIsolatedAgentTurn(params: {
892897
...deliveryResult.result,
893898
deliveryAttempted:
894899
deliveryResult.result.deliveryAttempted ?? deliveryResult.deliveryAttempted,
900+
deliveryError: deliveryResult.result.deliveryError ?? deliveryResult.deliveryError,
895901
};
896902
if (!hasFatalErrorPayload || deliveryResult.result.status !== "ok") {
897903
return resultWithDeliveryMeta;
898904
}
899905
return resolveRunOutcome({
900906
delivered: deliveryResult.result.delivered,
901907
deliveryAttempted: resultWithDeliveryMeta.deliveryAttempted,
908+
deliveryError: resultWithDeliveryMeta.deliveryError,
902909
});
903910
}
904911
const delivered = deliveryResult.delivered;
905912
const deliveryAttempted = deliveryResult.deliveryAttempted;
906913
summary = deliveryResult.summary;
907914
outputText = deliveryResult.outputText;
908915

909-
return resolveRunOutcome({ delivered, deliveryAttempted });
916+
return resolveRunOutcome({
917+
delivered,
918+
deliveryAttempted,
919+
deliveryError: deliveryResult.deliveryError,
920+
});
910921
}

src/cron/service.jobs.test.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,16 +297,14 @@ describe("applyJobPatch", () => {
297297
expect(job.delivery?.failureDestination?.to).toBe("https://example.invalid/failure");
298298
});
299299

300-
it("rejects Telegram delivery with invalid target (chatId/topicId format)", () => {
300+
it("accepts Telegram delivery with legacy slash topic targets", () => {
301301
const job = createIsolatedAgentTurnJob("job-telegram-invalid", {
302302
mode: "announce",
303303
channel: "telegram",
304304
to: "-10012345/6789",
305305
});
306306

307-
expect(() => applyJobPatch(job, { enabled: true })).toThrow(
308-
'Invalid Telegram delivery target "-10012345/6789". Use colon (:) as delimiter for topics, not slash. Valid formats: -1001234567890, -1001234567890:123, -1001234567890:topic:123, @username, https://t.me/username',
309-
);
307+
expect(() => applyJobPatch(job, { enabled: true })).not.toThrow();
310308
});
311309

312310
it("accepts Telegram delivery with t.me URL", () => {

0 commit comments

Comments
 (0)