fix(telegram): keep dm reply threads on main session

This commit is contained in:
Peter Steinberger
2026-05-02 09:53:49 +01:00
parent 2b92de604c
commit b8c0a1e9ff
14 changed files with 141 additions and 29 deletions

View File

@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
- Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427.
- Plugins: clarify config-selected duplicate plugin override diagnostics and document manifest schema updates for bundled-plugin forks. Fixes #8582. Thanks @sachah.
- CLI backends/Claude: make live-session JSONL turn caps bounded and configurable via `reliability.outputLimits`, raising the default guard for tool-heavy Claude CLI turns while preserving memory limits. Fixes #75838. Thanks @hcordoba840.
- Telegram/DMs: keep incidental `message_thread_id` reply-with-quote metadata on the flat DM session by default while preserving opt-in DM topic isolation for configured topics. Fixes #75975. Thanks @ProjectEvolutionEVE.
- Providers/OpenAI: resolve `keychain:<service>:<account>` `OPENAI_API_KEY` refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt.
- Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai.
- Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry.

View File

@@ -1,4 +1,4 @@
366770fd037ace1092595b351fbd83473ee1ecce188bceb0ab4510a5579a9073 config-baseline.json
2d132b4c2e3b0e0f2524fc1cc889d3be658ad0e40c970b2d367bf27348883658 config-baseline.core.json
f42329d45c095881bd226bdb192c235980658fd250606d0c0badc2b12f12f5d3 config-baseline.channel.json
ba41e5775c361dba63fec0441f943106d8cd0cb0f10c10fee36becc1555d5059 config-baseline.json
7b1716d578d22e5b4388f56140b50d326f61327b760f8c580bdd9b971335fb85 config-baseline.core.json
74632b512b6470a155652c7d15b9e430738a05df3b5a85dca16cc4d84dcea764 config-baseline.channel.json
fffe0e74eab92a88c3c57952a70bc932438ce3a7f5f9982688437f2cdaee0bcb config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
84befa4ad71bee22d9ea91a6ff689532deb3783143af7488a98a7341d5ce5f25 plugin-sdk-api-baseline.json
046bb0c9bc40bfb2f8a323bf658c45eeeb486571301757abc5472018db7d2189 plugin-sdk-api-baseline.jsonl
1b91ea9cadcedacd0c7e7cf9ca2e48739bd8f99a107cb59ba8b0798d0729b374 plugin-sdk-api-baseline.json
f323d1b6e71b9e65555c13e22dcdad0cd9c9db24243dad4c7da27855d2b69888 plugin-sdk-api-baseline.jsonl

View File

@@ -260,7 +260,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
- Routing is deterministic: Telegram inbound replies back to Telegram (the model does not pick channels).
- Inbound messages normalize into the shared channel envelope with reply metadata and media placeholders.
- Group sessions are isolated by group ID. Forum topics append `:topic:<threadId>` to keep topics isolated.
- DM messages can carry `message_thread_id`; OpenClaw routes them with thread-aware session keys and preserves thread ID for replies.
- DM messages can carry `message_thread_id`; OpenClaw preserves the thread ID for replies but keeps DMs on the flat session by default. Configure `channels.telegram.direct.<chatId>.threadReplies: "inbound"` or `requireTopic: true` when you intentionally want DM topic session isolation.
- Long polling uses grammY runner with per-chat/per-thread sequencing. Overall runner sink concurrency uses `agents.defaults.maxConcurrent`.
- Long polling is guarded inside each gateway process so only one active poller can use a bot token at a time. If you still see `getUpdates` 409 conflicts, another OpenClaw gateway, script, or external poller is likely using the same token.
- Long-polling watchdog restarts trigger after 120 seconds without completed `getUpdates` liveness by default. Increase `channels.telegram.pollingStallThresholdMs` only if your deployment still sees false polling-stall restarts during long-running work. The value is in milliseconds and is allowed from `30000` to `600000`; per-account overrides are supported.
@@ -542,7 +542,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
**Thread-bound ACP spawn from chat**: `/acp spawn <agent> --thread here|auto` binds the current topic to a new ACP session; follow-ups route there directly. OpenClaw pins the spawn confirmation in-topic. Requires `channels.telegram.threadBindings.spawnSessions` to remain enabled (default: `true`).
Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing but use thread-aware session keys.
Template context exposes `MessageThreadId` and `IsForum`. DM chats with `message_thread_id` keep DM routing and reply metadata; they only use thread-aware session keys when the DM is configured with `threadReplies: "inbound"`, `threadReplies: "always"`, `requireTopic: true`, or a matching topic config.
</Accordion>

