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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 594 additions and 439 deletions

View File

@ -127,6 +127,7 @@ Docs: https://docs.openclaw.ai
- Telegram/audio: transcode Telegram voice-note `.ogg` attachments before the local `whisper-cli` auto fallback runs, and keep mention-preflight transcription enabled in auto mode when `tools.media.audio` is unset.
- Matrix/direct rooms: recover fresh auto-joined 1:1 DMs without eagerly persisting invite-only `m.direct` mappings, while keeping named, aliased, and explicitly configured rooms on the room path. (#58024) Thanks @gumadeiras.
- TTS: Restore 3.28 schema compatibility and fallback observability. (#57953) Thanks @joshavant.
- Telegram/forum topics: restore reply routing to the active topic and keep ACP `sessions_spawn(..., thread=true, mode="session")` bound to that same topic instead of falling back to root chat or losing follow-up routing. (#56060) Thanks @one27001.
## 2026.3.28

View File

@ -59,7 +59,7 @@ describe("telegram thread bindings", () => {
expect(manager.getByConversationId("-100200300:topic:77")?.boundBy).toBe("user-1");
});
it("does not support child placement", async () => {
it("rejects child placement when conversationId is a bare topic ID with no group context", async () => {
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
@ -73,12 +73,36 @@ describe("telegram thread bindings", () => {
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "-100200300:topic:77",
conversationId: "77",
},
placement: "child",
}),
).rejects.toMatchObject({
code: "BINDING_CAPABILITY_UNSUPPORTED",
code: "BINDING_CREATE_FAILED",
});
});
it("rejects child placement when parentConversationId is also a bare topic ID", async () => {
createTelegramThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
});
await expect(
getSessionBindingService().bind({
targetSessionKey: "agent:main:acp:child-acp-1",
targetKind: "session",
conversation: {
channel: "telegram",
accountId: "default",
conversationId: "77",
parentConversationId: "99",
},
placement: "child",
}),
).rejects.toMatchObject({
code: "BINDING_CREATE_FAILED",
});
});

View File

@ -1,6 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
formatThreadBindingDurationLabel,
registerSessionBindingAdapter,
@ -15,6 +16,8 @@ import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { createForumTopicTelegram } from "./send.js";
import { resolveTelegramToken } from "./token.js";
const DEFAULT_THREAD_BINDING_IDLE_TIMEOUT_MS = 24 * 60 * 60 * 1000;
const DEFAULT_THREAD_BINDING_MAX_AGE_MS = 0;
@ -556,18 +559,63 @@ export function createTelegramThreadBindingManager(
channel: "telegram",
accountId,
capabilities: {
placements: ["current"],
placements: ["current", "child"],
},
bind: async (input) => {
if (input.conversation.channel !== "telegram") {
return null;
}
if (input.placement === "child") {
const targetSessionKey = input.targetSessionKey.trim();
if (!targetSessionKey) {
return null;
}
const conversationId = normalizeConversationId(input.conversation.conversationId);
const targetSessionKey = input.targetSessionKey.trim();
if (!conversationId || !targetSessionKey) {
const placement = input.placement === "child" ? "child" : "current";
const metadata = input.metadata ?? {};
let conversationId: string | undefined;
if (placement === "child") {
const rawConversationId = input.conversation.conversationId?.trim() ?? "";
const rawParent = input.conversation.parentConversationId?.trim() ?? "";
const cfg = loadConfig();
let chatId = rawParent || rawConversationId;
if (!chatId) {
logVerbose(
`telegram: child bind failed: could not resolve group chat ID from conversationId=${rawConversationId}`,
);
return null;
}
if (!chatId.startsWith("-")) {
logVerbose(
`telegram: child bind failed: conversationId "${chatId}" looks like a bare topic ID, not a group chat ID (expected to start with "-"). Provide a full chatId:topic:topicId conversationId or set parentConversationId to the group chat ID.`,
);
return null;
}
const threadName =
(typeof metadata.threadName === "string" ? metadata.threadName.trim() : "") ||
(typeof metadata.label === "string" ? metadata.label.trim() : "") ||
`Agent: ${targetSessionKey.split(":").pop()}`;
try {
const tokenResolution = resolveTelegramToken(cfg, { accountId });
if (!tokenResolution.token) {
return null;
}
const result = await createForumTopicTelegram(chatId, threadName, {
cfg,
token: tokenResolution.token,
accountId,
});
conversationId = `${result.chatId}:topic:${result.topicId}`;
} catch (err) {
logVerbose(
`telegram: child thread-binding failed for ${chatId}: ${err instanceof Error ? err.message : String(err)}`,
);
return null;
}
} else {
conversationId = normalizeConversationId(input.conversation.conversationId);
}
if (!conversationId) {
return null;
}
const record = fromSessionBindingInput({

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,
},
);

View File

@ -24,6 +24,7 @@ import { getAbortMemory, isAbortRequestText } from "./abort-primitives.js";
import type { buildStatusReply, handleCommands } from "./commands.runtime.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import { isDirectiveOnly } from "./directive-handling.parse.js";
import { extractExplicitGroupId } from "./group-id.js";
import type { createModelSelectionState } from "./model-selection.js";
import { extractInlineSimpleCommand } from "./reply-inline.js";
import type { TypingController } from "./typing.js";
@ -226,6 +227,7 @@ export async function handleInlineActions(params: {
agentAccountId: (ctx as { AccountId?: string }).AccountId,
agentTo: ctx.OriginatingTo ?? ctx.To,
agentThreadId: ctx.MessageThreadId ?? undefined,
agentGroupId: extractExplicitGroupId(ctx.From),
requesterAgentIdOverride: agentId,
agentDir,
workspaceDir,

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { extractExplicitGroupId } from "./group-id.js";
describe("extractExplicitGroupId", () => {
it("returns undefined for empty/null input", () => {
expect(extractExplicitGroupId(undefined)).toBeUndefined();
expect(extractExplicitGroupId(null)).toBeUndefined();
expect(extractExplicitGroupId("")).toBeUndefined();
expect(extractExplicitGroupId(" ")).toBeUndefined();
});
it("extracts group ID from telegram group format", () => {
expect(extractExplicitGroupId("telegram:group:-1003776849159")).toBe("-1003776849159");
});
it("extracts group ID from telegram forum topic format, stripping topic suffix", () => {
expect(extractExplicitGroupId("telegram:group:-1003776849159:topic:1264")).toBe(
"-1003776849159",
);
});
it("extracts group ID from channel format", () => {
expect(extractExplicitGroupId("telegram:channel:-1001234567890")).toBe("-1001234567890");
});
it("extracts group ID from channel format with topic", () => {
expect(extractExplicitGroupId("telegram:channel:-1001234567890:topic:42")).toBe(
"-1001234567890",
);
});
it("extracts group ID from bare group: prefix", () => {
expect(extractExplicitGroupId("group:-1003776849159")).toBe("-1003776849159");
});
it("extracts group ID from bare group: prefix with topic", () => {
expect(extractExplicitGroupId("group:-1003776849159:topic:999")).toBe("-1003776849159");
});
it("extracts WhatsApp group ID", () => {
expect(extractExplicitGroupId("whatsapp:120363123456789@g.us")).toBe("120363123456789@g.us");
});
it("returns undefined for unrecognized formats", () => {
expect(extractExplicitGroupId("user:12345")).toBeUndefined();
expect(extractExplicitGroupId("just-a-string")).toBeUndefined();
});
});

View File

@ -5,7 +5,8 @@ export function extractExplicitGroupId(raw: string | undefined | null): string |
}
const parts = trimmed.split(":").filter(Boolean);
if (parts.length >= 3 && (parts[1] === "group" || parts[1] === "channel")) {
return parts.slice(2).join(":") || undefined;
const joined = parts.slice(2).join(":");
return joined.replace(/:topic:.*$/, "") || undefined;
}
if (
parts.length >= 2 &&
@ -15,7 +16,8 @@ export function extractExplicitGroupId(raw: string | undefined | null): string |
return parts.slice(1).join(":") || undefined;
}
if (parts.length >= 2 && (parts[0] === "group" || parts[0] === "channel")) {
return parts.slice(1).join(":") || undefined;
const joined = parts.slice(1).join(":");
return joined.replace(/:topic:.*$/, "") || undefined;
}
return undefined;
}

View File

@ -154,9 +154,6 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
});
describe("resolveSessionDeliveryTarget", () => {
type SessionDeliveryRequest = Parameters<typeof resolveSessionDeliveryTarget>[0];
type HeartbeatDeliveryRequest = Parameters<typeof resolveHeartbeatDeliveryTarget>[0];
const expectImplicitRoute = (
resolved: SessionDeliveryTarget,
params: {
@ -183,31 +180,13 @@ describe("resolveSessionDeliveryTarget", () => {
const expectTopicParsedFromExplicitTo = (
entry: Parameters<typeof resolveSessionDeliveryTarget>[0]["entry"],
) => {
expectResolvedSessionTarget({
request: {
entry,
requestedChannel: "last",
explicitTo: "63448508:topic:1008013",
},
expected: {
to: "63448508",
threadId: 1008013,
},
const resolved = resolveSessionDeliveryTarget({
entry,
requestedChannel: "last",
explicitTo: "63448508:topic:1008013",
});
};
const expectResolvedSessionTarget = (params: {
request: SessionDeliveryRequest;
expected: Partial<SessionDeliveryTarget>;
}) => {
expect(resolveSessionDeliveryTarget(params.request)).toMatchObject(params.expected);
};
const expectResolvedHeartbeatRoute = (params: {
request: HeartbeatDeliveryRequest;
expected: Partial<ReturnType<typeof resolveHeartbeatDeliveryTarget>>;
}) => {
expect(resolveHeartbeatDeliveryTarget(params.request)).toMatchObject(params.expected);
expect(resolved.to).toBe("63448508");
expect(resolved.threadId).toBe(1008013);
};
it("derives implicit delivery from the last route", () => {
@ -236,34 +215,6 @@ describe("resolveSessionDeliveryTarget", () => {
});
});
it("uses origin provider and accountId when legacy last route fields are absent", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-origin-route",
updatedAt: 1,
lastTo: " +1555 ",
origin: {
provider: " whatsapp ",
accountId: " acct-origin ",
},
},
requestedChannel: "last",
});
expect(resolved).toEqual({
channel: "whatsapp",
to: "+1555",
accountId: "acct-origin",
threadId: undefined,
threadIdExplicit: false,
mode: "implicit",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "acct-origin",
lastThreadId: undefined,
});
});
it("prefers explicit targets without reusing lastTo", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
@ -303,65 +254,53 @@ describe("resolveSessionDeliveryTarget", () => {
});
});
it.each([
{
name: "passes through explicitThreadId when provided",
request: {
entry: {
sessionId: "sess-thread",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
lastThreadId: 999,
},
requestedChannel: "last",
explicitThreadId: 42,
it("passes through explicitThreadId when provided", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-thread",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
lastThreadId: 999,
},
expected: {
channel: "telegram",
to: "-100123",
threadId: 42,
requestedChannel: "last",
explicitThreadId: 42,
});
expect(resolved.threadId).toBe(42);
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-100123");
});
it("uses session lastThreadId when no explicitThreadId", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-thread-2",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
lastThreadId: 999,
},
},
{
name: "uses session lastThreadId when no explicitThreadId",
request: {
entry: {
sessionId: "sess-thread-2",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
lastThreadId: 999,
},
requestedChannel: "last",
requestedChannel: "last",
});
expect(resolved.threadId).toBe(999);
});
it("does not inherit lastThreadId in heartbeat mode", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-heartbeat-thread",
updatedAt: 1,
lastChannel: "slack",
lastTo: "user:U123",
lastThreadId: "1739142736.000100",
},
expected: {
threadId: 999,
},
},
{
name: "does not inherit lastThreadId in heartbeat mode",
request: {
entry: {
sessionId: "sess-heartbeat-thread",
updatedAt: 1,
lastChannel: "slack",
lastTo: "user:U123",
lastThreadId: "1739142736.000100",
},
requestedChannel: "last",
mode: "heartbeat",
},
expected: {
threadId: undefined,
},
},
] satisfies Array<{
name: string;
request: SessionDeliveryRequest;
expected: Partial<SessionDeliveryTarget>;
}>)("$name", ({ request, expected }) => {
expectResolvedSessionTarget({ request, expected });
requestedChannel: "last",
mode: "heartbeat",
});
expect(resolved.threadId).toBeUndefined();
});
it("falls back to a provided channel when requested is unsupported", () => {
@ -401,44 +340,36 @@ describe("resolveSessionDeliveryTarget", () => {
});
});
it.each([
{
name: "skips :topic: parsing for non-telegram channels",
request: {
entry: {
sessionId: "sess-slack",
updatedAt: 1,
lastChannel: "slack",
lastTo: "C12345",
},
requestedChannel: "last",
explicitTo: "C12345:topic:999",
},
},
{
name: "skips :topic: parsing when channel is explicitly non-telegram even if lastChannel was telegram",
request: {
entry: {
sessionId: "sess-cross",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "63448508",
},
requestedChannel: "slack",
explicitTo: "C12345:topic:999",
},
},
] satisfies Array<{
name: string;
request: SessionDeliveryRequest;
}>)("$name", ({ request }) => {
expectResolvedSessionTarget({
request,
expected: {
to: "C12345:topic:999",
threadId: undefined,
it("skips :topic: parsing for non-telegram channels", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-slack",
updatedAt: 1,
lastChannel: "slack",
lastTo: "C12345",
},
requestedChannel: "last",
explicitTo: "C12345:topic:999",
});
expect(resolved.to).toBe("C12345:topic:999");
expect(resolved.threadId).toBeUndefined();
});
it("skips :topic: parsing when channel is explicitly non-telegram even if lastChannel was telegram", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-cross",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "63448508",
},
requestedChannel: "slack",
explicitTo: "C12345:topic:999",
});
expect(resolved.to).toBe("C12345:topic:999");
expect(resolved.threadId).toBeUndefined();
});
it("keeps raw :topic: targets when the telegram plugin registry is unavailable", () => {
@ -460,23 +391,20 @@ describe("resolveSessionDeliveryTarget", () => {
});
it("explicitThreadId takes priority over :topic: parsed value", () => {
expectResolvedSessionTarget({
request: {
entry: {
sessionId: "sess-priority",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "63448508",
},
requestedChannel: "last",
explicitTo: "63448508:topic:1008013",
explicitThreadId: 42,
},
expected: {
threadId: 42,
to: "63448508",
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-priority",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "63448508",
},
requestedChannel: "last",
explicitTo: "63448508:topic:1008013",
explicitThreadId: 42,
});
expect(resolved.threadId).toBe(42);
expect(resolved.to).toBe("63448508");
});
const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") =>
@ -630,290 +558,335 @@ describe("resolveSessionDeliveryTarget", () => {
});
});
it.each([
{
name: "allows heartbeat delivery to Discord DMs by default",
request: {
cfg: {} as OpenClawConfig,
entry: {
sessionId: "sess-heartbeat-discord-dm",
updatedAt: 1,
lastChannel: "discord",
lastTo: "user:12345",
},
heartbeat: {
target: "last",
},
it("allows heartbeat delivery to Discord DMs by default", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-discord-dm",
updatedAt: 1,
lastChannel: "discord",
lastTo: "user:12345",
},
expected: {
channel: "discord",
to: "user:12345",
heartbeat: {
target: "last",
},
},
{
name: "keeps heartbeat delivery to Discord channels",
request: {
cfg: {} as OpenClawConfig,
entry: {
sessionId: "sess-heartbeat-discord-channel",
updatedAt: 1,
lastChannel: "discord",
lastTo: "channel:999",
},
heartbeat: {
target: "last",
},
});
expect(resolved.channel).toBe("discord");
expect(resolved.to).toBe("user:12345");
});
it("keeps heartbeat delivery to Discord channels", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
entry: {
sessionId: "sess-heartbeat-discord-channel",
updatedAt: 1,
lastChannel: "discord",
lastTo: "channel:999",
},
expected: {
channel: "discord",
to: "channel:999",
heartbeat: {
target: "last",
},
},
{
name: "parses explicit heartbeat topic targets into threadId",
request: {
cfg: {} as OpenClawConfig,
heartbeat: {
target: "telegram",
to: "-10063448508:topic:1008013",
},
},
expected: {
channel: "telegram",
to: "-10063448508",
threadId: 1008013,
},
},
{
name: "prefers turn-scoped routing over mutable session routing for target=last",
request: {
cfg: {},
entry: {
sessionId: "sess-heartbeat-turn-source",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
heartbeat: {
target: "last",
},
turnSource: {
channel: "telegram",
to: "-100123",
threadId: 42,
},
},
expected: {
channel: "telegram",
to: "-100123",
threadId: 42,
},
},
{
name: "merges partial turn-scoped metadata with the stored session route for target=last",
request: {
cfg: {},
entry: {
sessionId: "sess-heartbeat-turn-source-partial",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
},
heartbeat: {
target: "last",
},
turnSource: {
threadId: 42,
},
},
expected: {
channel: "telegram",
to: "-100123",
threadId: 42,
},
},
] satisfies Array<{
name: string;
request: HeartbeatDeliveryRequest;
expected: Partial<ReturnType<typeof resolveHeartbeatDeliveryTarget>>;
}>)("$name", ({ request, expected }) => {
expectResolvedHeartbeatRoute({ request, expected });
});
expect(resolved.channel).toBe("discord");
expect(resolved.to).toBe("channel:999");
});
it("keeps explicit threadId in heartbeat mode", () => {
expectResolvedSessionTarget({
request: {
entry: {
sessionId: "sess-heartbeat-explicit-thread",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
lastThreadId: 999,
},
requestedChannel: "last",
mode: "heartbeat",
explicitThreadId: 42,
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-heartbeat-explicit-thread",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
lastThreadId: 999,
},
expected: {
requestedChannel: "last",
mode: "heartbeat",
explicitThreadId: 42,
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-100123");
expect(resolved.threadId).toBe(42);
expect(resolved.threadIdExplicit).toBe(true);
});
it("parses explicit heartbeat topic targets into threadId", () => {
const cfg: OpenClawConfig = {};
const resolved = resolveHeartbeatDeliveryTarget({
cfg,
heartbeat: {
target: "telegram",
to: "-10063448508:topic:1008013",
},
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-10063448508");
expect(resolved.threadId).toBe(1008013);
});
it("prefers turn-scoped routing over mutable session routing for target=last", () => {
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-heartbeat-turn-source",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
heartbeat: {
target: "last",
},
turnSource: {
channel: "telegram",
to: "-100123",
threadId: 42,
threadIdExplicit: true,
},
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-100123");
expect(resolved.threadId).toBe(42);
});
it("merges partial turn-scoped metadata with the stored session route for target=last", () => {
const resolved = resolveHeartbeatDeliveryTarget({
cfg: {},
entry: {
sessionId: "sess-heartbeat-turn-source-partial",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-100123",
},
heartbeat: {
target: "last",
},
turnSource: {
threadId: 42,
},
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-100123");
expect(resolved.threadId).toBe(42);
});
});
describe("resolveSessionDeliveryTarget — cross-channel reply guard (#24152)", () => {
const expectCrossChannelReplyGuard = (params: {
request: Parameters<typeof resolveSessionDeliveryTarget>[0];
expected: Partial<SessionDeliveryTarget>;
}) => {
expect(resolveSessionDeliveryTarget(params.request)).toMatchObject(params.expected);
};
it("uses turnSourceChannel over session lastChannel when provided", () => {
// Simulate: WhatsApp message originated the turn, but a Slack message
// arrived concurrently and updated lastChannel to "slack"
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-shared",
updatedAt: 1,
lastChannel: "slack", // <- concurrently overwritten
lastTo: "U0AEMECNCBV", // <- Slack user (wrong target)
},
requestedChannel: "last",
turnSourceChannel: "whatsapp", // <- originated from WhatsApp
turnSourceTo: "+66972796305", // <- WhatsApp user (correct target)
});
it.each([
{
name: "uses turnSourceChannel over session lastChannel when provided",
request: {
entry: {
sessionId: "sess-shared",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U0AEMECNCBV",
},
requestedChannel: "last",
turnSourceChannel: "whatsapp",
turnSourceTo: "+66972796305",
expect(resolved.channel).toBe("whatsapp");
expect(resolved.to).toBe("+66972796305");
});
it("falls back to session lastChannel when turnSourceChannel is not set", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-normal",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "8587265585",
},
expected: {
channel: "whatsapp",
to: "+66972796305",
requestedChannel: "last",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("8587265585");
});
it("respects explicit requestedChannel over turnSourceChannel", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-explicit",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U12345",
},
},
{
name: "falls back to session lastChannel when turnSourceChannel is not set",
request: {
entry: {
sessionId: "sess-normal",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "8587265585",
},
requestedChannel: "last",
requestedChannel: "telegram",
explicitTo: "8587265585",
turnSourceChannel: "whatsapp",
turnSourceTo: "+66972796305",
});
// Explicit requestedChannel "telegram" is not "last", so it takes priority
expect(resolved.channel).toBe("telegram");
});
it("preserves turnSourceAccountId and turnSourceThreadId", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-meta",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
lastAccountId: "wrong-account",
},
expected: {
channel: "telegram",
to: "8587265585",
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "8587265585",
turnSourceAccountId: "bot-123",
turnSourceThreadId: 42,
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("8587265585");
expect(resolved.accountId).toBe("bot-123");
expect(resolved.threadId).toBe(42);
});
it("does not fall back to session target metadata when turnSourceChannel is set", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-no-fallback",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
lastAccountId: "wrong-account",
lastThreadId: "1739142736.000100",
},
},
{
name: "respects explicit requestedChannel over turnSourceChannel",
request: {
entry: {
sessionId: "sess-explicit",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U12345",
},
requestedChannel: "telegram",
explicitTo: "8587265585",
turnSourceChannel: "whatsapp",
turnSourceTo: "+66972796305",
requestedChannel: "last",
turnSourceChannel: "whatsapp",
});
expect(resolved.channel).toBe("whatsapp");
expect(resolved.to).toBeUndefined();
expect(resolved.accountId).toBeUndefined();
expect(resolved.threadId).toBeUndefined();
expect(resolved.lastTo).toBeUndefined();
expect(resolved.lastAccountId).toBeUndefined();
expect(resolved.lastThreadId).toBeUndefined();
});
it("falls back to session lastThreadId when turnSourceChannel matches session channel and no explicit turnSourceThreadId", () => {
// Regression: Telegram forum topic replies were landing in the root chat instead of the topic
// thread because turnSourceThreadId was undefined (not explicitly passed), causing lastThreadId
// to be undefined even though the session had the correct lastThreadId from the inbound message.
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-forum-topic",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-1001234567890",
lastThreadId: 1122,
},
expected: {
channel: "telegram",
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "-1001234567890",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-1001234567890");
expect(resolved.threadId).toBe(1122);
});
it("does not fall back to session lastThreadId when turnSourceChannel differs from session channel", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-cross-channel-no-thread",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_SLACK",
lastThreadId: "1739142736.000100",
},
},
{
name: "preserves turnSourceAccountId and turnSourceThreadId",
request: {
entry: {
sessionId: "sess-meta",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
lastAccountId: "wrong-account",
},
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "8587265585",
turnSourceAccountId: "bot-123",
turnSourceThreadId: 42,
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "-1001234567890",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.threadId).toBeUndefined();
});
it("prefers explicit turnSourceThreadId over session lastThreadId on same channel", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-explicit-thread-override",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-1001234567890",
lastThreadId: 1122,
},
expected: {
channel: "telegram",
to: "8587265585",
accountId: "bot-123",
threadId: 42,
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "-1001234567890",
turnSourceThreadId: 9999,
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-1001234567890");
expect(resolved.threadId).toBe(9999);
});
it("drops session threadId when turnSourceTo differs from session to (shared-session race)", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-shared-race",
updatedAt: 1,
lastChannel: "telegram",
lastTo: "-1001234567890",
lastThreadId: 1122,
},
},
{
name: "does not fall back to session target metadata when turnSourceChannel is set",
request: {
entry: {
sessionId: "sess-no-fallback",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
lastAccountId: "wrong-account",
lastThreadId: "1739142736.000100",
},
requestedChannel: "last",
turnSourceChannel: "whatsapp",
requestedChannel: "last",
turnSourceChannel: "telegram",
turnSourceTo: "-1009999999999",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("-1009999999999");
expect(resolved.threadId).toBeUndefined();
});
it("uses explicitTo even when turnSourceTo is omitted", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-explicit-to",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
expected: {
channel: "whatsapp",
to: undefined,
accountId: undefined,
threadId: undefined,
lastTo: undefined,
lastAccountId: undefined,
lastThreadId: undefined,
requestedChannel: "last",
explicitTo: "+15551234567",
turnSourceChannel: "whatsapp",
});
expect(resolved.channel).toBe("whatsapp");
expect(resolved.to).toBe("+15551234567");
});
it("still allows mismatched lastTo only from turn-scoped metadata", () => {
const resolved = resolveSessionDeliveryTarget({
entry: {
sessionId: "sess-mismatch-turn",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
},
{
name: "uses explicitTo even when turnSourceTo is omitted",
request: {
entry: {
sessionId: "sess-explicit-to",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
requestedChannel: "last",
explicitTo: "+15551234567",
turnSourceChannel: "whatsapp",
},
expected: {
channel: "whatsapp",
to: "+15551234567",
},
},
{
name: "still allows mismatched lastTo only from turn-scoped metadata",
request: {
entry: {
sessionId: "sess-mismatch-turn",
updatedAt: 1,
lastChannel: "slack",
lastTo: "U_WRONG",
},
requestedChannel: "telegram",
allowMismatchedLastTo: true,
turnSourceChannel: "whatsapp",
turnSourceTo: "+15550000000",
},
expected: {
channel: "telegram",
to: "+15550000000",
},
},
] satisfies Array<{
name: string;
request: Parameters<typeof resolveSessionDeliveryTarget>[0];
expected: Partial<SessionDeliveryTarget>;
}>)("$name", ({ request, expected }) => {
expectCrossChannelReplyGuard({ request, expected });
requestedChannel: "telegram",
allowMismatchedLastTo: true,
turnSourceChannel: "whatsapp",
turnSourceTo: "+15550000000",
});
expect(resolved.channel).toBe("telegram");
expect(resolved.to).toBe("+15550000000");
});
});

