Skip to content

Commit 645b9c0

Browse files
committed
fix(cron): route topic targets through plugins
1 parent 68bcd4e commit 645b9c0

11 files changed

Lines changed: 143 additions & 26 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
5555

5656
- CLI/tasks: reject partially numeric `openclaw tasks audit --limit` values so audit limits must be real positive integers instead of accepting strings like `5abc`. (#84901) Thanks @jbetala7.
5757
- Status/diagnostics: bound deep Docker audit probes so `openclaw status --deep` reports slow container checks instead of hanging behind unbounded inspection. (#85476) Thanks @giodl73-repo.
58+
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:`, `:topicId`, and legacy slash forms for announce delivery. Thanks @etticat.
5859
- Twitch: keep stale message-handler cleanup callbacks from removing newer handler registrations for the same account, preserving inbound message delivery after reconnects. Fixes #83888. (#85425) Thanks @alkor2000.
5960
- Memory/LanceDB: expose public memory artifacts through the active memory provider bridge so memory-wiki imports durable memory files, daily notes, dream reports, and event logs without depending on memory-core internals. Fixes #83604. (#85060) Thanks @brokemac79.
6061
- Docker setup: stop printing the Gateway bearer token in setup logs and printed follow-up commands.

docs/automation/cron-jobs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ Before an isolated cron run enters the agent runner, OpenClaw checks reachable l
167167
| `webhook` | POST finished event payload to a URL |
168168
| `none` | No runner fallback delivery |
169169

170-
Use `--announce --channel telegram --to "-1001234567890"` for channel delivery. For Telegram forum topics, use `-1001234567890:topic:123`; direct RPC/config callers may also pass `delivery.threadId` as a string or number. Slack/Discord/Mattermost targets should use explicit prefixes (`channel:<id>`, `user:<id>`). Matrix room IDs are case-sensitive; use the exact room ID or `room:!room:server` form from Matrix.
170+
Use `--announce --channel telegram --to "-1001234567890"` for channel delivery. For Telegram forum topics, use `-1001234567890:topic:123`; OpenClaw also accepts the Telegram-owned `-1001234567890:123` and legacy `-1001234567890/123` topic forms. Direct RPC/config callers may also pass `delivery.threadId` as a string or number. Slack/Discord/Mattermost targets should use explicit prefixes (`channel:<id>`, `user:<id>`). Matrix room IDs are case-sensitive; use the exact room ID or `room:!room:server` form from Matrix.
171171

172172
When announce delivery uses `channel: "last"` or omits `channel`, a provider-prefixed target such as `telegram:123` can select the channel before cron falls back to session history or a single configured channel. Only prefixes advertised by the loaded plugin are provider selectors. If `delivery.channel` is explicit, the target prefix must name the same provider; for example, `channel: "whatsapp"` with `to: "telegram:123"` is rejected instead of letting WhatsApp interpret the Telegram ID as a phone number. Target-kind and service prefixes such as `channel:<id>`, `user:<id>`, `imessage:<handle>`, and `sms:<number>` remain channel-owned target syntax, not provider selectors.
173173

extensions/telegram/src/targets.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ describe("parseTelegramTarget", () => {
6464
});
6565
});
6666

67+
it("parses legacy chatId/topicId format", () => {
68+
expect(parseTelegramTarget("-1001234567890/123")).toEqual({
69+
chatId: "-1001234567890",
70+
messageThreadId: 123,
71+
chatType: "group",
72+
});
73+
});
74+
6775
it("parses chatId:topic:topicId format", () => {
6876
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
6977
chatId: "-1001234567890",

extensions/telegram/src/targets.ts

Lines changed: 10 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 numeric topic/thread ID)
7778
* - `chatId:topicId` (numeric topic/thread ID)
7879
* - `chatId:topic:topicId` (explicit topic marker; preferred)
7980
*/
@@ -100,6 +101,15 @@ export function parseTelegramTarget(to: string): TelegramTarget {
100101
};
101102
}
102103

104+
const slashMatch = /^(-?\d+)\/(\d+)$/.exec(normalized);
105+
if (slashMatch) {
106+
return {
107+
chatId: slashMatch[1],
108+
messageThreadId: Number.parseInt(slashMatch[2], 10),
109+
chatType: resolveTelegramChatType(slashMatch[1]),
110+
};
111+
}
112+
103113
const colonMatch = /^(.+):(\d+)$/.exec(normalized);
104114
if (colonMatch) {
105115
return {

src/cron/isolated-agent.direct-delivery-core-channels.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,18 @@ describe("runCronIsolatedAgentTurn telegram forum-topic direct delivery", () =>
404404
});
405405
});
406406

407+
it("routes legacy slash supergroup topic targets through plugin parsing", async () => {
408+
await expectTelegramAnnounceDelivery({
409+
to: "-1003774691294/47",
410+
payloads: [{ text: "topic 47 completion" }],
411+
expected: {
412+
chatId: "-1003774691294",
413+
text: "topic 47 completion",
414+
messageThreadId: 47,
415+
},
416+
});
417+
});
418+
407419
it("delivers only the final assistant-visible text to forum-topic telegram targets", async () => {
408420
await expectTelegramAnnounceDelivery({
409421
to: "123:topic:42",

src/cron/isolated-agent.test-setup.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ function parseTelegramTargetForTest(raw: string): {
6464
chatType: "group",
6565
};
6666
}
67+
const slashPair = /^(-?\d+)\/(\d+)$/i.exec(trimmed);
68+
if (slashPair) {
69+
return {
70+
chatId: slashPair[1],
71+
messageThreadId: Number.parseInt(slashPair[2], 10),
72+
chatType: slashPair[1].startsWith("-") ? "group" : "direct",
73+
};
74+
}
6775
return {
6876
chatId: trimmed,
6977
chatType: trimmed.startsWith("-") ? "group" : "unknown",
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
import type { ParsedChannelExplicitTarget } from "../../channels/plugins/target-parsing-loaded.js";
2+
import type { OpenClawConfig } from "../../config/types.openclaw.js";
3+
import { resolveOutboundChannelPlugin } from "../../infra/outbound/channel-resolution.js";
14
export { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
25
export { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js";
36
export { resolveFirstBoundAccountId } from "../../routing/bound-account-read.js";
7+
8+
export function parseExplicitTargetForDelivery(params: {
9+
cfg: OpenClawConfig;
10+
channel: string;
11+
rawTarget: string;
12+
}): ParsedChannelExplicitTarget | null {
13+
return (
14+
resolveOutboundChannelPlugin({
15+
channel: params.channel,
16+
cfg: params.cfg,
17+
allowBootstrap: true,
18+
})?.messaging?.parseExplicitTarget?.({ raw: params.rawTarget }) ?? null
19+
);
20+
}

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,19 @@ describe("resolveDeliveryTarget", () => {
917917
expect(result.threadId).toBe(1008013);
918918
});
919919

920+
it("parses plugin-owned slash topic targets into delivery threadId", async () => {
921+
setMainSessionEntry(undefined);
922+
923+
const result = await resolveDeliveryTarget(makeCfg({ bindings: [] }), AGENT_ID, {
924+
channel: "telegram",
925+
to: "-100200300/77",
926+
});
927+
928+
expect(result.ok).toBe(true);
929+
expect(result.to).toBe("-100200300");
930+
expect(result.threadId).toBe(77);
931+
});
932+
920933
it("prefers explicit telegram :topic: targets over session-derived threadId", async () => {
921934
setLastSessionEntry({
922935
sessionId: "sess-telegram-topic",

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

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import { formatErrorMessage } from "../../infra/errors.js";
99
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-id-resolution.js";
1010
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
1111
import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js";
12-
import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js";
12+
import {
13+
resolveSessionDeliveryTarget,
14+
type ExplicitTargetParser,
15+
} from "../../infra/outbound/targets-session.js";
1316
import type { OutboundChannel } from "../../infra/outbound/targets.js";
1417
import { normalizeAccountId } from "../../routing/session-key.js";
1518
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
@@ -64,6 +67,7 @@ async function resolveOutboundTargetWithRuntime(
6467
function normalizeTargetForThreadCarry(
6568
channel: Exclude<OutboundChannel, "none"> | undefined,
6669
to: string | undefined,
70+
parseExplicitTarget?: ExplicitTargetParser,
6771
): string | undefined {
6872
if (!channel || !to) {
6973
return undefined;
@@ -74,7 +78,10 @@ function normalizeTargetForThreadCarry(
7478
if (!comparable) {
7579
return undefined;
7680
}
77-
const parsed = parseExplicitTargetForLoadedChannel(channel, comparable);
81+
const parsed = (parseExplicitTarget ?? parseExplicitTargetForLoadedChannel)(
82+
channel,
83+
comparable,
84+
);
7885
const base = parsed?.to ?? comparable;
7986
return normalizeTargetForProvider(channel, base) ?? base;
8087
} catch {
@@ -86,15 +93,24 @@ function deliveryTargetsShareThreadRoute(params: {
8693
channel: Exclude<OutboundChannel, "none"> | undefined;
8794
to: string | undefined;
8895
lastTo: string | undefined;
96+
parseExplicitTarget?: ExplicitTargetParser;
8997
}): boolean {
9098
if (!params.to || !params.lastTo) {
9199
return false;
92100
}
93101
if (params.to === params.lastTo) {
94102
return true;
95103
}
96-
const normalizedTo = normalizeTargetForThreadCarry(params.channel, params.to);
97-
const normalizedLastTo = normalizeTargetForThreadCarry(params.channel, params.lastTo);
104+
const normalizedTo = normalizeTargetForThreadCarry(
105+
params.channel,
106+
params.to,
107+
params.parseExplicitTarget,
108+
);
109+
const normalizedLastTo = normalizeTargetForThreadCarry(
110+
params.channel,
111+
params.lastTo,
112+
params.parseExplicitTarget,
113+
);
98114
return Boolean(normalizedTo && normalizedLastTo && normalizedTo === normalizedLastTo);
99115
}
100116

@@ -128,6 +144,13 @@ export async function resolveDeliveryTarget(
128144
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
129145
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
130146
const allowMismatchedLastTo = requestedChannel === "last";
147+
const deliveryTargetRuntime = await loadDeliveryTargetRuntime();
148+
const parseExplicitTarget: ExplicitTargetParser = (channel, rawTarget) =>
149+
deliveryTargetRuntime.parseExplicitTargetForDelivery({
150+
cfg,
151+
channel,
152+
rawTarget,
153+
});
131154

132155
const sessionCfg = cfg.session;
133156
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
@@ -167,6 +190,7 @@ export async function resolveDeliveryTarget(
167190
explicitTo,
168191
explicitThreadId: jobPayload.threadId,
169192
allowMismatchedLastTo,
193+
parseExplicitTarget,
170194
});
171195

172196
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
@@ -197,6 +221,7 @@ export async function resolveDeliveryTarget(
197221
fallbackChannel,
198222
allowMismatchedLastTo,
199223
mode: preliminary.mode,
224+
parseExplicitTarget,
200225
})
201226
: preliminary;
202227

@@ -213,8 +238,11 @@ export async function resolveDeliveryTarget(
213238
: undefined;
214239
let accountId = explicitAccountId ?? resolved.accountId;
215240
if (!accountId && channel) {
216-
const { resolveFirstBoundAccountId } = await loadDeliveryTargetRuntime();
217-
accountId = resolveFirstBoundAccountId({ cfg, channelId: channel, agentId });
241+
accountId = deliveryTargetRuntime.resolveFirstBoundAccountId({
242+
cfg,
243+
channelId: channel,
244+
agentId,
245+
});
218246
}
219247

220248
// job.delivery.accountId takes highest precedence — explicitly set by the job author.
@@ -233,20 +261,11 @@ export async function resolveDeliveryTarget(
233261
channel,
234262
to: resolved.to,
235263
lastTo: resolved.lastTo,
264+
parseExplicitTarget,
236265
}))
237266
? resolved.threadId
238267
: undefined;
239268

240-
if (channel === "telegram" && typeof toCandidate === "string") {
241-
const topicMatch = toCandidate.match(/:topic:(\d+)$/i);
242-
if (topicMatch) {
243-
if (jobPayload.threadId == null || jobPayload.threadId === "") {
244-
threadId = Number(topicMatch[1]);
245-
}
246-
toCandidate = toCandidate.replace(/:topic:\d+$/i, "");
247-
}
248-
}
249-
250269
if (!channel) {
251270
return {
252271
ok: false,
@@ -263,8 +282,7 @@ export async function resolveDeliveryTarget(
263282

264283
let effectiveAllowFrom: string[] | undefined;
265284
if (mode === "implicit") {
266-
const { getLoadedChannelPluginForRead, mapAllowFromEntries } =
267-
await loadDeliveryTargetRuntime();
285+
const { getLoadedChannelPluginForRead, mapAllowFromEntries } = deliveryTargetRuntime;
268286
const channelPlugin = getLoadedChannelPluginForRead(channel);
269287
const resolvedAccountId = normalizeAccountId(accountId);
270288
const configuredAllowFromRaw = channelPlugin?.config.resolveAllowFrom?.({

src/infra/outbound/targets-session.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
22
parseExplicitTargetForLoadedChannel,
3-
resolveRouteTargetForLoadedChannel,
3+
type ParsedChannelExplicitTarget,
44
} from "../../channels/plugins/target-parsing-loaded.js";
55
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js";
66
import type { SessionEntry } from "../../config/sessions.js";
7-
import { channelRouteTargetsShareConversation } from "../../plugin-sdk/channel-route.js";
7+
import {
8+
channelRouteTargetsShareConversation,
9+
resolveChannelRouteTargetWithParser,
10+
} from "../../plugin-sdk/channel-route.js";
811
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
912
import {
1013
isDeliverableMessageChannel,
@@ -30,10 +33,28 @@ export type SessionDeliveryTarget = {
3033
lastThreadId?: string | number;
3134
};
3235

36+
export type ExplicitTargetParser = (
37+
channel: string,
38+
rawTarget: string,
39+
) => ParsedChannelExplicitTarget | null;
40+
41+
function resolveRouteTarget(params: {
42+
channel: string;
43+
rawTarget?: string | null;
44+
fallbackThreadId?: string | number | null;
45+
parseExplicitTarget?: ExplicitTargetParser;
46+
}) {
47+
return resolveChannelRouteTargetWithParser({
48+
...params,
49+
parseExplicitTarget: params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel,
50+
});
51+
}
52+
3353
function parseExplicitTargetWithPlugin(params: {
3454
channel?: DeliverableMessageChannel;
3555
fallbackChannel?: DeliverableMessageChannel;
3656
raw?: string;
57+
parseExplicitTarget?: ExplicitTargetParser;
3758
}) {
3859
const raw = params.raw?.trim();
3960
if (!raw) {
@@ -43,7 +64,7 @@ function parseExplicitTargetWithPlugin(params: {
4364
if (!provider) {
4465
return null;
4566
}
46-
return parseExplicitTargetForLoadedChannel(provider, raw);
67+
return (params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel)(provider, raw);
4768
}
4869

4970
export function resolveSessionDeliveryTarget(params: {
@@ -64,25 +85,28 @@ export function resolveSessionDeliveryTarget(params: {
6485
turnSourceTo?: string;
6586
turnSourceAccountId?: string;
6687
turnSourceThreadId?: string | number;
88+
parseExplicitTarget?: ExplicitTargetParser;
6789
}): SessionDeliveryTarget {
6890
const context = deliveryContextFromSession(params.entry);
6991
const sessionLastChannel =
7092
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
7193
const parsedSessionTarget = sessionLastChannel
72-
? resolveRouteTargetForLoadedChannel({
94+
? resolveRouteTarget({
7395
channel: sessionLastChannel,
7496
rawTarget: context?.to,
7597
fallbackThreadId: context?.threadId,
98+
parseExplicitTarget: params.parseExplicitTarget,
7699
})
77100
: null;
78101

79102
const hasTurnSourceChannel = params.turnSourceChannel != null;
80103
const parsedTurnSourceTarget =
81104
hasTurnSourceChannel && params.turnSourceChannel
82-
? resolveRouteTargetForLoadedChannel({
105+
? resolveRouteTarget({
83106
channel: params.turnSourceChannel,
84107
rawTarget: params.turnSourceTo,
85108
fallbackThreadId: params.turnSourceThreadId,
109+
parseExplicitTarget: params.parseExplicitTarget,
86110
})
87111
: null;
88112
const hasTurnSourceThreadId = parsedTurnSourceTarget?.threadId != null;
@@ -135,6 +159,7 @@ export function resolveSessionDeliveryTarget(params: {
135159
channel,
136160
fallbackChannel: !channel ? lastChannel : undefined,
137161
raw: rawExplicitTo,
162+
parseExplicitTarget: params.parseExplicitTarget,
138163
});
139164
if (parsedExplicitTarget?.to) {
140165
explicitTo = parsedExplicitTarget.to;

0 commit comments

Comments
 (0)