Skip to content

Commit 9175491

Browse files
authored
fix(cron): route topic targets through channel plugins
Route cron announce topic target parsing through channel plugin target parsers instead of Telegram-specific cron core code. Keep supported Telegram topic forms in the Telegram plugin and document the channel-owned shorthand.
1 parent f4b92f5 commit 9175491

7 files changed

Lines changed: 102 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
6363
- 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.
6464
- 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.
6565
- Providers/Anthropic: migrate 1M context handling to GA-capable Claude 4.x models by sizing eligible models at 1M without the retired `context-1m-2025-08-07` beta, ignoring that retired beta in older configs, and preserving OAuth-required Anthropic beta headers. (#45613) Thanks @haoyu-haoyu.
66+
- Cron/Telegram: parse forum-topic delivery targets through the Telegram plugin instead of cron core, including `:topic:` and `:topicId` forms for announce delivery. Thanks @etticat.
6667
- 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.
6768
- 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.
6869
- Crabbox: keep AWS hydration compatible with local Actions replay by inlining the hydrate workflow's Node/pnpm setup instead of invoking repo-local composite actions.

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` shorthand. Direct RPC/config callers may 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

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 numeric topic shorthand 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: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { parseExplicitTargetForLoadedChannel } from "../../channels/plugins/target-parsing-loaded.js";
21
import type { ChannelId } from "../../channels/plugins/types.public.js";
32
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
43
import { resolveStorePath } from "../../config/sessions/paths.js";
@@ -9,7 +8,10 @@ import { formatErrorMessage } from "../../infra/errors.js";
98
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-id-resolution.js";
109
import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js";
1110
import { tryResolveLoadedOutboundTarget } from "../../infra/outbound/targets-loaded.js";
12-
import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-session.js";
11+
import {
12+
resolveSessionDeliveryTarget,
13+
type ExplicitTargetParser,
14+
} from "../../infra/outbound/targets-session.js";
1315
import type { OutboundChannel } from "../../infra/outbound/targets.js";
1416
import { normalizeAccountId } from "../../routing/session-key.js";
1517
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
@@ -64,6 +66,7 @@ async function resolveOutboundTargetWithRuntime(
6466
function normalizeTargetForThreadCarry(
6567
channel: Exclude<OutboundChannel, "none"> | undefined,
6668
to: string | undefined,
69+
parseExplicitTarget: ExplicitTargetParser,
6770
): string | undefined {
6871
if (!channel || !to) {
6972
return undefined;
@@ -74,7 +77,7 @@ function normalizeTargetForThreadCarry(
7477
if (!comparable) {
7578
return undefined;
7679
}
77-
const parsed = parseExplicitTargetForLoadedChannel(channel, comparable);
80+
const parsed = parseExplicitTarget(channel, comparable);
7881
const base = parsed?.to ?? comparable;
7982
return normalizeTargetForProvider(channel, base) ?? base;
8083
} catch {
@@ -86,15 +89,24 @@ function deliveryTargetsShareThreadRoute(params: {
8689
channel: Exclude<OutboundChannel, "none"> | undefined;
8790
to: string | undefined;
8891
lastTo: string | undefined;
92+
parseExplicitTarget: ExplicitTargetParser;
8993
}): boolean {
9094
if (!params.to || !params.lastTo) {
9195
return false;
9296
}
9397
if (params.to === params.lastTo) {
9498
return true;
9599
}
96-
const normalizedTo = normalizeTargetForThreadCarry(params.channel, params.to);
97-
const normalizedLastTo = normalizeTargetForThreadCarry(params.channel, params.lastTo);
100+
const normalizedTo = normalizeTargetForThreadCarry(
101+
params.channel,
102+
params.to,
103+
params.parseExplicitTarget,
104+
);
105+
const normalizedLastTo = normalizeTargetForThreadCarry(
106+
params.channel,
107+
params.lastTo,
108+
params.parseExplicitTarget,
109+
);
98110
return Boolean(normalizedTo && normalizedLastTo && normalizedTo === normalizedLastTo);
99111
}
100112

@@ -128,6 +140,13 @@ export async function resolveDeliveryTarget(
128140
const requestedChannel = typeof jobPayload.channel === "string" ? jobPayload.channel : "last";
129141
const explicitTo = typeof jobPayload.to === "string" ? jobPayload.to : undefined;
130142
const allowMismatchedLastTo = requestedChannel === "last";
143+
const deliveryTargetRuntime = await loadDeliveryTargetRuntime();
144+
const parseExplicitTarget: ExplicitTargetParser = (channel, rawTarget) =>
145+
deliveryTargetRuntime.parseExplicitTargetForDelivery({
146+
cfg,
147+
channel,
148+
rawTarget,
149+
});
131150

132151
const sessionCfg = cfg.session;
133152
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
@@ -167,6 +186,7 @@ export async function resolveDeliveryTarget(
167186
explicitTo,
168187
explicitThreadId: jobPayload.threadId,
169188
allowMismatchedLastTo,
189+
parseExplicitTarget,
170190
});
171191

172192
let fallbackChannel: Exclude<OutboundChannel, "none"> | undefined;
@@ -197,6 +217,7 @@ export async function resolveDeliveryTarget(
197217
fallbackChannel,
198218
allowMismatchedLastTo,
199219
mode: preliminary.mode,
220+
parseExplicitTarget,
200221
})
201222
: preliminary;
202223

@@ -213,8 +234,11 @@ export async function resolveDeliveryTarget(
213234
: undefined;
214235
let accountId = explicitAccountId ?? resolved.accountId;
215236
if (!accountId && channel) {
216-
const { resolveFirstBoundAccountId } = await loadDeliveryTargetRuntime();
217-
accountId = resolveFirstBoundAccountId({ cfg, channelId: channel, agentId });
237+
accountId = deliveryTargetRuntime.resolveFirstBoundAccountId({
238+
cfg,
239+
channelId: channel,
240+
agentId,
241+
});
218242
}
219243

220244
// job.delivery.accountId takes highest precedence — explicitly set by the job author.
@@ -233,20 +257,11 @@ export async function resolveDeliveryTarget(
233257
channel,
234258
to: resolved.to,
235259
lastTo: resolved.lastTo,
260+
parseExplicitTarget,
236261
}))
237262
? resolved.threadId
238263
: undefined;
239264

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-
250265
if (!channel) {
251266
return {
252267
ok: false,
@@ -263,8 +278,7 @@ export async function resolveDeliveryTarget(
263278

264279
let effectiveAllowFrom: string[] | undefined;
265280
if (mode === "implicit") {
266-
const { getLoadedChannelPluginForRead, mapAllowFromEntries } =
267-
await loadDeliveryTargetRuntime();
281+
const { getLoadedChannelPluginForRead, mapAllowFromEntries } = deliveryTargetRuntime;
268282
const channelPlugin = getLoadedChannelPluginForRead(channel);
269283
const resolvedAccountId = normalizeAccountId(accountId);
270284
const configuredAllowFromRaw = channelPlugin?.config.resolveAllowFrom?.({

src/infra/outbound/targets-session.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import {
2-
parseExplicitTargetForLoadedChannel,
3-
resolveRouteTargetForLoadedChannel,
4-
} from "../../channels/plugins/target-parsing-loaded.js";
1+
import { parseExplicitTargetForLoadedChannel } from "../../channels/plugins/target-parsing-loaded.js";
52
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.public.js";
63
import type { SessionEntry } from "../../config/sessions.js";
7-
import { channelRouteTargetsShareConversation } from "../../plugin-sdk/channel-route.js";
4+
import {
5+
type ChannelRouteExplicitTargetParser,
6+
channelRouteTargetsShareConversation,
7+
resolveChannelRouteTargetWithParser,
8+
} from "../../plugin-sdk/channel-route.js";
89
import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js";
910
import {
1011
isDeliverableMessageChannel,
@@ -30,10 +31,25 @@ export type SessionDeliveryTarget = {
3031
lastThreadId?: string | number;
3132
};
3233

33-
function parseExplicitTargetWithPlugin(params: {
34+
export type ExplicitTargetParser = ChannelRouteExplicitTargetParser;
35+
36+
function resolveParsedRouteTarget(params: {
37+
channel: string;
38+
rawTarget?: string | null;
39+
fallbackThreadId?: string | number | null;
40+
parseExplicitTarget?: ExplicitTargetParser;
41+
}) {
42+
return resolveChannelRouteTargetWithParser({
43+
...params,
44+
parseExplicitTarget: params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel,
45+
});
46+
}
47+
48+
function parseExplicitDeliveryTarget(params: {
3449
channel?: DeliverableMessageChannel;
3550
fallbackChannel?: DeliverableMessageChannel;
3651
raw?: string;
52+
parseExplicitTarget?: ExplicitTargetParser;
3753
}) {
3854
const raw = params.raw?.trim();
3955
if (!raw) {
@@ -43,7 +59,7 @@ function parseExplicitTargetWithPlugin(params: {
4359
if (!provider) {
4460
return null;
4561
}
46-
return parseExplicitTargetForLoadedChannel(provider, raw);
62+
return (params.parseExplicitTarget ?? parseExplicitTargetForLoadedChannel)(provider, raw);
4763
}
4864

4965
export function resolveSessionDeliveryTarget(params: {
@@ -64,25 +80,28 @@ export function resolveSessionDeliveryTarget(params: {
6480
turnSourceTo?: string;
6581
turnSourceAccountId?: string;
6682
turnSourceThreadId?: string | number;
83+
parseExplicitTarget?: ExplicitTargetParser;
6784
}): SessionDeliveryTarget {
6885
const context = deliveryContextFromSession(params.entry);
6986
const sessionLastChannel =
7087
context?.channel && isDeliverableMessageChannel(context.channel) ? context.channel : undefined;
7188
const parsedSessionTarget = sessionLastChannel
72-
? resolveRouteTargetForLoadedChannel({
89+
? resolveParsedRouteTarget({
7390
channel: sessionLastChannel,
7491
rawTarget: context?.to,
7592
fallbackThreadId: context?.threadId,
93+
parseExplicitTarget: params.parseExplicitTarget,
7694
})
7795
: null;
7896

7997
const hasTurnSourceChannel = params.turnSourceChannel != null;
8098
const parsedTurnSourceTarget =
8199
hasTurnSourceChannel && params.turnSourceChannel
82-
? resolveRouteTargetForLoadedChannel({
100+
? resolveParsedRouteTarget({
83101
channel: params.turnSourceChannel,
84102
rawTarget: params.turnSourceTo,
85103
fallbackThreadId: params.turnSourceThreadId,
104+
parseExplicitTarget: params.parseExplicitTarget,
86105
})
87106
: null;
88107
const hasTurnSourceThreadId = parsedTurnSourceTarget?.threadId != null;
@@ -131,10 +150,11 @@ export function resolveSessionDeliveryTarget(params: {
131150
}
132151

133152
let explicitTo = rawExplicitTo;
134-
const parsedExplicitTarget = parseExplicitTargetWithPlugin({
153+
const parsedExplicitTarget = parseExplicitDeliveryTarget({
135154
channel,
136155
fallbackChannel: !channel ? lastChannel : undefined,
137156
raw: rawExplicitTo,
157+
parseExplicitTarget: params.parseExplicitTarget,
138158
});
139159
if (parsedExplicitTarget?.to) {
140160
explicitTo = parsedExplicitTarget.to;

src/infra/outbound/targets.test-helpers.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,13 @@ function parseTelegramTargetForTest(raw: string): {
6262
const trimmed = raw.trim();
6363
const withoutPrefix = trimmed.replace(/^telegram:/i, "").trim();
6464
const topicMatch = withoutPrefix.match(/^(.*):topic:(\d+)$/i);
65-
const chatId = topicMatch?.[1]?.trim() || withoutPrefix;
66-
const messageThreadId = topicMatch?.[2] ? Number.parseInt(topicMatch[2], 10) : undefined;
65+
const colonMatch = withoutPrefix.match(/^(-?\d+):(\d+)$/i);
66+
const chatId = topicMatch?.[1]?.trim() || colonMatch?.[1] || withoutPrefix;
67+
const messageThreadId = topicMatch?.[2]
68+
? Number.parseInt(topicMatch[2], 10)
69+
: colonMatch?.[2]
70+
? Number.parseInt(colonMatch[2], 10)
71+
: undefined;
6772
const numericId = chatId.startsWith("-") ? chatId.slice(1) : chatId;
6873
const chatType =
6974
/^\d+$/.test(numericId) && !chatId.startsWith("-100")

0 commit comments

Comments
 (0)