Skip to content

Commit 585ce38

Browse files
committed
fix(telegram): stabilize topic dispatch runtime
1 parent 48e1256 commit 585ce38

14 files changed

Lines changed: 216 additions & 32 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai
4646
- Plugins/update: move ClawHub-preferred externalized plugin installs back to ClawHub after an earlier npm fallback once the ClawHub package becomes available. Thanks @vincentkoc.
4747
- Plugins/update: clean stale bundled load paths for already-externalized pinned npm and ClawHub plugin installs, so release-channel sync does not leave removed bundled paths ahead of the installed external package. Thanks @vincentkoc.
4848
- Plugins/CLI: include package dependency install state in `openclaw plugins list --json` so scripts can spot missing plugin dependencies without runtime-loading plugins.
49+
- Telegram: accept plugin-owned numeric forum-topic targets in the agent message tool and keep reply-dispatch provider chunks behind a real stable runtime alias during in-place package updates. Fixes #77137. Thanks @richardmqq.
4950
- Google Meet: preserve `realtime.introMessage: ""` so realtime Chrome joins can stay silent instead of restoring the default spoken intro. Thanks @vincentkoc.
5051
- Discord/status: add degraded Discord transport and gateway event-loop starvation signals to `openclaw channels status`, `openclaw status --deep`, and fetch-timeout logs so intermittent socket resets do not look like a healthy running channel. (#76327) Thanks @joshavant.
5152
- Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc.

docs/channels/telegram.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,11 +777,12 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
777777
- `channels.telegram.dms["<user_id>"].historyLimit`
778778
- `channels.telegram.retry` config applies to Telegram send helpers (CLI/tools/actions) for recoverable outbound API errors. Inbound final-reply delivery also uses a bounded safe-send retry for Telegram pre-connect failures, but it does not retry ambiguous post-send network envelopes that could duplicate visible messages.
779779

780-
CLI send target can be numeric chat ID or username:
780+
CLI and message-tool send targets can be numeric chat ID, username, or a forum topic target:
781781

782782
```bash
783783
openclaw message send --channel telegram --target 123456789 --message "hi"
784784
openclaw message send --channel telegram --target @name --message "hi"
785+
openclaw message send --channel telegram --target -1001234567890:topic:42 --message "hi topic"
785786
```
786787

787788
Telegram polls use `openclaw message poll` and support forum topics:

docs/cli/message.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ Channel selection:
2727
Target formats (`--target`):
2828

2929
- WhatsApp: E.164, group JID, or WhatsApp Channel/Newsletter JID (`...@newsletter`)
30-
- Telegram: chat id or `@username`
30+
- Telegram: chat id, `@username`, or forum topic target (`-1001234567890:topic:42`, or `--thread-id 42`)
3131
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
3232
- Google Chat: `spaces/<spaceId>` or `users/<userId>`
3333
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)

scripts/runtime-postbuild.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ const LEGACY_ROOT_RUNTIME_COMPAT_ALIASES = [
4141
["server-close-DsVPJDIx.js", "server-close.runtime.js"],
4242
["server-close-DvAvfgr8.js", "server-close.runtime.js"],
4343
// v2026.5.3 beta reply-dispatch lazy chunks.
44-
["provider-dispatcher-6EQEtc-t.js", "provider-dispatcher.js"],
45-
["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.js"],
46-
["provider-dispatcher-JG96SkLX.js", "provider-dispatcher.js"],
44+
["provider-dispatcher-6EQEtc-t.js", "provider-dispatcher.runtime.js"],
45+
["provider-dispatcher-BpL2E92x.js", "provider-dispatcher.runtime.js"],
46+
["provider-dispatcher-JG96SkLX.js", "provider-dispatcher.runtime.js"],
4747
];
4848
const LEGACY_CLI_EXIT_COMPAT_CHUNKS = [
4949
{

src/agents/tools/message-tool.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,34 @@ describe("message tool path passthrough", () => {
427427
});
428428
});
429429

