mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:00:42 +00:00
fix(telegram): keep dm reply threads on main session
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user