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:
Gabriel M.
2026-03-31 06:48:09 +02:00
committed by GitHub
parent 54c69414ad
commit f7ced438f7
11 changed files with 594 additions and 439 deletions

View File

@@ -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");

View File

@@ -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 {

View File

@@ -218,6 +218,7 @@ export function createSessionsSpawnTool(
agentAccountId: opts?.agentAccountId,
agentTo: opts?.agentTo,
agentThreadId: opts?.agentThreadId,
agentGroupId: opts?.agentGroupId ?? undefined,
sandboxed: opts?.sandboxed,
},
);