430+
describe("message tool Telegram topic targets", () => {
431+
it("passes numeric forum topic targets and thread ids to outbound resolution", async () => {
432+
mockSendResult({ to: "telegram:-1001234567890:topic:42" });
433+
434+
const call = await executeSend({
435+
toolOptions: {
436+
currentChannelProvider: "telegram",
437+
currentChannelId: "telegram:-1001234567890:topic:42",
438+
},
439+
action: {
440+
channel: "telegram",
441+
target: "-1001234567890:topic:42",
442+
threadId: "42",
443+
message: "topic hello",
444+
},
445+
});
446+
447+
expect(call?.params).toEqual(
448+
expect.objectContaining({
449+
channel: "telegram",
450+
target: "-1001234567890:topic:42",
451+
threadId: "42",
452+
message: "topic hello",
453+
}),
454+
);
455+
});
456+
});
457+
430458
describe("message tool schema scoping", () => {
431459
const telegramPlugin = createChannelPlugin({
432460
id: "telegram",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./provider-dispatcher.js";

src/infra/outbound/message-action-runner.core-send.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,36 @@ describe("runMessageAction core send routing", () => {
123123
}),
124124
);
125125
});
126+
127+
it("accepts Telegram numeric forum topic targets through plugin-owned grammar", async () => {
128+
setActivePluginRegistry(createTestRegistry([]));
129+
130+
const result = await runMessageAction({
131+
cfg: {
132+
channels: {
133+
telegram: {
134+
botToken: "123:test",
135+
},
136+
},
137+
} as OpenClawConfig,
138+
action: "send",
139+
params: {
140+
channel: "telegram",
141+
target: "-1001234567890:topic:42",
142+
message: "topic hello",
143+
},
144+
dryRun: true,
145+
});
146+
147+
if (result.kind !== "send") {
148+
throw new Error(`Expected send result, got ${result.kind}`);
149+
}
150+
expect(result.to).toBe("telegram:-1001234567890:topic:42");
151+
expect(result.payload).toEqual(
152+
expect.objectContaining({
153+
to: "telegram:-1001234567890:topic:42",
154+
dryRun: true,
155+
}),
156+
);
157+
});
126158
});

src/infra/outbound/target-normalization.test.ts

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
22
import type { OpenClawConfig } from "../../config/config.js";
33

4+
const getLoadedChannelPluginMock = vi.hoisted(() => vi.fn());
45
const getChannelPluginMock = vi.hoisted(() => vi.fn());
56
const getActivePluginChannelRegistryVersionMock = vi.hoisted(() => vi.fn());
67