View File

@ -117,10 +117,22 @@ export function resolveSessionDeliveryTarget(params: {
// When a turn-source channel is provided, use only turn-scoped metadata.
// Falling back to mutable session fields would re-introduce routing races.
const hasTurnSourceChannel = params.turnSourceChannel != null;
const hasTurnSourceThreadId =
params.turnSourceThreadId != null && params.turnSourceThreadId !== "";
const lastChannel = hasTurnSourceChannel ? params.turnSourceChannel : sessionLastChannel;
const lastTo = hasTurnSourceChannel ? params.turnSourceTo : context?.to;
const lastAccountId = hasTurnSourceChannel ? params.turnSourceAccountId : context?.accountId;
const lastThreadId = hasTurnSourceChannel ? params.turnSourceThreadId : context?.threadId;
// Fall back to the session's stored threadId only when the turn-source channel AND destination
// match the session context. This avoids mixing a turn-scoped `to` with a stale session-scoped
// threadId from a different chat/topic in shared-session scenarios.
const turnToMatchesSession =
!params.turnSourceTo || !context?.to || params.turnSourceTo === context.to;
const lastThreadId = hasTurnSourceThreadId
? params.turnSourceThreadId
: hasTurnSourceChannel &&
(params.turnSourceChannel !== sessionLastChannel || !turnToMatchesSession)
? undefined
: context?.threadId;
const rawRequested = params.requestedChannel ?? "last";
const requested = rawRequested === "last" ? "last" : normalizeMessageChannel(rawRequested);
@ -166,8 +178,6 @@ export function resolveSessionDeliveryTarget(params: {
const mode = params.mode ?? (explicitTo ? "explicit" : "implicit");
const accountId = channel && channel === lastChannel ? lastAccountId : undefined;
const hasTurnSourceThreadId =
params.turnSourceThreadId != null && params.turnSourceThreadId !== "";
const threadId =
channel && channel === lastChannel
? mode === "heartbeat"