View File

@@ -14,7 +14,11 @@ import {
import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status";
import { replaceConfigFile } from "openclaw/plugin-sdk/config-mutation";
import type { DmPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { TelegramGroupConfig, TelegramTopicConfig } from "openclaw/plugin-sdk/config-types";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-types";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
@@ -72,6 +76,7 @@ import {
resolveTelegramForumFlag,
resolveTelegramForumThreadId,
resolveTelegramGroupAllowFromContext,
shouldUseTelegramDmThreadSession,
withResolvedTelegramForumFlag,
} from "./bot/helpers.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
@@ -320,7 +325,10 @@ export const registerTelegramHandlers = ({
});
const dmThreadId = !params.isGroup ? params.messageThreadId : undefined;
const topicThreadId = resolvedThreadId ?? dmThreadId;
const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId);
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId);
const directConfig = !params.isGroup
? (groupConfig as TelegramDirectConfig | undefined)
: undefined;
const { route } = resolveTelegramConversationRoute({
cfg: runtimeCfg,
accountId,
@@ -338,10 +346,9 @@ export const registerTelegramHandlers = ({
isGroup: params.isGroup,
senderId: params.senderId,
});
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` })
: null;
const threadKeys = shouldUseTelegramDmThreadSession({ dmThreadId, directConfig, topicConfig })
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, {
agentId: route.agentId,

View File

@@ -59,12 +59,19 @@ afterEach(() => {
});
describe("buildTelegramMessageContext dm thread sessions", () => {
const buildContext = async (message: Record<string, unknown>) =>
const buildContext = async (
message: Record<string, unknown>,
params?: Pick<
Parameters<typeof buildTelegramMessageContextForTest>[0],
"resolveTelegramGroupConfig"
>,
) =>
await buildTelegramMessageContextForTest({
message,
...params,
});
it("uses thread session key for dm topics", async () => {
it("keeps incidental dm message_thread_id on the main session by default", async () => {
const ctx = await buildContext({
message_id: 1,
chat: { id: 1234, type: "private" },
@@ -74,6 +81,29 @@ describe("buildTelegramMessageContext dm thread sessions", () => {
from: { id: 42, first_name: "Alice" },
});
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageThreadId).toBe(42);
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main");
});
it("uses thread session key for configured dm topics", async () => {
const ctx = await buildContext(
{
message_id: 3,
chat: { id: 1234, type: "private" },
date: 1700000002,
text: "hello",
message_thread_id: 42,
from: { id: 42, first_name: "Alice" },
},
{
resolveTelegramGroupConfig: () => ({
groupConfig: { requireTopic: true },
topicConfig: undefined,
}),
},
);
expect(ctx).not.toBeNull();
expect(ctx?.ctxPayload?.MessageThreadId).toBe(42);
expect(ctx?.ctxPayload?.SessionKey).toBe("agent:main:main:thread:1234:42");

View File

@@ -22,6 +22,7 @@ import {
extractTelegramForumFlag,
resolveTelegramForumFlag,
resolveTelegramThreadSpec,
shouldUseTelegramDmThreadSession,
} from "./bot/helpers.js";
import type { TelegramGetChat } from "./bot/types.js";
import {
@@ -381,11 +382,14 @@ export const buildTelegramMessageContext = async ({
isGroup,
senderId,
});
// DMs: use thread suffix for session isolation (works regardless of dmScope)
const threadKeys =
dmThreadId != null
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null;
const useDmThreadSession = shouldUseTelegramDmThreadSession({
dmThreadId,
directConfig,
topicConfig,
});
const threadKeys = useDmThreadSession
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
route = {
...route,

View File

@@ -67,6 +67,7 @@ import {
resolveTelegramForumFlag,
resolveTelegramGroupAllowFromContext,
resolveTelegramThreadSpec,
shouldUseTelegramDmThreadSession,
} from "./bot/helpers.js";
import type { TelegramContext, TelegramGetChat } from "./bot/types.js";
import type { TelegramInlineButtons } from "./button-types.js";
@@ -887,13 +888,16 @@ export const registerTelegramNativeCommands = ({
senderId,
});
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
const threadKeys =
dmThreadId != null
? (await resolveNativeCommandRuntime()).resolveThreadSessionKeys({
baseSessionKey,
threadId: `${chatId}:${dmThreadId}`,
})
: null;
const threadKeys = shouldUseTelegramDmThreadSession({
dmThreadId,
directConfig: !isGroup ? (groupConfig as TelegramDirectConfig | undefined) : undefined,
topicConfig,
})
? (await resolveNativeCommandRuntime()).resolveThreadSessionKeys({
baseSessionKey,
threadId: `${chatId}:${dmThreadId}`,
})
: null;
cachedTargetSessionKey = threadKeys?.sessionKey ?? baseSessionKey;
return cachedTargetSessionKey;
};

View File

@@ -2240,7 +2240,7 @@ describe("createTelegramBot", () => {
undefined,
);
});
it("sets command target session key for dm topic commands", async () => {
it("keeps unconfigured dm topic commands on the flat dm session", async () => {
onSpy.mockClear();
sendMessageSpy.mockClear();
commandSpy.mockClear();
@@ -2279,7 +2279,7 @@ describe("createTelegramBot", () => {
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:12345:99");
expect(payload.CommandTargetSessionKey).toBe("agent:main:main");
});
it("allows native DM commands for paired users", async () => {

View File

@@ -13,6 +13,7 @@ import {
resolveTelegramForumFlag,
resolveTelegramForumThreadId,
resetTelegramForumFlagCacheForTest,
shouldUseTelegramDmThreadSession,
} from "./helpers.js";
describe("resolveTelegramForumThreadId", () => {
@@ -125,6 +126,33 @@ describe("buildTelegramThreadParams", () => {
});
});
describe("shouldUseTelegramDmThreadSession", () => {
it("keeps incidental DM thread ids flat by default", () => {
expect(shouldUseTelegramDmThreadSession({ dmThreadId: 42 })).toBe(false);
});
it("uses DM thread sessions for explicit or topic-required configs", () => {
expect(
shouldUseTelegramDmThreadSession({
dmThreadId: 42,
directConfig: { threadReplies: "inbound" },
}),
).toBe(true);
expect(
shouldUseTelegramDmThreadSession({
dmThreadId: 42,
directConfig: { requireTopic: true },
}),
).toBe(true);
expect(
shouldUseTelegramDmThreadSession({
dmThreadId: 42,
topicConfig: { agentId: "support" },
}),
).toBe(true);
});
});
describe("buildTelegramRoutingTarget", () => {
it.each([
{

View File

@@ -241,6 +241,24 @@ export function resolveTelegramThreadSpec(params: {
};
}
export function shouldUseTelegramDmThreadSession(params: {
dmThreadId?: number;
directConfig?: TelegramDirectConfig;
topicConfig?: TelegramTopicConfig;
}): boolean {
if (params.dmThreadId == null) {
return false;
}
const explicitPolicy = params.directConfig?.threadReplies;
if (explicitPolicy === "off") {
return false;
}
if (explicitPolicy === "inbound" || explicitPolicy === "always") {
return true;
}
return params.directConfig?.requireTopic === true || params.topicConfig !== undefined;
}
/**
* Build thread params for Telegram API calls (messages, media).
*

View File

@@ -208,6 +208,23 @@ describe("telegram topic agentId schema", () => {
expect(res.data.direct?.["123456789"]?.topics?.["99"]?.agentId).toBe("support");
});
it("accepts DM threadReplies overrides", () => {
const res = TelegramConfigSchema.safeParse({
direct: {
"123456789": {
threadReplies: "inbound",
},
},
});
expect(res.success).toBe(true);
if (!res.success) {
console.error(res.error.format());
return;
}
expect(res.data.direct?.["123456789"]?.threadReplies).toBe("inbound");
});
it("accepts empty config without agentId", () => {
const res = TelegramConfigSchema.safeParse({
groups: {

View File

@@ -281,6 +281,8 @@ export type AutoTopicLabelConfig =
export type TelegramDirectConfig = {
/** Per-DM override for DM message policy (open|disabled|allowlist). */
dmPolicy?: DmPolicy;
/** Controls whether Telegram DM message_thread_id values split sessions. Default: off unless topic config requires it. */
threadReplies?: "off" | "inbound" | "always";
/** Optional tool policy overrides for this DM. */
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;

View File

@@ -162,6 +162,7 @@ const AutoTopicLabelSchema = z
export const TelegramDirectSchema = z
.object({
dmPolicy: DmPolicySchema.optional(),
threadReplies: z.enum(["off", "inbound", "always"]).optional(),
tools: ToolPolicySchema,
toolsBySender: ToolPolicyBySenderSchema,
skills: z.array(z.string()).optional(),