mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
feat(telegram): improve DM topics support (#30579) (thanks @kesor)
This commit is contained in:
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/Chat tooling: add `feishu_chat` tool actions for chat info and member queries, with configurable enablement under `channels.feishu.tools.chat`. (#14674) Thanks @liuweifly.
|
||||
- Feishu/Doc permissions: support optional owner permission grant fields on `feishu_doc` create and report permission metadata only when the grant call succeeds, with regression coverage for success/failure/omitted-owner paths. (#28295) Thanks @zhoulongchao77.
|
||||
- Memory/LanceDB: support custom OpenAI `baseUrl` and embedding dimensions for LanceDB memory. (#17874) Thanks @rish2jain and @vincentkoc.
|
||||
- Telegram/DM topics: add per-DM `direct` + topic config (allowlists, `dmPolicy`, `skills`, `systemPrompt`, `requireTopic`), route DM topics as distinct inbound/outbound sessions, and enforce topic-aware authorization/debounce for messages, callbacks, commands, and reactions. Landed from contributor PR #30579 by @kesor. Thanks @kesor.
|
||||
|
||||
- ACP/ACPX streaming: pin ACPX plugin support to `0.1.15`, add configurable ACPX command/version probing, and streamline ACP stream delivery (`final_only` default + reduced tool-event noise) with matching runtime and test updates. (#30036) Thanks @osolmaz.
|
||||
- Cron/Heartbeat light bootstrap context: add opt-in lightweight bootstrap mode for automation runs (`--light-context` for cron agent turns and `agents.*.heartbeat.lightContext` for heartbeat), keeping only `HEARTBEAT.md` for heartbeat runs and skipping bootstrap-file injection for cron lightweight runs. (#26064) Thanks @jose-velez.
|
||||
|
||||
@@ -107,6 +107,7 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
|
||||
group_channel: safeTrim(ctx.GroupChannel),
|
||||
group_space: safeTrim(ctx.GroupSpace),
|
||||
thread_label: safeTrim(ctx.ThreadLabel),
|
||||
topic_id: ctx.MessageThreadId != null ? String(ctx.MessageThreadId) : undefined,
|
||||
is_forum: ctx.IsForum === true ? true : undefined,
|
||||
is_group_chat: !isDirect ? true : undefined,
|
||||
was_mentioned: ctx.WasMentioned === true ? true : undefined,
|
||||
|
||||
@@ -139,6 +139,8 @@ export type MsgContext = {
|
||||
MessageThreadId?: string | number;
|
||||
/** Telegram forum supergroup marker. */
|
||||
IsForum?: boolean;
|
||||
/** Warning: DM has topics enabled but this message is not in a topic. */
|
||||
TopicRequiredButMissing?: boolean;
|
||||
/**
|
||||
* Originating channel for reply routing.
|
||||
* When set, replies should be routed back to this provider
|
||||
|
||||
@@ -79,6 +79,8 @@ export type TelegramAccountConfig = {
|
||||
/** Control reply threading when reply tags are present (off|first|all). */
|
||||
replyToMode?: ReplyToMode;
|
||||
groups?: Record<string, TelegramGroupConfig>;
|
||||
/** Per-DM configuration for Telegram DM topics (key is chat ID). */
|
||||
direct?: Record<string, TelegramDirectConfig>;
|
||||
/** DM allowlist (numeric Telegram user IDs). Onboarding can resolve @username to IDs. */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Default delivery target for CLI `--deliver` when no explicit `--reply-to` is provided. */
|
||||
@@ -204,6 +206,26 @@ export type TelegramGroupConfig = {
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramDirectConfig = {
|
||||
/** Per-DM override for DM message policy (open|disabled|allowlist). */
|
||||
dmPolicy?: DmPolicy;
|
||||
/** Optional tool policy overrides for this DM. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
/** If specified, only load these skills for this DM (when no topic). Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** Per-topic configuration for DM topics (key is message_thread_id as string) */
|
||||
topics?: Record<string, TelegramTopicConfig>;
|
||||
/** If false, disable the bot for this DM (and its topics). */
|
||||
enabled?: boolean;
|
||||
/** If true, require messages to be from a topic when topics are enabled. */
|
||||
requireTopic?: boolean;
|
||||
/** Optional allowlist for DM senders (numeric Telegram user IDs). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Optional system prompt snippet for this DM. */
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type TelegramConfig = {
|
||||
/** Optional per-account Telegram configuration (multi-account). */
|
||||
accounts?: Record<string, TelegramAccountConfig>;
|
||||
|
||||
@@ -79,6 +79,20 @@ export const TelegramGroupSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const TelegramDirectSchema = z
|
||||
.object({
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: ToolPolicyBySenderSchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
|
||||
requireTopic: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const TelegramCustomCommandSchema = z
|
||||
.object({
|
||||
command: z.string().transform(normalizeTelegramCommandName),
|
||||
@@ -148,6 +162,7 @@ export const TelegramAccountSchemaBase = z
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
streaming: z.union([z.boolean(), z.enum(["off", "partial", "block", "progress"])]).optional(),
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("checkBrowserOrigin", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts wildcard entries with surrounding whitespace', () => {
|
||||
it("accepts wildcard entries with surrounding whitespace", () => {
|
||||
const result = checkBrowserOrigin({
|
||||
requestHost: "100.86.79.37:18789",
|
||||
origin: "https://100.86.79.37:18789",
|
||||
|
||||
@@ -293,7 +293,9 @@ function resolveTelegramSession(
|
||||
(chatType === "unknown" &&
|
||||
params.resolvedTarget?.kind &&
|
||||
params.resolvedTarget.kind !== "user");
|
||||
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId;
|
||||
// For groups: include thread ID in peerId. For DMs: use simple chatId (thread handled via suffix).
|
||||
const peerId =
|
||||
isGroup && resolvedThreadId ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : chatId;
|
||||
const peer: RoutePeer = {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
@@ -305,12 +307,21 @@ function resolveTelegramSession(
|
||||
accountId: params.accountId,
|
||||
peer,
|
||||
});
|
||||
// Use thread suffix for DM topics to match inbound session key format
|
||||
const threadKeys =
|
||||
resolvedThreadId && !isGroup
|
||||
? { sessionKey: `${baseSessionKey}:thread:${resolvedThreadId}` }
|
||||
: null;
|
||||
return {
|
||||
sessionKey: baseSessionKey,
|
||||
sessionKey: threadKeys?.sessionKey ?? baseSessionKey,
|
||||
baseSessionKey,
|
||||
peer,
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `telegram:group:${peerId}` : `telegram:${chatId}`,
|
||||
from: isGroup
|
||||
? `telegram:group:${peerId}`
|
||||
: resolvedThreadId
|
||||
? `telegram:${chatId}:topic:${resolvedThreadId}`
|
||||
: `telegram:${chatId}`,
|
||||
to: `telegram:${chatId}`,
|
||||
threadId: resolvedThreadId,
|
||||
};
|
||||
|
||||
@@ -925,6 +925,19 @@ describe("resolveOutboundSessionRoute", () => {
|
||||
threadId: 42,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Telegram DM with topic",
|
||||
cfg: perChannelPeerCfg,
|
||||
channel: "telegram",
|
||||
target: "123456789:topic:99",
|
||||
expected: {
|
||||
sessionKey: "agent:main:telegram:direct:123456789:thread:99",
|
||||
from: "telegram:123456789:topic:99",
|
||||
to: "telegram:123456789",
|
||||
threadId: 99,
|
||||
chatType: "direct",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Telegram unresolved username DM",
|
||||
cfg: perChannelPeerCfg,
|
||||
|
||||
@@ -18,7 +18,11 @@ import { loadConfig } from "../config/config.js";
|
||||
import { writeConfigFile } from "../config/io.js";
|
||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||
import type { DmPolicy } from "../config/types.base.js";
|
||||
import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
import { danger, logVerbose, warn } from "../globals.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { MediaFetchError } from "../media/fetch.js";
|
||||
@@ -608,22 +612,30 @@ export const registerTelegramHandlers = ({
|
||||
|
||||
const resolveTelegramEventAuthorizationContext = async (params: {
|
||||
chatId: number;
|
||||
isGroup: boolean;
|
||||
isForum: boolean;
|
||||
messageThreadId?: number;
|
||||
groupAllowContext?: TelegramGroupAllowContext;
|
||||
}): Promise<TelegramEventAuthorizationContext> => {
|
||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||
const groupAllowContext =
|
||||
params.groupAllowContext ??
|
||||
(await resolveTelegramGroupAllowFromContext({
|
||||
chatId: params.chatId,
|
||||
accountId,
|
||||
isGroup: params.isGroup,
|
||||
isForum: params.isForum,
|
||||
messageThreadId: params.messageThreadId,
|
||||
groupAllowFrom,
|
||||
resolveTelegramGroupConfig,
|
||||
}));
|
||||
return { dmPolicy, ...groupAllowContext };
|
||||
// Use direct config dmPolicy override if available for DMs
|
||||
const effectiveDmPolicy =
|
||||
!params.isGroup &&
|
||||
groupAllowContext.groupConfig &&
|
||||
"dmPolicy" in groupAllowContext.groupConfig
|
||||
? (groupAllowContext.groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing")
|
||||
: (telegramCfg.dmPolicy ?? "pairing");
|
||||
return { dmPolicy: effectiveDmPolicy, ...groupAllowContext };
|
||||
};
|
||||
|
||||
const authorizeTelegramEventSender = (params: {
|
||||
@@ -642,6 +654,7 @@ export const registerTelegramHandlers = ({
|
||||
storeAllowFrom,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
groupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
} = context;
|
||||
@@ -677,8 +690,10 @@ export const registerTelegramHandlers = ({
|
||||
return { allowed: false, reason: "direct-disabled" };
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom,
|
||||
allowFrom: dmAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
@@ -729,6 +744,7 @@ export const registerTelegramHandlers = ({
|
||||
}
|
||||
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
});
|
||||
const senderAuthorization = authorizeTelegramEventSender({
|
||||
@@ -744,6 +760,20 @@ export const registerTelegramHandlers = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Enforce requireTopic for DM reactions: since Telegram doesn't provide messageThreadId
|
||||
// for reactions, we cannot determine if the reaction came from a topic, so block all
|
||||
// reactions if requireTopic is enabled for this DM.
|
||||
if (!isGroup) {
|
||||
const requireTopic = (eventAuthContext.groupConfig as TelegramDirectConfig | undefined)
|
||||
?.requireTopic;
|
||||
if (requireTopic === true) {
|
||||
logVerbose(
|
||||
`Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect added reactions.
|
||||
const oldEmojis = new Set(
|
||||
reaction.old_reaction
|
||||
@@ -811,6 +841,7 @@ export const registerTelegramHandlers = ({
|
||||
msg: Message;
|
||||
chatId: number;
|
||||
resolvedThreadId?: number;
|
||||
dmThreadId?: number;
|
||||
storeAllowFrom: string[];
|
||||
sendOversizeWarning: boolean;
|
||||
oversizeLogMessage: string;
|
||||
@@ -820,6 +851,7 @@ export const registerTelegramHandlers = ({
|
||||
msg,
|
||||
chatId,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
storeAllowFrom,
|
||||
sendOversizeWarning,
|
||||
oversizeLogMessage,
|
||||
@@ -832,7 +864,9 @@ export const registerTelegramHandlers = ({
|
||||
if (text && !isCommandLike) {
|
||||
const nowMs = Date.now();
|
||||
const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown";
|
||||
const key = `text:${chatId}:${resolvedThreadId ?? "main"}:${senderId}`;
|
||||
// Use resolvedThreadId for forum groups, dmThreadId for DM topics
|
||||
const threadId = resolvedThreadId ?? dmThreadId;
|
||||
const key = `text:${chatId}:${threadId ?? "main"}:${senderId}`;
|
||||
const existing = textFragmentBuffer.get(key);
|
||||
|
||||
if (existing) {
|
||||
@@ -970,8 +1004,9 @@ export const registerTelegramHandlers = ({
|
||||
]
|
||||
: [];
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const conversationThreadId = resolvedThreadId ?? dmThreadId;
|
||||
const conversationKey =
|
||||
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
||||
conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId);
|
||||
const debounceLane = resolveTelegramDebounceLane(msg);
|
||||
const debounceKey = senderId
|
||||
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}:${debounceLane}`
|
||||
@@ -1065,10 +1100,18 @@ export const registerTelegramHandlers = ({
|
||||
const isForum = callbackMessage.chat.is_forum === true;
|
||||
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
||||
chatId,
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
});
|
||||
const { resolvedThreadId, storeAllowFrom } = eventAuthContext;
|
||||
const { resolvedThreadId, dmThreadId, storeAllowFrom, groupConfig } = eventAuthContext;
|
||||
const requireTopic = (groupConfig as { requireTopic?: boolean } | undefined)?.requireTopic;
|
||||
if (!isGroup && requireTopic === true && dmThreadId == null) {
|
||||
logVerbose(
|
||||
`Blocked telegram callback in DM ${chatId}: requireTopic=true but no topic present`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const senderId = callback.from?.id ? String(callback.from.id) : "";
|
||||
const senderUsername = callback.from?.username ?? "";
|
||||
const authorizationMode: TelegramEventAuthorizationMode =
|
||||
@@ -1323,20 +1366,25 @@ export const registerTelegramHandlers = ({
|
||||
}
|
||||
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
||||
chatId: event.chatId,
|
||||
isGroup: event.isGroup,
|
||||
isForum: event.isForum,
|
||||
messageThreadId: event.messageThreadId,
|
||||
});
|
||||
const {
|
||||
dmPolicy,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
storeAllowFrom,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
groupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
} = eventAuthContext;
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom,
|
||||
allowFrom: dmAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
@@ -1384,6 +1432,7 @@ export const registerTelegramHandlers = ({
|
||||
msg: event.msg,
|
||||
chatId: event.chatId,
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
storeAllowFrom,
|
||||
sendOversizeWarning: event.sendOversizeWarning,
|
||||
oversizeLogMessage: event.oversizeLogMessage,
|
||||
|
||||
@@ -30,7 +30,12 @@ import {
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.js";
|
||||
import type { DmPolicy, TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
|
||||
import type {
|
||||
DmPolicy,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
@@ -87,7 +92,10 @@ type TelegramLogger = {
|
||||
type ResolveTelegramGroupConfig = (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||
) => {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
};
|
||||
|
||||
type ResolveGroupActivation = (params: {
|
||||
chatId: string | number;
|
||||
@@ -174,7 +182,14 @@ export const buildTelegramMessageContext = async ({
|
||||
});
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const replyThreadId = threadSpec.id;
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, resolvedThreadId);
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
|
||||
const { groupConfig, topicConfig } = resolveTelegramGroupConfig(chatId, threadIdForConfig);
|
||||
// Use direct config dmPolicy override if available for DMs
|
||||
const effectiveDmPolicy =
|
||||
!isGroup && groupConfig && "dmPolicy" in groupConfig
|
||||
? (groupConfig.dmPolicy ?? dmPolicy)
|
||||
: dmPolicy;
|
||||
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
||||
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
||||
@@ -200,16 +215,22 @@ export const buildTelegramMessageContext = async ({
|
||||
return null;
|
||||
}
|
||||
const baseSessionKey = route.sessionKey;
|
||||
// DMs: use raw messageThreadId for thread sessions (not forum topic ids)
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
// DMs: use thread suffix for session isolation (works regardless of dmScope)
|
||||
const threadKeys =
|
||||
dmThreadId != null
|
||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
|
||||
: null;
|
||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({ allowFrom, storeAllowFrom, dmPolicy });
|
||||
// Calculate groupAllowOverride first - it's needed for both DM and group allowlist checks
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: dmAllowFrom,
|
||||
storeAllowFrom,
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
});
|
||||
// Group sender checks are explicit and must not inherit DM pairing-store entries.
|
||||
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
@@ -237,7 +258,11 @@ export const buildTelegramMessageContext = async ({
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`);
|
||||
logVerbose(
|
||||
isGroup
|
||||
? `Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`
|
||||
: `Blocked telegram DM sender ${senderId || "unknown"} (DM allowFrom override)`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -252,10 +277,17 @@ export const buildTelegramMessageContext = async ({
|
||||
const requireMention = firstDefined(
|
||||
activationOverride,
|
||||
topicConfig?.requireMention,
|
||||
groupConfig?.requireMention,
|
||||
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
|
||||
baseRequireMention,
|
||||
);
|
||||
|
||||
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
|
||||
const topicRequiredButMissing = !isGroup && requireTopic === true && dmThreadId == null;
|
||||
if (topicRequiredButMissing) {
|
||||
logVerbose(`Blocked telegram DM ${chatId}: requireTopic=true but no topic present`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const sendTyping = async () => {
|
||||
await withTelegramApiErrorLogging({
|
||||
operation: "sendChatAction",
|
||||
@@ -287,7 +319,7 @@ export const buildTelegramMessageContext = async ({
|
||||
if (
|
||||
!(await enforceTelegramDmAccess({
|
||||
isGroup,
|
||||
dmPolicy,
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
msg,
|
||||
chatId,
|
||||
effectiveDmAllow,
|
||||
@@ -669,7 +701,7 @@ export const buildTelegramMessageContext = async ({
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import type {
|
||||
ReplyToMode,
|
||||
TelegramAccountConfig,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
@@ -172,6 +173,7 @@ async function resolveTelegramCommandAuth(params: {
|
||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||
chatId,
|
||||
accountId,
|
||||
isGroup,
|
||||
isForum,
|
||||
messageThreadId,
|
||||
groupAllowFrom,
|
||||
@@ -179,12 +181,26 @@ async function resolveTelegramCommandAuth(params: {
|
||||
});
|
||||
const {
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
storeAllowFrom,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
groupAllowOverride,
|
||||
effectiveGroupAllow,
|
||||
hasGroupAllowOverride,
|
||||
} = groupAllowContext;
|
||||
// Use direct config dmPolicy override if available for DMs
|
||||
const effectiveDmPolicy =
|
||||
!isGroup && groupConfig && "dmPolicy" in groupConfig
|
||||
? (groupConfig.dmPolicy ?? telegramCfg.dmPolicy ?? "pairing")
|
||||
: (telegramCfg.dmPolicy ?? "pairing");
|
||||
const requireTopic = (groupConfig as TelegramDirectConfig | undefined)?.requireTopic;
|
||||
if (!isGroup && requireTopic === true && dmThreadId == null) {
|
||||
logVerbose(`Blocked telegram command in DM ${chatId}: requireTopic=true but no topic present`);
|
||||
return null;
|
||||
}
|
||||
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||
const senderUsername = msg.from?.username ?? "";
|
||||
|
||||
@@ -254,9 +270,9 @@ async function resolveTelegramCommandAuth(params: {
|
||||
}
|
||||
|
||||
const dmAllow = normalizeDmAllowFromWithStore({
|
||||
allowFrom: allowFrom,
|
||||
allowFrom: dmAllowFrom,
|
||||
storeAllowFrom: isGroup ? [] : storeAllowFrom,
|
||||
dmPolicy: telegramCfg.dmPolicy ?? "pairing",
|
||||
dmPolicy: effectiveDmPolicy,
|
||||
});
|
||||
const senderAllowed = isSenderAllowed({
|
||||
allow: dmAllow,
|
||||
@@ -575,7 +591,7 @@ export const registerTelegramNativeCommands = ({
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
||||
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||
SenderName: buildSenderName(msg),
|
||||
SenderId: senderId || undefined,
|
||||
SenderUsername: senderUsername || undefined,
|
||||
|
||||
@@ -588,6 +588,87 @@ describe("createTelegramBot", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("isolates inbound debounce by DM topic thread id", async () => {
|
||||
const DEBOUNCE_MS = 4321;
|
||||
onSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
loadConfig.mockReturnValue({
|
||||
agents: {
|
||||
defaults: {
|
||||
envelopeTimezone: "utc",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: DEBOUNCE_MS,
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "private" },
|
||||
text: "topic-100",
|
||||
date: 1736380800,
|
||||
message_id: 201,
|
||||
message_thread_id: 100,
|
||||
from: { id: 42, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 7, type: "private" },
|
||||
text: "topic-200",
|
||||
date: 1736380801,
|
||||
message_id: 202,
|
||||
message_thread_id: 200,
|
||||
from: { id: 42, first_name: "Ada" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({}),
|
||||
});
|
||||
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
|
||||
const debounceTimerIndexes = setTimeoutSpy.mock.calls
|
||||
.map((call, index) => ({ index, delay: call[1] }))
|
||||
.filter((entry) => entry.delay === DEBOUNCE_MS)
|
||||
.map((entry) => entry.index);
|
||||
expect(debounceTimerIndexes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
for (const index of debounceTimerIndexes) {
|
||||
clearTimeout(setTimeoutSpy.mock.results[index]?.value as ReturnType<typeof setTimeout>);
|
||||
}
|
||||
for (const index of debounceTimerIndexes) {
|
||||
const flushTimer = setTimeoutSpy.mock.calls[index]?.[0] as (() => unknown) | undefined;
|
||||
await flushTimer?.();
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(replySpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
const threadIds = replySpy.mock.calls
|
||||
.map((call) => (call[0] as { MessageThreadId?: number }).MessageThreadId)
|
||||
.toSorted((a, b) => (a ?? 0) - (b ?? 0));
|
||||
expect(threadIds).toEqual([100, 200]);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("handles quote-only replies without reply metadata", async () => {
|
||||
onSpy.mockClear();
|
||||
sendMessageSpy.mockClear();
|
||||
|
||||
@@ -270,12 +270,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
|
||||
const groupAllowFrom =
|
||||
opts.groupAllowFrom ??
|
||||
telegramCfg.groupAllowFrom ??
|
||||
(telegramCfg.allowFrom && telegramCfg.allowFrom.length > 0
|
||||
? telegramCfg.allowFrom
|
||||
: undefined) ??
|
||||
(opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined);
|
||||
opts.groupAllowFrom ?? telegramCfg.groupAllowFrom ?? telegramCfg.allowFrom ?? allowFrom;
|
||||
const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "off";
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "telegram",
|
||||
@@ -339,11 +334,25 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
||||
});
|
||||
const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
|
||||
const groups = telegramCfg.groups;
|
||||
const direct = telegramCfg.direct;
|
||||
const chatIdStr = String(chatId);
|
||||
const isDm = !chatIdStr.startsWith("-");
|
||||
|
||||
if (isDm) {
|
||||
const directConfig = direct?.[chatIdStr] ?? direct?.["*"];
|
||||
if (directConfig) {
|
||||
const topicConfig =
|
||||
messageThreadId != null ? directConfig.topics?.[String(messageThreadId)] : undefined;
|
||||
return { groupConfig: directConfig, topicConfig };
|
||||
}
|
||||
// DMs without direct config: don't fall through to groups lookup
|
||||
return { groupConfig: undefined, topicConfig: undefined };
|
||||
}
|
||||
|
||||
if (!groups) {
|
||||
return { groupConfig: undefined, topicConfig: undefined };
|
||||
}
|
||||
const groupKey = String(chatId);
|
||||
const groupConfig = groups[groupKey] ?? groups["*"];
|
||||
const groupConfig = groups[chatIdStr] ?? groups["*"];
|
||||
const topicConfig =
|
||||
messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined;
|
||||
return { groupConfig, topicConfig };
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
|
||||
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
|
||||
import { resolveTelegramPreviewStreamMode } from "../../config/discord-preview-streaming.js";
|
||||
import type { TelegramGroupConfig, TelegramTopicConfig } from "../../config/types.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../../config/types.js";
|
||||
import { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
|
||||
@@ -17,33 +21,43 @@ export type TelegramThreadSpec = {
|
||||
export async function resolveTelegramGroupAllowFromContext(params: {
|
||||
chatId: string | number;
|
||||
accountId?: string;
|
||||
isGroup?: boolean;
|
||||
isForum?: boolean;
|
||||
messageThreadId?: number | null;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
resolveTelegramGroupConfig: (
|
||||
chatId: string | number,
|
||||
messageThreadId?: number,
|
||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||
) => {
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
};
|
||||
}): Promise<{
|
||||
resolvedThreadId?: number;
|
||||
dmThreadId?: number;
|
||||
storeAllowFrom: string[];
|
||||
groupConfig?: TelegramGroupConfig;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
groupAllowOverride?: Array<string | number>;
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
hasGroupAllowOverride: boolean;
|
||||
}> {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const resolvedThreadId = resolveTelegramForumThreadId({
|
||||
// Use resolveTelegramThreadSpec to handle both forum groups AND DM topics
|
||||
const threadSpec = resolveTelegramThreadSpec({
|
||||
isGroup: params.isGroup ?? false,
|
||||
isForum: params.isForum,
|
||||
messageThreadId: params.messageThreadId,
|
||||
});
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
const threadIdForConfig = resolvedThreadId ?? dmThreadId;
|
||||
const storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch(
|
||||
() => [],
|
||||
);
|
||||
const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig(
|
||||
params.chatId,
|
||||
resolvedThreadId,
|
||||
threadIdForConfig,
|
||||
);
|
||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||
// Group sender access must remain explicit (groupAllowFrom/per-group allowFrom only).
|
||||
@@ -52,6 +66,7 @@ export async function resolveTelegramGroupAllowFromContext(params: {
|
||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||
return {
|
||||
resolvedThreadId,
|
||||
dmThreadId,
|
||||
storeAllowFrom,
|
||||
groupConfig,
|
||||
topicConfig,
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js";
|
||||
import type {
|
||||
TelegramAccountConfig,
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
@@ -20,7 +21,7 @@ export type TelegramGroupBaseAccessResult =
|
||||
|
||||
export const evaluateTelegramGroupBaseAccess = (params: {
|
||||
isGroup: boolean;
|
||||
groupConfig?: TelegramGroupConfig;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
hasGroupAllowOverride: boolean;
|
||||
effectiveGroupAllow: NormalizedAllowFrom;
|
||||
@@ -29,15 +30,34 @@ export const evaluateTelegramGroupBaseAccess = (params: {
|
||||
enforceAllowOverride: boolean;
|
||||
requireSenderForAllowOverride: boolean;
|
||||
}): TelegramGroupBaseAccessResult => {
|
||||
if (!params.isGroup) {
|
||||
return { allowed: true };
|
||||
}
|
||||
// Check enabled flags for both groups and DMs
|
||||
if (params.groupConfig?.enabled === false) {
|
||||
return { allowed: false, reason: "group-disabled" };
|
||||
}
|
||||
if (params.topicConfig?.enabled === false) {
|
||||
return { allowed: false, reason: "topic-disabled" };
|
||||
}
|
||||
if (!params.isGroup) {
|
||||
// For DMs, check allowFrom override if present
|
||||
if (params.enforceAllowOverride && params.hasGroupAllowOverride) {
|
||||
if (!params.effectiveGroupAllow.hasEntries) {
|
||||
return { allowed: false, reason: "group-override-unauthorized" };
|
||||
}
|
||||
const senderId = params.senderId ?? "";
|
||||
if (params.requireSenderForAllowOverride && !senderId) {
|
||||
return { allowed: false, reason: "group-override-unauthorized" };
|
||||
}
|
||||
const allowed = isSenderAllowed({
|
||||
allow: params.effectiveGroupAllow,
|
||||
senderId,
|
||||
senderUsername: params.senderUsername ?? "",
|
||||
});
|
||||
if (!allowed) {
|
||||
return { allowed: false, reason: "group-override-unauthorized" };
|
||||
}
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
if (!params.enforceAllowOverride || !params.hasGroupAllowOverride) {
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
|
||||
import type {
|
||||
TelegramDirectConfig,
|
||||
TelegramGroupConfig,
|
||||
TelegramTopicConfig,
|
||||
} from "../config/types.js";
|
||||
import { firstDefined } from "./bot-access.js";
|
||||
|
||||
export function resolveTelegramGroupPromptSettings(params: {
|
||||
groupConfig?: TelegramGroupConfig;
|
||||
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||
topicConfig?: TelegramTopicConfig;
|
||||
}): {
|
||||
skillFilter: string[] | undefined;
|
||||
|
||||
Reference in New Issue
Block a user