@@ -15,7 +16,11 @@ let normalizeTargetForProvider: TargetNormalizationModule["normalizeTargetForPro
1516
let resetTargetNormalizerCacheForTests: TargetNormalizationModule["__testing"]["resetTargetNormalizerCacheForTests"];
1617

1718
vi.mock("../../channels/plugins/registry-loaded-read.js", () => ({
18-
getLoadedChannelPluginForRead: (...args: unknown[]) => getChannelPluginMock(...args),
19+
getLoadedChannelPluginForRead: (...args: unknown[]) => getLoadedChannelPluginMock(...args),
20+
}));
21+
22+
vi.mock("../../channels/plugins/index.js", () => ({
23+
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
1924
}));
2025

2126
vi.mock("../../plugins/runtime.js", () => ({
@@ -38,6 +43,7 @@ beforeAll(async () => {
3843
});
3944

4045
beforeEach(() => {
46+
getLoadedChannelPluginMock.mockReset();
4147
getChannelPluginMock.mockReset();
4248
getActivePluginChannelRegistryVersionMock.mockReset();
4349
resetTargetNormalizerCacheForTests();
@@ -58,6 +64,7 @@ describe("normalizeTargetForProvider", () => {
5864
{
5965
provider: "unknown",
6066
setup: () => {
67+
getLoadedChannelPluginMock.mockReturnValueOnce(undefined);
6168
getChannelPluginMock.mockReturnValueOnce(undefined);
6269
},
6370
expected: "raw-id",
@@ -66,6 +73,7 @@ describe("normalizeTargetForProvider", () => {
6673
provider: "alpha",
6774
setup: () => {
6875
getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(1);
76+
getLoadedChannelPluginMock.mockReturnValueOnce(undefined);
6977
getChannelPluginMock.mockReturnValueOnce(undefined);
7078
},
7179
expected: "raw-id",
@@ -85,7 +93,7 @@ describe("normalizeTargetForProvider", () => {
8593
.mockReturnValueOnce(10)
8694
.mockReturnValueOnce(10)
8795
.mockReturnValueOnce(11);
88-
getChannelPluginMock
96+
getLoadedChannelPluginMock
8997
.mockReturnValueOnce({
9098
messaging: { normalizeTarget: firstNormalizer },
9199
})
@@ -97,14 +105,30 @@ describe("normalizeTargetForProvider", () => {
97105
expect(normalizeTargetForProvider("alpha", " def ")).toBe("DEF");
98106
expect(normalizeTargetForProvider("alpha", " ghi ")).toBe("next:ghi");
99107

100-
expect(getChannelPluginMock).toHaveBeenCalledTimes(2);
108+
expect(getLoadedChannelPluginMock).toHaveBeenCalledTimes(2);
109+
expect(getChannelPluginMock).not.toHaveBeenCalled();
101110
expect(firstNormalizer).toHaveBeenCalledTimes(2);
102111
expect(secondNormalizer).toHaveBeenCalledTimes(1);
103112
});
104113

114+
it("uses bundled/catalog target normalization when the channel is not loaded", () => {
115+
getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(30);
116+
getLoadedChannelPluginMock.mockReturnValueOnce(undefined);
117+
getChannelPluginMock.mockReturnValueOnce({
118+
messaging: {
119+
normalizeTarget: (raw: string) =>
120+
raw.trim() === "-1001234567890:topic:42" ? "telegram:-1001234567890:topic:42" : undefined,
121+
},
122+
});
123+
124+
expect(normalizeTargetForProvider("telegram", " -1001234567890:topic:42 ")).toBe(
125+
"telegram:-1001234567890:topic:42",
126+
);
127+
});
128+
105129
it("returns undefined when the provider normalizer resolves to an empty value", () => {
106130
getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(20);
107-
getChannelPluginMock.mockReturnValueOnce({
131+
getLoadedChannelPluginMock.mockReturnValueOnce({
108132
messaging: {
109133
normalizeTarget: () => "",
110134
},
@@ -121,7 +145,7 @@ describe("resolveNormalizedTargetInput", () => {
121145

122146
it("returns raw and normalized values", () => {
123147
getActivePluginChannelRegistryVersionMock.mockReturnValueOnce(1);
124-
getChannelPluginMock.mockReturnValueOnce({
148+
getLoadedChannelPluginMock.mockReturnValueOnce({
125149
messaging: {
126150
normalizeTarget: (raw: string) => raw.trim().toUpperCase(),
127151
},
@@ -137,7 +161,7 @@ describe("resolveNormalizedTargetInput", () => {
137161
describe("looksLikeTargetId", () => {
138162
it("uses plugin looksLikeId when available", () => {
139163
const pluginLooksLikeId = vi.fn((raw: string, normalized: string) => raw !== normalized);
140-
getChannelPluginMock.mockReturnValueOnce({
164+
getLoadedChannelPluginMock.mockReturnValueOnce({
141165
messaging: {
142166
targetResolver: {
143167
looksLikeId: pluginLooksLikeId,
@@ -158,17 +182,38 @@ describe("looksLikeTargetId", () => {
158182
it.each(["channel:C123", "@alice", "#general", "+15551234567", "conversation:abc", "foo@thread"])(
159183
"falls back to built-in id-like heuristics for %s",
160184
(raw) => {
185+
getLoadedChannelPluginMock.mockReturnValueOnce(undefined);
161186
getChannelPluginMock.mockReturnValueOnce(undefined);
162187
expect(looksLikeTargetId({ channel: "workspace", raw })).toBe(true);
163188
},
164189
);
190+
191+
it("uses bundled/catalog target id detection when the channel is not loaded", () => {
192+
getLoadedChannelPluginMock.mockReturnValueOnce(undefined);
193+
getChannelPluginMock.mockReturnValueOnce({
194+
messaging: {
195+
targetResolver: {
196+
looksLikeId: (raw: string, normalized?: string) =>
197+
raw === "-1001234567890:topic:42" && normalized === "telegram:-1001234567890:topic:42",
198+
},
199+
},
200+
});
201+
202+
expect(
203+
looksLikeTargetId({
204+
channel: "telegram",
205+
raw: "-1001234567890:topic:42",
206+
normalized: "telegram:-1001234567890:topic:42",
207+
}),
208+
).toBe(true);
209+
});
165210
});
166211

167212
describe("maybeResolvePluginMessagingTarget", () => {
168213
const cfg = {} as OpenClawConfig;
169214

170215
it("returns undefined when requireIdLike is set and the target is not id-like", async () => {
171-
getChannelPluginMock.mockReturnValueOnce({
216+
getLoadedChannelPluginMock.mockReturnValueOnce({
172217
messaging: {
173218
targetResolver: {
174219
looksLikeId: () => false,
@@ -194,7 +239,7 @@ describe("maybeResolvePluginMessagingTarget", () => {
194239
kind: "group",
195240
display: "general",
196241
});
197-
getChannelPluginMock
242+
getLoadedChannelPluginMock
198243
.mockReturnValueOnce({
199244
messaging: {
200245
normalizeTarget: (raw: string) => raw.trim().toUpperCase(),
@@ -234,7 +279,7 @@ describe("maybeResolvePluginMessagingTarget", () => {
234279
describe("buildTargetResolverSignature", () => {
235280
it("builds stable signatures from resolver hint and looksLikeId source", () => {
236281
const looksLikeId = (value: string) => value.startsWith("C");
237-
getChannelPluginMock.mockReturnValueOnce({
282+
getLoadedChannelPluginMock.mockReturnValueOnce({
238283
messaging: {
239284
targetResolver: {
240285
hint: "Use channel id",
@@ -244,7 +289,7 @@ describe("buildTargetResolverSignature", () => {
244289
});
245290

246291
const first = buildTargetResolverSignature("workspace");
247-
getChannelPluginMock.mockReturnValueOnce({
292+
getLoadedChannelPluginMock.mockReturnValueOnce({
248293
messaging: {
249294
targetResolver: {
250295
hint: "Use channel id",
@@ -258,7 +303,7 @@ describe("buildTargetResolverSignature", () => {
258303
});
259304

260305
it("changes when resolver metadata changes", () => {
261-
getChannelPluginMock.mockReturnValueOnce({
306+
getLoadedChannelPluginMock.mockReturnValueOnce({
262307
messaging: {
263308
targetResolver: {
264309
hint: "Use channel id",
@@ -268,7 +313,7 @@ describe("buildTargetResolverSignature", () => {
268313
});
269314
const first = buildTargetResolverSignature("workspace");
270315

271-
getChannelPluginMock.mockReturnValueOnce({
316+
getLoadedChannelPluginMock.mockReturnValueOnce({
272317
messaging: {
273318
targetResolver: {
274319
hint: "Use user id",

src/infra/outbound/target-normalization.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { getChannelPlugin } from "../../channels/plugins/index.js";
12
import { getLoadedChannelPluginForRead } from "../../channels/plugins/registry-loaded-read.js";
3+
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
24
import type { ChannelDirectoryEntryKind, ChannelId } from "../../channels/plugins/types.public.js";
35
import type { OpenClawConfig } from "../../config/types.openclaw.js";
46
import { getActivePluginChannelRegistryVersion } from "../../plugins/runtime.js";
@@ -19,6 +21,10 @@ type TargetNormalizerCacheEntry = {
1921

2022
const targetNormalizerCacheByChannelId = new Map<string, TargetNormalizerCacheEntry>();
2123

24+
function resolveChannelPluginForTargetRead(channelId: ChannelId): ChannelPlugin | undefined {
25+
return getLoadedChannelPluginForRead(channelId) ?? getChannelPlugin(channelId);
26+
}
27+
2228
function resetTargetNormalizerCacheForTests(): void {
2329
targetNormalizerCacheByChannelId.clear();
2430
}
@@ -33,7 +39,7 @@ function resolveTargetNormalizer(channelId: ChannelId): TargetNormalizer {
3339
if (cached && cached.version === version) {
3440
return cached.normalizer;
3541
}
36-
const plugin = getLoadedChannelPluginForRead(channelId);
42+
const plugin = resolveChannelPluginForTargetRead(channelId);
3743
const normalizer = plugin?.messaging?.normalizeTarget;
3844
targetNormalizerCacheByChannelId.set(channelId, {
3945
version,
@@ -85,7 +91,7 @@ export function looksLikeTargetId(params: {
8591
}): boolean {
8692
const normalizedInput =
8793
params.normalized ?? normalizeTargetForProvider(params.channel, params.raw);
88-
const lookup = getLoadedChannelPluginForRead(params.channel)?.messaging?.targetResolver
94+
const lookup = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver
8995
?.looksLikeId;
9096
if (lookup) {
9197
return lookup(params.raw, normalizedInput ?? params.raw);
@@ -117,7 +123,7 @@ export async function maybeResolvePluginMessagingTarget(params: {
117123
if (!normalizedInput) {
118124
return undefined;
119125
}
120-
const resolver = getLoadedChannelPluginForRead(params.channel)?.messaging?.targetResolver;
126+
const resolver = resolveChannelPluginForTargetRead(params.channel)?.messaging?.targetResolver;
121127
if (!resolver?.resolveTarget) {
122128
return undefined;
123129
}
@@ -150,7 +156,7 @@ export async function maybeResolvePluginMessagingTarget(params: {
150156
}
151157

152158
export function buildTargetResolverSignature(channel: ChannelId): string {
153-
const plugin = getLoadedChannelPluginForRead(channel);
159+
const plugin = resolveChannelPluginForTargetRead(channel);
154160
const resolver = plugin?.messaging?.targetResolver;
155161
const hint = resolver?.hint ?? "";
156162
const looksLike = resolver?.looksLikeId;

0 commit comments

Comments
 (0)