mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:51:37 +00:00
fix(telegram): pass parentPeer for forum topic binding inheritance (#9789)
Fixes #9545 and #9351. When a message comes from a Telegram forum topic, the peer ID includes the topic suffix (e.g., `-1001234567890:topic:99`). Users configure bindings with the base group ID, which previously did not match. This adds `parentPeer` to `resolveAgentRoute()` calls for forum groups, enabling binding inheritance from the parent group to all topics. - Extract `buildTelegramParentPeer()` helper in bot/helpers.ts - Pass parentPeer in bot-message-context.ts, bot-handlers.ts, bot-native-commands.ts, and bot.ts (reaction handler) - Add tests for forum topic routing and topic precedence
This commit is contained in:
@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
|
- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351)
|
||||||
- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
|
- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
|
||||||
- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
|
- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
|
||||||
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
|
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bo
|
|||||||
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
|
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
|
||||||
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
||||||
import { resolveMedia } from "./bot/delivery.js";
|
import { resolveMedia } from "./bot/delivery.js";
|
||||||
import { buildTelegramGroupPeerId, resolveTelegramForumThreadId } from "./bot/helpers.js";
|
import {
|
||||||
|
buildTelegramGroupPeerId,
|
||||||
|
buildTelegramParentPeer,
|
||||||
|
resolveTelegramForumThreadId,
|
||||||
|
} from "./bot/helpers.js";
|
||||||
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||||
import {
|
import {
|
||||||
@@ -149,6 +153,11 @@ export const registerTelegramHandlers = ({
|
|||||||
const peerId = params.isGroup
|
const peerId = params.isGroup
|
||||||
? buildTelegramGroupPeerId(params.chatId, resolvedThreadId)
|
? buildTelegramGroupPeerId(params.chatId, resolvedThreadId)
|
||||||
: String(params.chatId);
|
: String(params.chatId);
|
||||||
|
const parentPeer = buildTelegramParentPeer({
|
||||||
|
isGroup: params.isGroup,
|
||||||
|
resolvedThreadId,
|
||||||
|
chatId: params.chatId,
|
||||||
|
});
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@@ -157,6 +166,7 @@ export const registerTelegramHandlers = ({
|
|||||||
kind: params.isGroup ? "group" : "dm",
|
kind: params.isGroup ? "group" : "dm",
|
||||||
id: peerId,
|
id: peerId,
|
||||||
},
|
},
|
||||||
|
parentPeer,
|
||||||
});
|
});
|
||||||
const baseSessionKey = route.sessionKey;
|
const baseSessionKey = route.sessionKey;
|
||||||
const dmThreadId = !params.isGroup ? params.messageThreadId : undefined;
|
const dmThreadId = !params.isGroup ? params.messageThreadId : undefined;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
buildSenderName,
|
buildSenderName,
|
||||||
buildTelegramGroupFrom,
|
buildTelegramGroupFrom,
|
||||||
buildTelegramGroupPeerId,
|
buildTelegramGroupPeerId,
|
||||||
|
buildTelegramParentPeer,
|
||||||
buildTypingThreadParams,
|
buildTypingThreadParams,
|
||||||
expandTextLinks,
|
expandTextLinks,
|
||||||
normalizeForwardedContext,
|
normalizeForwardedContext,
|
||||||
@@ -161,6 +162,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
const replyThreadId = threadSpec.id;
|
const replyThreadId = threadSpec.id;
|
||||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||||
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
||||||
|
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@@ -169,6 +171,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
kind: isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: peerId,
|
id: peerId,
|
||||||
},
|
},
|
||||||
|
parentPeer,
|
||||||
});
|
});
|
||||||
const baseSessionKey = route.sessionKey;
|
const baseSessionKey = route.sessionKey;
|
||||||
// DMs: use raw messageThreadId for thread sessions (not forum topic ids)
|
// DMs: use raw messageThreadId for thread sessions (not forum topic ids)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
buildSenderName,
|
buildSenderName,
|
||||||
buildTelegramGroupFrom,
|
buildTelegramGroupFrom,
|
||||||
buildTelegramGroupPeerId,
|
buildTelegramGroupPeerId,
|
||||||
|
buildTelegramParentPeer,
|
||||||
resolveTelegramForumThreadId,
|
resolveTelegramForumThreadId,
|
||||||
resolveTelegramThreadSpec,
|
resolveTelegramThreadSpec,
|
||||||
} from "./bot/helpers.js";
|
} from "./bot/helpers.js";
|
||||||
@@ -469,6 +470,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@@ -477,6 +479,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
kind: isGroup ? "group" : "dm",
|
kind: isGroup ? "group" : "dm",
|
||||||
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
||||||
},
|
},
|
||||||
|
parentPeer,
|
||||||
});
|
});
|
||||||
const baseSessionKey = route.sessionKey;
|
const baseSessionKey = route.sessionKey;
|
||||||
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
||||||
|
|||||||
@@ -331,6 +331,124 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
it("routes forum topic messages using parent group binding", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
// Binding specifies the base group ID without topic suffix.
|
||||||
|
// The fix passes parentPeer to resolveAgentRoute so the binding matches
|
||||||
|
// even when the actual peer id includes the topic suffix.
|
||||||
|
loadConfig.mockReturnValue({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
groupPolicy: "open",
|
||||||
|
groups: { "*": { requireMention: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "forum-agent" }],
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
agentId: "forum-agent",
|
||||||
|
match: {
|
||||||
|
channel: "telegram",
|
||||||
|
peer: { kind: "group", id: "-1001234567890" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
|
||||||
|
// Message comes from a forum topic (has message_thread_id and is_forum=true)
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
chat: {
|
||||||
|
id: -1001234567890,
|
||||||
|
type: "supergroup",
|
||||||
|
title: "Forum Group",
|
||||||
|
is_forum: true,
|
||||||
|
},
|
||||||
|
text: "hello from topic",
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 42,
|
||||||
|
message_thread_id: 99,
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
// Should route to forum-agent via parent peer binding inheritance
|
||||||
|
expect(payload.SessionKey).toContain("agent:forum-agent:");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers specific topic binding over parent group binding", async () => {
|
||||||
|
onSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
|
// Both a specific topic binding and a parent group binding are configured.
|
||||||
|
// The specific topic binding should take precedence.
|
||||||
|
loadConfig.mockReturnValue({
|
||||||
|
channels: {
|
||||||
|
telegram: {
|
||||||
|
groupPolicy: "open",
|
||||||
|
groups: { "*": { requireMention: false } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agents: {
|
||||||
|
list: [{ id: "topic-agent" }, { id: "group-agent" }],
|
||||||
|
},
|
||||||
|
bindings: [
|
||||||
|
{
|
||||||
|
agentId: "topic-agent",
|
||||||
|
match: {
|
||||||
|
channel: "telegram",
|
||||||
|
peer: { kind: "group", id: "-1001234567890:topic:99" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: "group-agent",
|
||||||
|
match: {
|
||||||
|
channel: "telegram",
|
||||||
|
peer: { kind: "group", id: "-1001234567890" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
createTelegramBot({ token: "tok" });
|
||||||
|
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
|
||||||
|
// Message from topic 99 - should match the specific topic binding
|
||||||
|
await handler({
|
||||||
|
message: {
|
||||||
|
chat: {
|
||||||
|
id: -1001234567890,
|
||||||
|
type: "supergroup",
|
||||||
|
title: "Forum Group",
|
||||||
|
is_forum: true,
|
||||||
|
},
|
||||||
|
text: "hello from topic 99",
|
||||||
|
date: 1736380800,
|
||||||
|
message_id: 42,
|
||||||
|
message_thread_id: 99,
|
||||||
|
},
|
||||||
|
me: { username: "openclaw_bot" },
|
||||||
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
|
const payload = replySpy.mock.calls[0][0];
|
||||||
|
// Should route to topic-agent (exact match) not group-agent (parent)
|
||||||
|
expect(payload.SessionKey).toContain("agent:topic-agent:");
|
||||||
|
});
|
||||||
|
|
||||||
it("sends GIF replies as animations", async () => {
|
it("sends GIF replies as animations", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "./bot-updates.js";
|
} from "./bot-updates.js";
|
||||||
import {
|
import {
|
||||||
buildTelegramGroupPeerId,
|
buildTelegramGroupPeerId,
|
||||||
|
buildTelegramParentPeer,
|
||||||
resolveTelegramForumThreadId,
|
resolveTelegramForumThreadId,
|
||||||
resolveTelegramStreamMode,
|
resolveTelegramStreamMode,
|
||||||
} from "./bot/helpers.js";
|
} from "./bot/helpers.js";
|
||||||
@@ -444,11 +445,13 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined })
|
? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined })
|
||||||
: undefined;
|
: undefined;
|
||||||
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
||||||
|
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
peer: { kind: isGroup ? "group" : "dm", id: peerId },
|
||||||
|
parentPeer,
|
||||||
});
|
});
|
||||||
const sessionKey = route.sessionKey;
|
const sessionKey = route.sessionKey;
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,24 @@ export function buildTelegramGroupFrom(chatId: number | string, messageThreadId?
|
|||||||
return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
|
return `telegram:group:${buildTelegramGroupPeerId(chatId, messageThreadId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build parentPeer for forum topic binding inheritance.
|
||||||
|
* When a message comes from a forum topic, the peer ID includes the topic suffix
|
||||||
|
* (e.g., `-1001234567890:topic:99`). To allow bindings configured for the base
|
||||||
|
* group ID to match, we provide the parent group as `parentPeer` so the routing
|
||||||
|
* layer can fall back to it when the exact peer doesn't match.
|
||||||
|
*/
|
||||||
|
export function buildTelegramParentPeer(params: {
|
||||||
|
isGroup: boolean;
|
||||||
|
resolvedThreadId?: number;
|
||||||
|
chatId: number | string;
|
||||||
|
}): { kind: "group"; id: string } | undefined {
|
||||||
|
if (!params.isGroup || params.resolvedThreadId == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { kind: "group", id: String(params.chatId) };
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSenderName(msg: Message) {
|
export function buildSenderName(msg: Message) {
|
||||||
const name =
|
const name =
|
||||||
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() ||
|
[msg.from?.first_name, msg.from?.last_name].filter(Boolean).join(" ").trim() ||
|
||||||
|
|||||||
Reference in New Issue
Block a user