mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 22:21:37 +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/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.
|
- 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.
|
- 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.
|
- 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.
|
- 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_channel: safeTrim(ctx.GroupChannel),
|
||||||
group_space: safeTrim(ctx.GroupSpace),
|
group_space: safeTrim(ctx.GroupSpace),
|
||||||
thread_label: safeTrim(ctx.ThreadLabel),
|
thread_label: safeTrim(ctx.ThreadLabel),
|
||||||
|
topic_id: ctx.MessageThreadId != null ? String(ctx.MessageThreadId) : undefined,
|
||||||
is_forum: ctx.IsForum === true ? true : undefined,
|
is_forum: ctx.IsForum === true ? true : undefined,
|
||||||
is_group_chat: !isDirect ? true : undefined,
|
is_group_chat: !isDirect ? true : undefined,
|
||||||
was_mentioned: ctx.WasMentioned === true ? true : undefined,
|
was_mentioned: ctx.WasMentioned === true ? true : undefined,
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export type MsgContext = {
|
|||||||
MessageThreadId?: string | number;
|
MessageThreadId?: string | number;
|
||||||
/** Telegram forum supergroup marker. */
|
/** Telegram forum supergroup marker. */
|
||||||
IsForum?: boolean;
|
IsForum?: boolean;
|
||||||
|
/** Warning: DM has topics enabled but this message is not in a topic. */
|
||||||
|
TopicRequiredButMissing?: boolean;
|
||||||
/**
|
/**
|
||||||
* Originating channel for reply routing.
|
* Originating channel for reply routing.
|
||||||
* When set, replies should be routed back to this provider
|
* 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). */
|
/** Control reply threading when reply tags are present (off|first|all). */
|
||||||
replyToMode?: ReplyToMode;
|
replyToMode?: ReplyToMode;
|
||||||
groups?: Record<string, TelegramGroupConfig>;
|
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. */
|
/** DM allowlist (numeric Telegram user IDs). Onboarding can resolve @username to IDs. */
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
/** Default delivery target for CLI `--deliver` when no explicit `--reply-to` is provided. */
|
/** Default delivery target for CLI `--deliver` when no explicit `--reply-to` is provided. */
|
||||||
@@ -204,6 +206,26 @@ export type TelegramGroupConfig = {
|
|||||||
systemPrompt?: string;
|
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 = {
|
export type TelegramConfig = {
|
||||||
/** Optional per-account Telegram configuration (multi-account). */
|
/** Optional per-account Telegram configuration (multi-account). */
|
||||||
accounts?: Record<string, TelegramAccountConfig>;
|
accounts?: Record<string, TelegramAccountConfig>;
|
||||||
|
|||||||
@@ -79,6 +79,20 @@ export const TelegramGroupSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.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
|
const TelegramCustomCommandSchema = z
|
||||||
.object({
|
.object({
|
||||||
command: z.string().transform(normalizeTelegramCommandName),
|
command: z.string().transform(normalizeTelegramCommandName),
|
||||||
@@ -148,6 +162,7 @@ export const TelegramAccountSchemaBase = z
|
|||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||||
|
direct: z.record(z.string(), TelegramDirectSchema.optional()).optional(),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||||
streaming: z.union([z.boolean(), z.enum(["off", "partial", "block", "progress"])]).optional(),
|
streaming: z.union([z.boolean(), z.enum(["off", "partial", "block", "progress"])]).optional(),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ describe("checkBrowserOrigin", () => {
|
|||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts wildcard entries with surrounding whitespace', () => {
|
it("accepts wildcard entries with surrounding whitespace", () => {
|
||||||
const result = checkBrowserOrigin({
|
const result = checkBrowserOrigin({
|
||||||
requestHost: "100.86.79.37:18789",
|
requestHost: "100.86.79.37:18789",
|
||||||
origin: "https://100.86.79.37:18789",
|
origin: "https://100.86.79.37:18789",
|
||||||
|
|||||||
@@ -293,7 +293,9 @@ function resolveTelegramSession(
|
|||||||
(chatType === "unknown" &&
|
(chatType === "unknown" &&
|
||||||
params.resolvedTarget?.kind &&
|
params.resolvedTarget?.kind &&
|
||||||
params.resolvedTarget.kind !== "user");
|
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 = {
|
const peer: RoutePeer = {
|
||||||
kind: isGroup ? "group" : "direct",
|
kind: isGroup ? "group" : "direct",
|
||||||
id: peerId,
|
id: peerId,
|
||||||
@@ -305,12 +307,21 @@ function resolveTelegramSession(
|
|||||||
accountId: params.accountId,
|
accountId: params.accountId,
|
||||||
peer,
|
peer,
|
||||||
});
|
});
|
||||||
|
// Use thread suffix for DM topics to match inbound session key format
|
||||||
|
const threadKeys =
|
||||||
|
resolvedThreadId && !isGroup
|
||||||
|
? { sessionKey: `${baseSessionKey}:thread:${resolvedThreadId}` }
|
||||||
|
: null;
|
||||||
return {
|
return {
|
||||||
sessionKey: baseSessionKey,
|
sessionKey: threadKeys?.sessionKey ?? baseSessionKey,
|
||||||
baseSessionKey,
|
baseSessionKey,
|
||||||
peer,
|
peer,
|
||||||
chatType: isGroup ? "group" : "direct",
|
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}`,
|
to: `telegram:${chatId}`,
|
||||||
threadId: resolvedThreadId,
|
threadId: resolvedThreadId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -925,6 +925,19 @@ describe("resolveOutboundSessionRoute", () => {
|
|||||||
threadId: 42,
|
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",
|
name: "Telegram unresolved username DM",
|
||||||
cfg: perChannelPeerCfg,
|
cfg: perChannelPeerCfg,
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import { loadConfig } from "../config/config.js";
|
|||||||
import { writeConfigFile } from "../config/io.js";
|
import { writeConfigFile } from "../config/io.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
import type { DmPolicy } from "../config/types.base.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 { danger, logVerbose, warn } from "../globals.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { MediaFetchError } from "../media/fetch.js";
|
import { MediaFetchError } from "../media/fetch.js";
|
||||||
@@ -608,22 +612,30 @@ export const registerTelegramHandlers = ({
|
|||||||
|
|
||||||
const resolveTelegramEventAuthorizationContext = async (params: {
|
const resolveTelegramEventAuthorizationContext = async (params: {
|
||||||
chatId: number;
|
chatId: number;
|
||||||
|
isGroup: boolean;
|
||||||
isForum: boolean;
|
isForum: boolean;
|
||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
groupAllowContext?: TelegramGroupAllowContext;
|
groupAllowContext?: TelegramGroupAllowContext;
|
||||||
}): Promise<TelegramEventAuthorizationContext> => {
|
}): Promise<TelegramEventAuthorizationContext> => {
|
||||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
|
||||||
const groupAllowContext =
|
const groupAllowContext =
|
||||||
params.groupAllowContext ??
|
params.groupAllowContext ??
|
||||||
(await resolveTelegramGroupAllowFromContext({
|
(await resolveTelegramGroupAllowFromContext({
|
||||||
chatId: params.chatId,
|
chatId: params.chatId,
|
||||||
accountId,
|
accountId,
|
||||||
|
isGroup: params.isGroup,
|
||||||
isForum: params.isForum,
|
isForum: params.isForum,
|
||||||
messageThreadId: params.messageThreadId,
|
messageThreadId: params.messageThreadId,
|
||||||
groupAllowFrom,
|
groupAllowFrom,
|
||||||
resolveTelegramGroupConfig,
|
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: {
|
const authorizeTelegramEventSender = (params: {
|
||||||
@@ -642,6 +654,7 @@ export const registerTelegramHandlers = ({
|
|||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
topicConfig,
|
topicConfig,
|
||||||
|
groupAllowOverride,
|
||||||
effectiveGroupAllow,
|
effectiveGroupAllow,
|
||||||
hasGroupAllowOverride,
|
hasGroupAllowOverride,
|
||||||
} = context;
|
} = context;
|
||||||
@@ -677,8 +690,10 @@ export const registerTelegramHandlers = ({
|
|||||||
return { allowed: false, reason: "direct-disabled" };
|
return { allowed: false, reason: "direct-disabled" };
|
||||||
}
|
}
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
|
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||||
|
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||||
allowFrom,
|
allowFrom: dmAllowFrom,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
dmPolicy,
|
dmPolicy,
|
||||||
});
|
});
|
||||||
@@ -729,6 +744,7 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
||||||
chatId,
|
chatId,
|
||||||
|
isGroup,
|
||||||
isForum,
|
isForum,
|
||||||
});
|
});
|
||||||
const senderAuthorization = authorizeTelegramEventSender({
|
const senderAuthorization = authorizeTelegramEventSender({
|
||||||
@@ -744,6 +760,20 @@ export const registerTelegramHandlers = ({
|
|||||||
return;
|
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.
|
// Detect added reactions.
|
||||||
const oldEmojis = new Set(
|
const oldEmojis = new Set(
|
||||||
reaction.old_reaction
|
reaction.old_reaction
|
||||||
@@ -811,6 +841,7 @@ export const registerTelegramHandlers = ({
|
|||||||
msg: Message;
|
msg: Message;
|
||||||
chatId: number;
|
chatId: number;
|
||||||
resolvedThreadId?: number;
|
resolvedThreadId?: number;
|
||||||
|
dmThreadId?: number;
|
||||||
storeAllowFrom: string[];
|
storeAllowFrom: string[];
|
||||||
sendOversizeWarning: boolean;
|
sendOversizeWarning: boolean;
|
||||||
oversizeLogMessage: string;
|
oversizeLogMessage: string;
|
||||||
@@ -820,6 +851,7 @@ export const registerTelegramHandlers = ({
|
|||||||
msg,
|
msg,
|
||||||
chatId,
|
chatId,
|
||||||
resolvedThreadId,
|
resolvedThreadId,
|
||||||
|
dmThreadId,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
sendOversizeWarning,
|
sendOversizeWarning,
|
||||||
oversizeLogMessage,
|
oversizeLogMessage,
|
||||||
@@ -832,7 +864,9 @@ export const registerTelegramHandlers = ({
|
|||||||
if (text && !isCommandLike) {
|
if (text && !isCommandLike) {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown";
|
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);
|
const existing = textFragmentBuffer.get(key);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -970,8 +1004,9 @@ export const registerTelegramHandlers = ({
|
|||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||||
|
const conversationThreadId = resolvedThreadId ?? dmThreadId;
|
||||||
const conversationKey =
|
const conversationKey =
|
||||||
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId);
|
||||||
const debounceLane = resolveTelegramDebounceLane(msg);
|
const debounceLane = resolveTelegramDebounceLane(msg);
|
||||||
const debounceKey = senderId
|
const debounceKey = senderId
|
||||||
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}:${debounceLane}`
|
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}:${debounceLane}`
|
||||||
@@ -1065,10 +1100,18 @@ export const registerTelegramHandlers = ({
|
|||||||
const isForum = callbackMessage.chat.is_forum === true;
|
const isForum = callbackMessage.chat.is_forum === true;
|
||||||
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
||||||
chatId,
|
chatId,
|
||||||
|
isGroup,
|
||||||
isForum,
|
isForum,
|
||||||
messageThreadId,
|
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 senderId = callback.from?.id ? String(callback.from.id) : "";
|
||||||
const senderUsername = callback.from?.username ?? "";
|
const senderUsername = callback.from?.username ?? "";
|
||||||
const authorizationMode: TelegramEventAuthorizationMode =
|
const authorizationMode: TelegramEventAuthorizationMode =
|
||||||
@@ -1323,20 +1366,25 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
||||||
chatId: event.chatId,
|
chatId: event.chatId,
|
||||||
|
isGroup: event.isGroup,
|
||||||
isForum: event.isForum,
|
isForum: event.isForum,
|
||||||
messageThreadId: event.messageThreadId,
|
messageThreadId: event.messageThreadId,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
dmPolicy,
|
dmPolicy,
|
||||||
resolvedThreadId,
|
resolvedThreadId,
|
||||||
|
dmThreadId,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
topicConfig,
|
topicConfig,
|
||||||
|
groupAllowOverride,
|
||||||
effectiveGroupAllow,
|
effectiveGroupAllow,
|
||||||
hasGroupAllowOverride,
|
hasGroupAllowOverride,
|
||||||
} = eventAuthContext;
|
} = eventAuthContext;
|
||||||
|
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
||||||
|
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
||||||
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
||||||
allowFrom,
|
allowFrom: dmAllowFrom,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
dmPolicy,
|
dmPolicy,
|
||||||
});
|
});
|
||||||
@@ -1384,6 +1432,7 @@ export const registerTelegramHandlers = ({
|
|||||||
msg: event.msg,
|
msg: event.msg,
|
||||||
chatId: event.chatId,
|
chatId: event.chatId,
|
||||||
resolvedThreadId,
|
resolvedThreadId,
|
||||||
|
dmThreadId,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
sendOversizeWarning: event.sendOversizeWarning,
|
sendOversizeWarning: event.sendOversizeWarning,
|
||||||
oversizeLogMessage: event.oversizeLogMessage,
|
oversizeLogMessage: event.oversizeLogMessage,
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ import {
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { readSessionUpdatedAt, resolveStorePath } from "../config/sessions.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 { logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
@@ -87,7 +92,10 @@ type TelegramLogger = {
|
|||||||
type ResolveTelegramGroupConfig = (
|
type ResolveTelegramGroupConfig = (
|
||||||
chatId: string | number,
|
chatId: string | number,
|
||||||
messageThreadId?: number,
|
messageThreadId?: number,
|
||||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
) => {
|
||||||
|
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||||
|
topicConfig?: TelegramTopicConfig;
|
||||||
|
};
|
||||||
|
|
||||||
type ResolveGroupActivation = (params: {
|
type ResolveGroupActivation = (params: {
|
||||||
chatId: string | number;
|
chatId: string | number;
|
||||||
@@ -174,7 +182,14 @@ export const buildTelegramMessageContext = async ({
|
|||||||
});
|
});
|
||||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||||
const replyThreadId = threadSpec.id;
|
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 peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
||||||
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||||
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
||||||
@@ -200,16 +215,22 @@ export const buildTelegramMessageContext = async ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const baseSessionKey = route.sessionKey;
|
const baseSessionKey = route.sessionKey;
|
||||||
// DMs: use raw messageThreadId for thread sessions (not forum topic ids)
|
// DMs: use thread suffix for session isolation (works regardless of dmScope)
|
||||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
|
||||||
const threadKeys =
|
const threadKeys =
|
||||||
dmThreadId != null
|
dmThreadId != null
|
||||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
|
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${chatId}:${dmThreadId}` })
|
||||||
: null;
|
: null;
|
||||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||||
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
|
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);
|
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.
|
// Group sender checks are explicit and must not inherit DM pairing-store entries.
|
||||||
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
|
const effectiveGroupAllow = normalizeAllowFrom(groupAllowOverride ?? groupAllowFrom);
|
||||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||||
@@ -237,7 +258,11 @@ export const buildTelegramMessageContext = async ({
|
|||||||
);
|
);
|
||||||
return null;
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,10 +277,17 @@ export const buildTelegramMessageContext = async ({
|
|||||||
const requireMention = firstDefined(
|
const requireMention = firstDefined(
|
||||||
activationOverride,
|
activationOverride,
|
||||||
topicConfig?.requireMention,
|
topicConfig?.requireMention,
|
||||||
groupConfig?.requireMention,
|
(groupConfig as TelegramGroupConfig | undefined)?.requireMention,
|
||||||
baseRequireMention,
|
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 () => {
|
const sendTyping = async () => {
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
operation: "sendChatAction",
|
operation: "sendChatAction",
|
||||||
@@ -287,7 +319,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
if (
|
if (
|
||||||
!(await enforceTelegramDmAccess({
|
!(await enforceTelegramDmAccess({
|
||||||
isGroup,
|
isGroup,
|
||||||
dmPolicy,
|
dmPolicy: effectiveDmPolicy,
|
||||||
msg,
|
msg,
|
||||||
chatId,
|
chatId,
|
||||||
effectiveDmAllow,
|
effectiveDmAllow,
|
||||||
@@ -669,7 +701,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
ConversationLabel: conversationLabel,
|
ConversationLabel: conversationLabel,
|
||||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||||
SenderName: senderName,
|
SenderName: senderName,
|
||||||
SenderId: senderId || undefined,
|
SenderId: senderId || undefined,
|
||||||
SenderUsername: senderUsername || undefined,
|
SenderUsername: senderUsername || undefined,
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
ReplyToMode,
|
ReplyToMode,
|
||||||
TelegramAccountConfig,
|
TelegramAccountConfig,
|
||||||
|
TelegramDirectConfig,
|
||||||
TelegramGroupConfig,
|
TelegramGroupConfig,
|
||||||
TelegramTopicConfig,
|
TelegramTopicConfig,
|
||||||
} from "../config/types.js";
|
} from "../config/types.js";
|
||||||
@@ -172,6 +173,7 @@ async function resolveTelegramCommandAuth(params: {
|
|||||||
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
const groupAllowContext = await resolveTelegramGroupAllowFromContext({
|
||||||
chatId,
|
chatId,
|
||||||
accountId,
|
accountId,
|
||||||
|
isGroup,
|
||||||
isForum,
|
isForum,
|
||||||
messageThreadId,
|
messageThreadId,
|
||||||
groupAllowFrom,
|
groupAllowFrom,
|
||||||
@@ -179,12 +181,26 @@ async function resolveTelegramCommandAuth(params: {
|
|||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
resolvedThreadId,
|
resolvedThreadId,
|
||||||
|
dmThreadId,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
topicConfig,
|
topicConfig,
|
||||||
|
groupAllowOverride,
|
||||||
effectiveGroupAllow,
|
effectiveGroupAllow,
|
||||||
hasGroupAllowOverride,
|
hasGroupAllowOverride,
|
||||||
} = groupAllowContext;
|
} = 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 senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||||
const senderUsername = msg.from?.username ?? "";
|
const senderUsername = msg.from?.username ?? "";
|
||||||
|
|
||||||
@@ -254,9 +270,9 @@ async function resolveTelegramCommandAuth(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dmAllow = normalizeDmAllowFromWithStore({
|
const dmAllow = normalizeDmAllowFromWithStore({
|
||||||
allowFrom: allowFrom,
|
allowFrom: dmAllowFrom,
|
||||||
storeAllowFrom: isGroup ? [] : storeAllowFrom,
|
storeAllowFrom: isGroup ? [] : storeAllowFrom,
|
||||||
dmPolicy: telegramCfg.dmPolicy ?? "pairing",
|
dmPolicy: effectiveDmPolicy,
|
||||||
});
|
});
|
||||||
const senderAllowed = isSenderAllowed({
|
const senderAllowed = isSenderAllowed({
|
||||||
allow: dmAllow,
|
allow: dmAllow,
|
||||||
@@ -575,7 +591,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
ChatType: isGroup ? "group" : "direct",
|
ChatType: isGroup ? "group" : "direct",
|
||||||
ConversationLabel: conversationLabel,
|
ConversationLabel: conversationLabel,
|
||||||
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
GroupSubject: isGroup ? (msg.chat.title ?? undefined) : undefined,
|
||||||
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
|
GroupSystemPrompt: isGroup || (!isGroup && groupConfig) ? groupSystemPrompt : undefined,
|
||||||
SenderName: buildSenderName(msg),
|
SenderName: buildSenderName(msg),
|
||||||
SenderId: senderId || undefined,
|
SenderId: senderId || undefined,
|
||||||
SenderUsername: senderUsername || 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 () => {
|
it("handles quote-only replies without reply metadata", async () => {
|
||||||
onSpy.mockClear();
|
onSpy.mockClear();
|
||||||
sendMessageSpy.mockClear();
|
sendMessageSpy.mockClear();
|
||||||
|
|||||||
@@ -270,12 +270,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||||
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
|
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;
|
||||||
const groupAllowFrom =
|
const groupAllowFrom =
|
||||||
opts.groupAllowFrom ??
|
opts.groupAllowFrom ?? telegramCfg.groupAllowFrom ?? telegramCfg.allowFrom ?? allowFrom;
|
||||||
telegramCfg.groupAllowFrom ??
|
|
||||||
(telegramCfg.allowFrom && telegramCfg.allowFrom.length > 0
|
|
||||||
? telegramCfg.allowFrom
|
|
||||||
: undefined) ??
|
|
||||||
(opts.allowFrom && opts.allowFrom.length > 0 ? opts.allowFrom : undefined);
|
|
||||||
const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "off";
|
const replyToMode = opts.replyToMode ?? telegramCfg.replyToMode ?? "off";
|
||||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||||
providerId: "telegram",
|
providerId: "telegram",
|
||||||
@@ -339,11 +334,25 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
});
|
});
|
||||||
const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
|
const resolveTelegramGroupConfig = (chatId: string | number, messageThreadId?: number) => {
|
||||||
const groups = telegramCfg.groups;
|
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) {
|
if (!groups) {
|
||||||
return { groupConfig: undefined, topicConfig: undefined };
|
return { groupConfig: undefined, topicConfig: undefined };
|
||||||
}
|
}
|
||||||
const groupKey = String(chatId);
|
const groupConfig = groups[chatIdStr] ?? groups["*"];
|
||||||
const groupConfig = groups[groupKey] ?? groups["*"];
|
|
||||||
const topicConfig =
|
const topicConfig =
|
||||||
messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined;
|
messageThreadId != null ? groupConfig?.topics?.[String(messageThreadId)] : undefined;
|
||||||
return { groupConfig, topicConfig };
|
return { groupConfig, topicConfig };
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
|
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
|
||||||
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
|
import { formatLocationText, type NormalizedLocation } from "../../channels/location.js";
|
||||||
import { resolveTelegramPreviewStreamMode } from "../../config/discord-preview-streaming.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 { readChannelAllowFromStore } from "../../pairing/pairing-store.js";
|
||||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
|
import { firstDefined, normalizeAllowFrom, type NormalizedAllowFrom } from "../bot-access.js";
|
||||||
@@ -17,33 +21,43 @@ export type TelegramThreadSpec = {
|
|||||||
export async function resolveTelegramGroupAllowFromContext(params: {
|
export async function resolveTelegramGroupAllowFromContext(params: {
|
||||||
chatId: string | number;
|
chatId: string | number;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
|
isGroup?: boolean;
|
||||||
isForum?: boolean;
|
isForum?: boolean;
|
||||||
messageThreadId?: number | null;
|
messageThreadId?: number | null;
|
||||||
groupAllowFrom?: Array<string | number>;
|
groupAllowFrom?: Array<string | number>;
|
||||||
resolveTelegramGroupConfig: (
|
resolveTelegramGroupConfig: (
|
||||||
chatId: string | number,
|
chatId: string | number,
|
||||||
messageThreadId?: number,
|
messageThreadId?: number,
|
||||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
) => {
|
||||||
|
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||||
|
topicConfig?: TelegramTopicConfig;
|
||||||
|
};
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
resolvedThreadId?: number;
|
resolvedThreadId?: number;
|
||||||
|
dmThreadId?: number;
|
||||||
storeAllowFrom: string[];
|
storeAllowFrom: string[];
|
||||||
groupConfig?: TelegramGroupConfig;
|
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||||
topicConfig?: TelegramTopicConfig;
|
topicConfig?: TelegramTopicConfig;
|
||||||
groupAllowOverride?: Array<string | number>;
|
groupAllowOverride?: Array<string | number>;
|
||||||
effectiveGroupAllow: NormalizedAllowFrom;
|
effectiveGroupAllow: NormalizedAllowFrom;
|
||||||
hasGroupAllowOverride: boolean;
|
hasGroupAllowOverride: boolean;
|
||||||
}> {
|
}> {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
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,
|
isForum: params.isForum,
|
||||||
messageThreadId: params.messageThreadId,
|
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 storeAllowFrom = await readChannelAllowFromStore("telegram", process.env, accountId).catch(
|
||||||
() => [],
|
() => [],
|
||||||
);
|
);
|
||||||
const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig(
|
const { groupConfig, topicConfig } = params.resolveTelegramGroupConfig(
|
||||||
params.chatId,
|
params.chatId,
|
||||||
resolvedThreadId,
|
threadIdForConfig,
|
||||||
);
|
);
|
||||||
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
const groupAllowOverride = firstDefined(topicConfig?.allowFrom, groupConfig?.allowFrom);
|
||||||
// Group sender access must remain explicit (groupAllowFrom/per-group allowFrom only).
|
// 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";
|
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||||
return {
|
return {
|
||||||
resolvedThreadId,
|
resolvedThreadId,
|
||||||
|
dmThreadId,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
groupConfig,
|
groupConfig,
|
||||||
topicConfig,
|
topicConfig,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
|||||||
import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js";
|
import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js";
|
||||||
import type {
|
import type {
|
||||||
TelegramAccountConfig,
|
TelegramAccountConfig,
|
||||||
|
TelegramDirectConfig,
|
||||||
TelegramGroupConfig,
|
TelegramGroupConfig,
|
||||||
TelegramTopicConfig,
|
TelegramTopicConfig,
|
||||||
} from "../config/types.js";
|
} from "../config/types.js";
|
||||||
@@ -20,7 +21,7 @@ export type TelegramGroupBaseAccessResult =
|
|||||||
|
|
||||||
export const evaluateTelegramGroupBaseAccess = (params: {
|
export const evaluateTelegramGroupBaseAccess = (params: {
|
||||||
isGroup: boolean;
|
isGroup: boolean;
|
||||||
groupConfig?: TelegramGroupConfig;
|
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||||
topicConfig?: TelegramTopicConfig;
|
topicConfig?: TelegramTopicConfig;
|
||||||
hasGroupAllowOverride: boolean;
|
hasGroupAllowOverride: boolean;
|
||||||
effectiveGroupAllow: NormalizedAllowFrom;
|
effectiveGroupAllow: NormalizedAllowFrom;
|
||||||
@@ -29,15 +30,34 @@ export const evaluateTelegramGroupBaseAccess = (params: {
|
|||||||
enforceAllowOverride: boolean;
|
enforceAllowOverride: boolean;
|
||||||
requireSenderForAllowOverride: boolean;
|
requireSenderForAllowOverride: boolean;
|
||||||
}): TelegramGroupBaseAccessResult => {
|
}): TelegramGroupBaseAccessResult => {
|
||||||
if (!params.isGroup) {
|
// Check enabled flags for both groups and DMs
|
||||||
return { allowed: true };
|
|
||||||
}
|
|
||||||
if (params.groupConfig?.enabled === false) {
|
if (params.groupConfig?.enabled === false) {
|
||||||
return { allowed: false, reason: "group-disabled" };
|
return { allowed: false, reason: "group-disabled" };
|
||||||
}
|
}
|
||||||
if (params.topicConfig?.enabled === false) {
|
if (params.topicConfig?.enabled === false) {
|
||||||
return { allowed: false, reason: "topic-disabled" };
|
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) {
|
if (!params.enforceAllowOverride || !params.hasGroupAllowOverride) {
|
||||||
return { allowed: true };
|
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";
|
import { firstDefined } from "./bot-access.js";
|
||||||
|
|
||||||
export function resolveTelegramGroupPromptSettings(params: {
|
export function resolveTelegramGroupPromptSettings(params: {
|
||||||
groupConfig?: TelegramGroupConfig;
|
groupConfig?: TelegramGroupConfig | TelegramDirectConfig;
|
||||||
topicConfig?: TelegramTopicConfig;
|
topicConfig?: TelegramTopicConfig;
|
||||||
}): {
|
}): {
|
||||||
skillFilter: string[] | undefined;
|
skillFilter: string[] | undefined;
|
||||||
|
|||||||
Reference in New Issue
Block a user