mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 10:50:21 +00:00
fix: restore Telegram forum-topic routing (#56060) (thanks @one27001)
* feat(telegram): add child thread-binding placement via createForumTopic Enable ACP subagent spawn on Telegram by adding "child" placement support to the thread-bindings adapter. When a child binding is requested, the adapter creates a new forum topic via the Telegram Bot API and binds the subagent session to it using the canonical chatId:topic:topicId conversation ID format. When the ACP spawn context provides only a topic ID (not a full group chat ID), the adapter resolves the group from the configured Telegram groups in openclaw.json. This mirrors the Discord adapter's child placement behavior (thread creation + session binding) and unblocks the orchestrator pattern on Telegram forum-enabled groups. Closes #5737 Ref #23414 * fix(telegram): return null with warning instead of silent group fallback for bare topic IDs in child bind * telegram: fix ACP child thread spawn with group chat ID from agentGroupId * telegram: scope agentGroupId substitution to telegram channel only * Telegram: fix forum topic replies routing to root chat instead of topic thread * fix: clean up dead guard in child bind + add explicit threadId override test - Simplify bare-topic-ID guards in thread-bindings.ts: split into separate !chatId and !chatId.startsWith("-") checks, removing unreachable second condition - Add regression test confirming explicit turnSourceThreadId overrides session lastThreadId on same channel * fix: guard threadId fallback against shared-session race Codex review P1: when turnSourceTo differs from the session's stored to, the session threadId may belong to a different chat/topic. Only fall back to context.threadId when the destination also matches. * fix(telegram): enable ACP spawn from forum topics without thread binding extractExplicitGroupId returned topic-qualified IDs (-100...:topic:1264) instead of bare group chat IDs, breaking agentGroupId resolution. agentGroupId was also never wired in the inline actions path. For Telegram forum topics, skip thread binding entirely — the delivery plan already routes correctly via requester origin (to + threadId). Creating new forum topics per child session is unnecessary; output goes back to the same topic the user asked from. * fix(acp): bind Telegram forum sessions to current topic * fix: restore Telegram forum-topic routing (#56060) (thanks @one27001) --------- Co-authored-by: openclaw <mgabrie.dev@gmail.com> Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
@@ -1260,7 +1260,7 @@ describe("spawnAcpDirect", () => {
|
||||
expect(notifyOrder[0] > agentCallOrder).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps inline delivery for thread-bound ACP session mode", async () => {
|
||||
it("binds Telegram forum-topic ACP sessions to the current topic", async () => {
|
||||
replaceSpawnConfig({
|
||||
...hoisted.state.cfg,
|
||||
channels: {
|
||||
@@ -1296,11 +1296,22 @@ describe("spawnAcpDirect", () => {
|
||||
agentAccountId: "default",
|
||||
agentTo: "telegram:-1003342490704",
|
||||
agentThreadId: "2",
|
||||
agentGroupId: "-1003342490704",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(result.mode).toBe("session");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1003342490704:topic:2",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const agentCall = hoisted.callGatewayMock.mock.calls
|
||||
.map((call: unknown[]) => call[0] as { method?: string; params?: Record<string, unknown> })
|
||||
.find((request) => request.method === "agent");
|
||||
|
||||
@@ -87,6 +87,8 @@ export type SpawnAcpContext = {
|
||||
agentAccountId?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string | number;
|
||||
/** Group chat ID for channels that distinguish group vs. topic (e.g. Telegram). */
|
||||
agentGroupId?: string;
|
||||
sandboxed?: boolean;
|
||||
};
|
||||
|
||||
@@ -360,7 +362,39 @@ function resolveConversationIdForThreadBinding(params: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
groupId?: string;
|
||||
}): string | undefined {
|
||||
const channel = params.channel?.trim().toLowerCase();
|
||||
const normalizedThreadId =
|
||||
params.threadId != null ? String(params.threadId).trim() || undefined : undefined;
|
||||
if (channel === "telegram") {
|
||||
const rawChatId = (params.groupId ?? params.to ?? "").trim();
|
||||
let chatId = rawChatId;
|
||||
while (true) {
|
||||
const next = (() => {
|
||||
if (/^(telegram|tg):/i.test(chatId)) {
|
||||
return chatId.replace(/^(telegram|tg):/i, "").trim();
|
||||
}
|
||||
if (/^(group|channel):/i.test(chatId)) {
|
||||
return chatId.replace(/^(group|channel):/i, "").trim();
|
||||
}
|
||||
return chatId;
|
||||
})();
|
||||
if (next === chatId) {
|
||||
break;
|
||||
}
|
||||
chatId = next;
|
||||
}
|
||||
chatId = chatId
|
||||
.replace(/:topic:\d+$/i, "")
|
||||
.replace(/:\d+$/i, "")
|
||||
.trim();
|
||||
if (/^-?\d+$/.test(chatId)) {
|
||||
return normalizedThreadId ? `${chatId}:topic:${normalizedThreadId}` : chatId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const genericConversationId = resolveConversationIdFromTargets({
|
||||
threadId: params.threadId,
|
||||
targets: [params.to],
|
||||
@@ -368,8 +402,6 @@ function resolveConversationIdForThreadBinding(params: {
|
||||
if (genericConversationId) {
|
||||
return genericConversationId;
|
||||
}
|
||||
|
||||
const channel = params.channel?.trim().toLowerCase();
|
||||
const target = params.to?.trim() || "";
|
||||
if (channel === "line") {
|
||||
const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1];
|
||||
@@ -390,6 +422,7 @@ function prepareAcpThreadBinding(params: {
|
||||
accountId?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
groupId?: string;
|
||||
}): { ok: true; binding: PreparedAcpThreadBinding } | { ok: false; error: string } {
|
||||
const channel = params.channel?.trim().toLowerCase();
|
||||
if (!channel) {
|
||||
@@ -444,12 +477,13 @@ function prepareAcpThreadBinding(params: {
|
||||
error: `Thread bindings do not support ${placement} placement for ${policy.channel}.`,
|
||||
};
|
||||
}
|
||||
const conversationId = resolveConversationIdForThreadBinding({
|
||||
const conversationIdRaw = resolveConversationIdForThreadBinding({
|
||||
channel: policy.channel,
|
||||
to: params.to,
|
||||
threadId: params.threadId,
|
||||
groupId: params.groupId,
|
||||
});
|
||||
if (!conversationId) {
|
||||
if (!conversationIdRaw) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Could not resolve a ${policy.channel} conversation for ACP thread spawn.`,
|
||||
@@ -462,7 +496,7 @@ function prepareAcpThreadBinding(params: {
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
placement,
|
||||
conversationId,
|
||||
conversationId: conversationIdRaw,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -752,7 +786,7 @@ export async function spawnAcpDirect(
|
||||
};
|
||||
}
|
||||
|
||||
const requestThreadBinding = params.thread === true;
|
||||
let requestThreadBinding = params.thread === true;
|
||||
const runtimePolicyError = resolveAcpSpawnRuntimePolicyError({
|
||||
cfg,
|
||||
requesterSessionKey: ctx.agentSessionKey,
|
||||
@@ -819,6 +853,7 @@ export async function spawnAcpDirect(
|
||||
accountId: ctx.agentAccountId,
|
||||
to: ctx.agentTo,
|
||||
threadId: ctx.agentThreadId,
|
||||
groupId: ctx.agentGroupId,
|
||||
});
|
||||
if (!prepared.ok) {
|
||||
return {
|
||||
|
||||
@@ -218,6 +218,7 @@ export function createSessionsSpawnTool(
|
||||
agentAccountId: opts?.agentAccountId,
|
||||
agentTo: opts?.agentTo,
|
||||
agentThreadId: opts?.agentThreadId,
|
||||
agentGroupId: opts?.agentGroupId ?? undefined,
|
||||
sandboxed: opts?.sandboxed,
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user