mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 17:14:33 +00:00
refactor(telegram): simplify send/dispatch/target handling (#17819)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: fcb7aeeca3
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { Message } from "@grammyjs/types";
|
import type { Message } from "@grammyjs/types";
|
||||||
|
import type { TelegramGroupConfig, TelegramTopicConfig } from "../config/types.js";
|
||||||
import type { TelegramMediaRef } from "./bot-message-context.js";
|
import type { TelegramMediaRef } from "./bot-message-context.js";
|
||||||
import type { TelegramContext } from "./bot/types.js";
|
import type { TelegramContext } from "./bot/types.js";
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
@@ -21,7 +22,11 @@ import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
|||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../routing/session-key.js";
|
||||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||||
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
|
import {
|
||||||
|
isSenderAllowed,
|
||||||
|
normalizeAllowFromWithStore,
|
||||||
|
type NormalizedAllowFrom,
|
||||||
|
} from "./bot-access.js";
|
||||||
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
|
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
|
||||||
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
|
||||||
import { resolveMedia } from "./bot/delivery.js";
|
import { resolveMedia } from "./bot/delivery.js";
|
||||||
@@ -31,6 +36,10 @@ import {
|
|||||||
resolveTelegramForumThreadId,
|
resolveTelegramForumThreadId,
|
||||||
resolveTelegramGroupAllowFromContext,
|
resolveTelegramGroupAllowFromContext,
|
||||||
} from "./bot/helpers.js";
|
} from "./bot/helpers.js";
|
||||||
|
import {
|
||||||
|
evaluateTelegramGroupBaseAccess,
|
||||||
|
evaluateTelegramGroupPolicyAccess,
|
||||||
|
} from "./group-access.js";
|
||||||
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
||||||
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
||||||
import {
|
import {
|
||||||
@@ -94,6 +103,30 @@ export const registerTelegramHandlers = ({
|
|||||||
debounceKey: string | null;
|
debounceKey: string | null;
|
||||||
botUsername?: string;
|
botUsername?: string;
|
||||||
};
|
};
|
||||||
|
const buildSyntheticTextMessage = (params: {
|
||||||
|
base: Message;
|
||||||
|
text: string;
|
||||||
|
date?: number;
|
||||||
|
from?: Message["from"];
|
||||||
|
}): Message => ({
|
||||||
|
...params.base,
|
||||||
|
...(params.from ? { from: params.from } : {}),
|
||||||
|
text: params.text,
|
||||||
|
caption: undefined,
|
||||||
|
caption_entities: undefined,
|
||||||
|
entities: undefined,
|
||||||
|
...(params.date != null ? { date: params.date } : {}),
|
||||||
|
});
|
||||||
|
const buildSyntheticContext = (
|
||||||
|
ctx: Pick<TelegramContext, "me"> & { getFile?: unknown },
|
||||||
|
message: Message,
|
||||||
|
): TelegramContext => {
|
||||||
|
const getFile =
|
||||||
|
typeof ctx.getFile === "function"
|
||||||
|
? (ctx.getFile as TelegramContext["getFile"]).bind(ctx as object)
|
||||||
|
: async () => ({});
|
||||||
|
return { message, me: ctx.me, getFile };
|
||||||
|
};
|
||||||
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
|
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
|
||||||
debounceMs,
|
debounceMs,
|
||||||
buildKey: (entry) => entry.debounceKey,
|
buildKey: (entry) => entry.debounceKey,
|
||||||
@@ -125,19 +158,14 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
const first = entries[0];
|
const first = entries[0];
|
||||||
const baseCtx = first.ctx;
|
const baseCtx = first.ctx;
|
||||||
const getFile =
|
const syntheticMessage = buildSyntheticTextMessage({
|
||||||
typeof baseCtx.getFile === "function" ? baseCtx.getFile.bind(baseCtx) : async () => ({});
|
base: first.msg,
|
||||||
const syntheticMessage: Message = {
|
|
||||||
...first.msg,
|
|
||||||
text: combinedText,
|
text: combinedText,
|
||||||
caption: undefined,
|
|
||||||
caption_entities: undefined,
|
|
||||||
entities: undefined,
|
|
||||||
date: last.msg.date ?? first.msg.date,
|
date: last.msg.date ?? first.msg.date,
|
||||||
};
|
});
|
||||||
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
|
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
|
||||||
await processMessage(
|
await processMessage(
|
||||||
{ message: syntheticMessage, me: baseCtx.me, getFile },
|
buildSyntheticContext(baseCtx, syntheticMessage),
|
||||||
[],
|
[],
|
||||||
first.storeAllowFrom,
|
first.storeAllowFrom,
|
||||||
messageIdOverride ? { messageIdOverride } : undefined,
|
messageIdOverride ? { messageIdOverride } : undefined,
|
||||||
@@ -227,11 +255,7 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const storeAllowFrom = await readChannelAllowFromStore(
|
const storeAllowFrom = await loadStoreAllowFrom();
|
||||||
"telegram",
|
|
||||||
process.env,
|
|
||||||
accountId,
|
|
||||||
).catch(() => []);
|
|
||||||
await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom);
|
await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
|
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
|
||||||
@@ -253,48 +277,187 @@ export const registerTelegramHandlers = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const syntheticMessage: Message = {
|
const syntheticMessage = buildSyntheticTextMessage({
|
||||||
...first.msg,
|
base: first.msg,
|
||||||
text: combinedText,
|
text: combinedText,
|
||||||
caption: undefined,
|
|
||||||
caption_entities: undefined,
|
|
||||||
entities: undefined,
|
|
||||||
date: last.msg.date ?? first.msg.date,
|
date: last.msg.date ?? first.msg.date,
|
||||||
};
|
});
|
||||||
|
|
||||||
const storeAllowFrom = await readChannelAllowFromStore(
|
const storeAllowFrom = await loadStoreAllowFrom();
|
||||||
"telegram",
|
|
||||||
process.env,
|
|
||||||
accountId,
|
|
||||||
).catch(() => []);
|
|
||||||
const baseCtx = first.ctx;
|
const baseCtx = first.ctx;
|
||||||
const getFile =
|
|
||||||
typeof baseCtx.getFile === "function" ? baseCtx.getFile.bind(baseCtx) : async () => ({});
|
|
||||||
|
|
||||||
await processMessage(
|
await processMessage(buildSyntheticContext(baseCtx, syntheticMessage), [], storeAllowFrom, {
|
||||||
{ message: syntheticMessage, me: baseCtx.me, getFile },
|
messageIdOverride: String(last.msg.message_id),
|
||||||
[],
|
});
|
||||||
storeAllowFrom,
|
|
||||||
{ messageIdOverride: String(last.msg.message_id) },
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error?.(danger(`text fragment handler failed: ${String(err)}`));
|
runtime.error?.(danger(`text fragment handler failed: ${String(err)}`));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const queueTextFragmentFlush = async (entry: TextFragmentEntry) => {
|
||||||
|
textFragmentProcessing = textFragmentProcessing
|
||||||
|
.then(async () => {
|
||||||
|
await flushTextFragments(entry);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
await textFragmentProcessing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runTextFragmentFlush = async (entry: TextFragmentEntry) => {
|
||||||
|
textFragmentBuffer.delete(entry.key);
|
||||||
|
await queueTextFragmentFlush(entry);
|
||||||
|
};
|
||||||
|
|
||||||
const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => {
|
const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => {
|
||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
entry.timer = setTimeout(async () => {
|
entry.timer = setTimeout(async () => {
|
||||||
textFragmentBuffer.delete(entry.key);
|
await runTextFragmentFlush(entry);
|
||||||
textFragmentProcessing = textFragmentProcessing
|
|
||||||
.then(async () => {
|
|
||||||
await flushTextFragments(entry);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
await textFragmentProcessing;
|
|
||||||
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
|
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const enqueueMediaGroupFlush = async (mediaGroupId: string, entry: MediaGroupEntry) => {
|
||||||
|
mediaGroupBuffer.delete(mediaGroupId);
|
||||||
|
mediaGroupProcessing = mediaGroupProcessing
|
||||||
|
.then(async () => {
|
||||||
|
await processMediaGroup(entry);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
await mediaGroupProcessing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleMediaGroupFlush = (mediaGroupId: string, entry: MediaGroupEntry) => {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
entry.timer = setTimeout(async () => {
|
||||||
|
await enqueueMediaGroupFlush(mediaGroupId, entry);
|
||||||
|
}, mediaGroupTimeoutMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrCreateMediaGroupEntry = (mediaGroupId: string) => {
|
||||||
|
const existing = mediaGroupBuffer.get(mediaGroupId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const entry: MediaGroupEntry = {
|
||||||
|
messages: [],
|
||||||
|
timer: setTimeout(() => undefined, mediaGroupTimeoutMs),
|
||||||
|
};
|
||||||
|
mediaGroupBuffer.set(mediaGroupId, entry);
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStoreAllowFrom = async () =>
|
||||||
|
readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []);
|
||||||
|
|
||||||
|
const isAllowlistAuthorized = (
|
||||||
|
allow: NormalizedAllowFrom,
|
||||||
|
senderId: string,
|
||||||
|
senderUsername: string,
|
||||||
|
) =>
|
||||||
|
allow.hasWildcard ||
|
||||||
|
(allow.hasEntries &&
|
||||||
|
isSenderAllowed({
|
||||||
|
allow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const shouldSkipGroupMessage = (params: {
|
||||||
|
isGroup: boolean;
|
||||||
|
chatId: string | number;
|
||||||
|
chatTitle?: string;
|
||||||
|
resolvedThreadId?: number;
|
||||||
|
senderId: string;
|
||||||
|
senderUsername: string;
|
||||||
|
effectiveGroupAllow: NormalizedAllowFrom;
|
||||||
|
hasGroupAllowOverride: boolean;
|
||||||
|
groupConfig?: TelegramGroupConfig;
|
||||||
|
topicConfig?: TelegramTopicConfig;
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
isGroup,
|
||||||
|
chatId,
|
||||||
|
chatTitle,
|
||||||
|
resolvedThreadId,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
effectiveGroupAllow,
|
||||||
|
hasGroupAllowOverride,
|
||||||
|
groupConfig,
|
||||||
|
topicConfig,
|
||||||
|
} = params;
|
||||||
|
const baseAccess = evaluateTelegramGroupBaseAccess({
|
||||||
|
isGroup,
|
||||||
|
groupConfig,
|
||||||
|
topicConfig,
|
||||||
|
hasGroupAllowOverride,
|
||||||
|
effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
enforceAllowOverride: true,
|
||||||
|
requireSenderForAllowOverride: true,
|
||||||
|
});
|
||||||
|
if (!baseAccess.allowed) {
|
||||||
|
if (baseAccess.reason === "group-disabled") {
|
||||||
|
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (baseAccess.reason === "topic-disabled") {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logVerbose(
|
||||||
|
`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!isGroup) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const policyAccess = evaluateTelegramGroupPolicyAccess({
|
||||||
|
isGroup,
|
||||||
|
chatId,
|
||||||
|
cfg,
|
||||||
|
telegramCfg,
|
||||||
|
topicConfig,
|
||||||
|
groupConfig,
|
||||||
|
effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
resolveGroupPolicy,
|
||||||
|
enforcePolicy: true,
|
||||||
|
useTopicAndGroupOverrides: true,
|
||||||
|
enforceAllowlistAuthorization: true,
|
||||||
|
allowEmptyAllowlistEntries: false,
|
||||||
|
requireSenderForAllowlistAuthorization: true,
|
||||||
|
checkChatAllowlist: true,
|
||||||
|
});
|
||||||
|
if (!policyAccess.allowed) {
|
||||||
|
if (policyAccess.reason === "group-policy-disabled") {
|
||||||
|
logVerbose("Blocked telegram group message (groupPolicy: disabled)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (policyAccess.reason === "group-policy-allowlist-no-sender") {
|
||||||
|
logVerbose("Blocked telegram group message (no sender ID, groupPolicy: allowlist)");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (policyAccess.reason === "group-policy-allowlist-empty") {
|
||||||
|
logVerbose(
|
||||||
|
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (policyAccess.reason === "group-policy-allowlist-unauthorized") {
|
||||||
|
logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
logger.info({ chatId, title: chatTitle, reason: "not-allowed" }, "skipping group message");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
bot.on("callback_query", async (ctx) => {
|
bot.on("callback_query", async (ctx) => {
|
||||||
const callback = ctx.callbackQuery;
|
const callback = ctx.callbackQuery;
|
||||||
if (!callback) {
|
if (!callback) {
|
||||||
@@ -303,11 +466,15 @@ export const registerTelegramHandlers = ({
|
|||||||
if (shouldSkipUpdate(ctx)) {
|
if (shouldSkipUpdate(ctx)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const answerCallbackQuery =
|
||||||
|
typeof (ctx as { answerCallbackQuery?: unknown }).answerCallbackQuery === "function"
|
||||||
|
? () => ctx.answerCallbackQuery()
|
||||||
|
: () => bot.api.answerCallbackQuery(callback.id);
|
||||||
// Answer immediately to prevent Telegram from retrying while we process
|
// Answer immediately to prevent Telegram from retrying while we process
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
operation: "answerCallbackQuery",
|
operation: "answerCallbackQuery",
|
||||||
runtime,
|
runtime,
|
||||||
fn: () => bot.api.answerCallbackQuery(callback.id),
|
fn: answerCallbackQuery,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
try {
|
try {
|
||||||
const data = (callback.data ?? "").trim();
|
const data = (callback.data ?? "").trim();
|
||||||
@@ -315,6 +482,38 @@ export const registerTelegramHandlers = ({
|
|||||||
if (!data || !callbackMessage) {
|
if (!data || !callbackMessage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const editCallbackMessage = async (
|
||||||
|
text: string,
|
||||||
|
params?: Parameters<typeof bot.api.editMessageText>[3],
|
||||||
|
) => {
|
||||||
|
const editTextFn = (ctx as { editMessageText?: unknown }).editMessageText;
|
||||||
|
if (typeof editTextFn === "function") {
|
||||||
|
return await ctx.editMessageText(text, params);
|
||||||
|
}
|
||||||
|
return await bot.api.editMessageText(
|
||||||
|
callbackMessage.chat.id,
|
||||||
|
callbackMessage.message_id,
|
||||||
|
text,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const deleteCallbackMessage = async () => {
|
||||||
|
const deleteFn = (ctx as { deleteMessage?: unknown }).deleteMessage;
|
||||||
|
if (typeof deleteFn === "function") {
|
||||||
|
return await ctx.deleteMessage();
|
||||||
|
}
|
||||||
|
return await bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id);
|
||||||
|
};
|
||||||
|
const replyToCallbackChat = async (
|
||||||
|
text: string,
|
||||||
|
params?: Parameters<typeof bot.api.sendMessage>[2],
|
||||||
|
) => {
|
||||||
|
const replyFn = (ctx as { reply?: unknown }).reply;
|
||||||
|
if (typeof replyFn === "function") {
|
||||||
|
return await ctx.reply(text, params);
|
||||||
|
}
|
||||||
|
return await bot.api.sendMessage(callbackMessage.chat.id, text, params);
|
||||||
|
};
|
||||||
|
|
||||||
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
||||||
cfg,
|
cfg,
|
||||||
@@ -344,8 +543,14 @@ export const registerTelegramHandlers = ({
|
|||||||
groupAllowFrom,
|
groupAllowFrom,
|
||||||
resolveTelegramGroupConfig,
|
resolveTelegramGroupConfig,
|
||||||
});
|
});
|
||||||
const { resolvedThreadId, storeAllowFrom, groupConfig, topicConfig, effectiveGroupAllow } =
|
const {
|
||||||
groupAllowContext;
|
resolvedThreadId,
|
||||||
|
storeAllowFrom,
|
||||||
|
groupConfig,
|
||||||
|
topicConfig,
|
||||||
|
effectiveGroupAllow,
|
||||||
|
hasGroupAllowOverride,
|
||||||
|
} = groupAllowContext;
|
||||||
const effectiveDmAllow = normalizeAllowFromWithStore({
|
const effectiveDmAllow = normalizeAllowFromWithStore({
|
||||||
allowFrom: telegramCfg.allowFrom,
|
allowFrom: telegramCfg.allowFrom,
|
||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
@@ -353,75 +558,21 @@ export const registerTelegramHandlers = ({
|
|||||||
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
|
||||||
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 ?? "";
|
||||||
|
if (
|
||||||
if (isGroup) {
|
shouldSkipGroupMessage({
|
||||||
if (groupConfig?.enabled === false) {
|
isGroup,
|
||||||
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
chatId,
|
||||||
return;
|
chatTitle: callbackMessage.chat.title,
|
||||||
}
|
resolvedThreadId,
|
||||||
if (topicConfig?.enabled === false) {
|
senderId,
|
||||||
logVerbose(
|
senderUsername,
|
||||||
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
effectiveGroupAllow,
|
||||||
);
|
hasGroupAllowOverride,
|
||||||
return;
|
groupConfig,
|
||||||
}
|
topicConfig,
|
||||||
if (groupAllowContext.hasGroupAllowOverride) {
|
})
|
||||||
const allowed =
|
) {
|
||||||
senderId &&
|
return;
|
||||||
isSenderAllowed({
|
|
||||||
allow: effectiveGroupAllow,
|
|
||||||
senderId,
|
|
||||||
senderUsername,
|
|
||||||
});
|
|
||||||
if (!allowed) {
|
|
||||||
logVerbose(
|
|
||||||
`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
||||||
const groupPolicy = firstDefined(
|
|
||||||
topicConfig?.groupPolicy,
|
|
||||||
groupConfig?.groupPolicy,
|
|
||||||
telegramCfg.groupPolicy,
|
|
||||||
defaultGroupPolicy,
|
|
||||||
"open",
|
|
||||||
);
|
|
||||||
if (groupPolicy === "disabled") {
|
|
||||||
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (groupPolicy === "allowlist") {
|
|
||||||
if (!senderId) {
|
|
||||||
logVerbose(`Blocked telegram group message (no sender ID, groupPolicy: allowlist)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!effectiveGroupAllow.hasEntries) {
|
|
||||||
logVerbose(
|
|
||||||
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!isSenderAllowed({
|
|
||||||
allow: effectiveGroupAllow,
|
|
||||||
senderId,
|
|
||||||
senderUsername,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
|
||||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
|
||||||
logger.info(
|
|
||||||
{ chatId, title: callbackMessage.chat.title, reason: "not-allowed" },
|
|
||||||
"skipping group message",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inlineButtonsScope === "allowlist") {
|
if (inlineButtonsScope === "allowlist") {
|
||||||
@@ -430,27 +581,13 @@ export const registerTelegramHandlers = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (dmPolicy !== "open") {
|
if (dmPolicy !== "open") {
|
||||||
const allowed =
|
const allowed = isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername);
|
||||||
effectiveDmAllow.hasWildcard ||
|
|
||||||
(effectiveDmAllow.hasEntries &&
|
|
||||||
isSenderAllowed({
|
|
||||||
allow: effectiveDmAllow,
|
|
||||||
senderId,
|
|
||||||
senderUsername,
|
|
||||||
}));
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const allowed =
|
const allowed = isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername);
|
||||||
effectiveGroupAllow.hasWildcard ||
|
|
||||||
(effectiveGroupAllow.hasEntries &&
|
|
||||||
isSenderAllowed({
|
|
||||||
allow: effectiveGroupAllow,
|
|
||||||
senderId,
|
|
||||||
senderUsername,
|
|
||||||
}));
|
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -487,12 +624,7 @@ export const registerTelegramHandlers = ({
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await bot.api.editMessageText(
|
await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined);
|
||||||
callbackMessage.chat.id,
|
|
||||||
callbackMessage.message_id,
|
|
||||||
result.text,
|
|
||||||
keyboard ? { reply_markup: keyboard } : undefined,
|
|
||||||
);
|
|
||||||
} catch (editErr) {
|
} catch (editErr) {
|
||||||
const errStr = String(editErr);
|
const errStr = String(editErr);
|
||||||
if (!errStr.includes("message is not modified")) {
|
if (!errStr.includes("message is not modified")) {
|
||||||
@@ -514,23 +646,14 @@ export const registerTelegramHandlers = ({
|
|||||||
) => {
|
) => {
|
||||||
const keyboard = buildInlineKeyboard(buttons);
|
const keyboard = buildInlineKeyboard(buttons);
|
||||||
try {
|
try {
|
||||||
await bot.api.editMessageText(
|
await editCallbackMessage(text, keyboard ? { reply_markup: keyboard } : undefined);
|
||||||
callbackMessage.chat.id,
|
|
||||||
callbackMessage.message_id,
|
|
||||||
text,
|
|
||||||
keyboard ? { reply_markup: keyboard } : undefined,
|
|
||||||
);
|
|
||||||
} catch (editErr) {
|
} catch (editErr) {
|
||||||
const errStr = String(editErr);
|
const errStr = String(editErr);
|
||||||
if (errStr.includes("no text in the message")) {
|
if (errStr.includes("no text in the message")) {
|
||||||
try {
|
try {
|
||||||
await bot.api.deleteMessage(callbackMessage.chat.id, callbackMessage.message_id);
|
await deleteCallbackMessage();
|
||||||
} catch {}
|
} catch {}
|
||||||
await bot.api.sendMessage(
|
await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard } : undefined);
|
||||||
callbackMessage.chat.id,
|
|
||||||
text,
|
|
||||||
keyboard ? { reply_markup: keyboard } : undefined,
|
|
||||||
);
|
|
||||||
} else if (!errStr.includes("message is not modified")) {
|
} else if (!errStr.includes("message is not modified")) {
|
||||||
throw editErr;
|
throw editErr;
|
||||||
}
|
}
|
||||||
@@ -597,41 +720,27 @@ export const registerTelegramHandlers = ({
|
|||||||
if (modelCallback.type === "select") {
|
if (modelCallback.type === "select") {
|
||||||
const { provider, model } = modelCallback;
|
const { provider, model } = modelCallback;
|
||||||
// Process model selection as a synthetic message with /model command
|
// Process model selection as a synthetic message with /model command
|
||||||
const syntheticMessage: Message = {
|
const syntheticMessage = buildSyntheticTextMessage({
|
||||||
...callbackMessage,
|
base: callbackMessage,
|
||||||
from: callback.from,
|
from: callback.from,
|
||||||
text: `/model ${provider}/${model}`,
|
text: `/model ${provider}/${model}`,
|
||||||
caption: undefined,
|
});
|
||||||
caption_entities: undefined,
|
await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, {
|
||||||
entities: undefined,
|
forceWasMentioned: true,
|
||||||
};
|
messageIdOverride: callback.id,
|
||||||
const getFile =
|
});
|
||||||
typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({});
|
|
||||||
await processMessage(
|
|
||||||
{ message: syntheticMessage, me: ctx.me, getFile },
|
|
||||||
[],
|
|
||||||
storeAllowFrom,
|
|
||||||
{
|
|
||||||
forceWasMentioned: true,
|
|
||||||
messageIdOverride: callback.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const syntheticMessage: Message = {
|
const syntheticMessage = buildSyntheticTextMessage({
|
||||||
...callbackMessage,
|
base: callbackMessage,
|
||||||
from: callback.from,
|
from: callback.from,
|
||||||
text: data,
|
text: data,
|
||||||
caption: undefined,
|
});
|
||||||
caption_entities: undefined,
|
await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, {
|
||||||
entities: undefined,
|
|
||||||
};
|
|
||||||
const getFile = typeof ctx.getFile === "function" ? ctx.getFile.bind(ctx) : async () => ({});
|
|
||||||
await processMessage({ message: syntheticMessage, me: ctx.me, getFile }, [], storeAllowFrom, {
|
|
||||||
forceWasMentioned: true,
|
forceWasMentioned: true,
|
||||||
messageIdOverride: callback.id,
|
messageIdOverride: callback.id,
|
||||||
});
|
});
|
||||||
@@ -723,85 +832,23 @@ export const registerTelegramHandlers = ({
|
|||||||
hasGroupAllowOverride,
|
hasGroupAllowOverride,
|
||||||
} = groupAllowContext;
|
} = groupAllowContext;
|
||||||
|
|
||||||
if (isGroup) {
|
const senderId = msg.from?.id != null ? String(msg.from.id) : "";
|
||||||
if (groupConfig?.enabled === false) {
|
const senderUsername = msg.from?.username ?? "";
|
||||||
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
if (
|
||||||
return;
|
shouldSkipGroupMessage({
|
||||||
}
|
isGroup,
|
||||||
if (topicConfig?.enabled === false) {
|
chatId,
|
||||||
logVerbose(
|
chatTitle: msg.chat.title,
|
||||||
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
resolvedThreadId,
|
||||||
);
|
senderId,
|
||||||
return;
|
senderUsername,
|
||||||
}
|
effectiveGroupAllow,
|
||||||
if (hasGroupAllowOverride) {
|
hasGroupAllowOverride,
|
||||||
const senderId = msg.from?.id;
|
groupConfig,
|
||||||
const senderUsername = msg.from?.username ?? "";
|
topicConfig,
|
||||||
const allowed =
|
})
|
||||||
senderId != null &&
|
) {
|
||||||
isSenderAllowed({
|
return;
|
||||||
allow: effectiveGroupAllow,
|
|
||||||
senderId: String(senderId),
|
|
||||||
senderUsername,
|
|
||||||
});
|
|
||||||
if (!allowed) {
|
|
||||||
logVerbose(
|
|
||||||
`Blocked telegram group sender ${senderId ?? "unknown"} (group allowFrom override)`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Group policy filtering: controls how group messages are handled
|
|
||||||
// - "open": groups bypass allowFrom, only mention-gating applies
|
|
||||||
// - "disabled": block all group messages entirely
|
|
||||||
// - "allowlist": only allow group messages from senders in groupAllowFrom/allowFrom
|
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
||||||
const groupPolicy = firstDefined(
|
|
||||||
topicConfig?.groupPolicy,
|
|
||||||
groupConfig?.groupPolicy,
|
|
||||||
telegramCfg.groupPolicy,
|
|
||||||
defaultGroupPolicy,
|
|
||||||
"open",
|
|
||||||
);
|
|
||||||
if (groupPolicy === "disabled") {
|
|
||||||
logVerbose(`Blocked telegram group message (groupPolicy: disabled)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (groupPolicy === "allowlist") {
|
|
||||||
// For allowlist mode, the sender (msg.from.id) must be in allowFrom
|
|
||||||
const senderId = msg.from?.id;
|
|
||||||
if (senderId == null) {
|
|
||||||
logVerbose(`Blocked telegram group message (no sender ID, groupPolicy: allowlist)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!effectiveGroupAllow.hasEntries) {
|
|
||||||
logVerbose(
|
|
||||||
"Blocked telegram group message (groupPolicy: allowlist, no group allowlist entries)",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const senderUsername = msg.from?.username ?? "";
|
|
||||||
if (
|
|
||||||
!isSenderAllowed({
|
|
||||||
allow: effectiveGroupAllow,
|
|
||||||
senderId: String(senderId),
|
|
||||||
senderUsername,
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
logVerbose(`Blocked telegram group message from ${senderId} (groupPolicy: allowlist)`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group allowlist based on configured group IDs.
|
|
||||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
|
||||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
|
||||||
logger.info(
|
|
||||||
{ chatId, title: msg.chat.title, reason: "not-allowed" },
|
|
||||||
"skipping group message",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
|
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
|
||||||
@@ -844,13 +891,7 @@ export const registerTelegramHandlers = ({
|
|||||||
|
|
||||||
// Not appendable (or limits exceeded): flush buffered entry first, then continue normally.
|
// Not appendable (or limits exceeded): flush buffered entry first, then continue normally.
|
||||||
clearTimeout(existing.timer);
|
clearTimeout(existing.timer);
|
||||||
textFragmentBuffer.delete(key);
|
await runTextFragmentFlush(existing);
|
||||||
textFragmentProcessing = textFragmentProcessing
|
|
||||||
.then(async () => {
|
|
||||||
await flushTextFragments(existing);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
await textFragmentProcessing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS;
|
const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS;
|
||||||
@@ -869,34 +910,9 @@ export const registerTelegramHandlers = ({
|
|||||||
// Media group handling - buffer multi-image messages
|
// Media group handling - buffer multi-image messages
|
||||||
const mediaGroupId = msg.media_group_id;
|
const mediaGroupId = msg.media_group_id;
|
||||||
if (mediaGroupId) {
|
if (mediaGroupId) {
|
||||||
const existing = mediaGroupBuffer.get(mediaGroupId);
|
const entry = getOrCreateMediaGroupEntry(mediaGroupId);
|
||||||
if (existing) {
|
entry.messages.push({ msg, ctx });
|
||||||
clearTimeout(existing.timer);
|
scheduleMediaGroupFlush(mediaGroupId, entry);
|
||||||
existing.messages.push({ msg, ctx });
|
|
||||||
existing.timer = setTimeout(async () => {
|
|
||||||
mediaGroupBuffer.delete(mediaGroupId);
|
|
||||||
mediaGroupProcessing = mediaGroupProcessing
|
|
||||||
.then(async () => {
|
|
||||||
await processMediaGroup(existing);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
await mediaGroupProcessing;
|
|
||||||
}, mediaGroupTimeoutMs);
|
|
||||||
} else {
|
|
||||||
const entry: MediaGroupEntry = {
|
|
||||||
messages: [{ msg, ctx }],
|
|
||||||
timer: setTimeout(async () => {
|
|
||||||
mediaGroupBuffer.delete(mediaGroupId);
|
|
||||||
mediaGroupProcessing = mediaGroupProcessing
|
|
||||||
.then(async () => {
|
|
||||||
await processMediaGroup(entry);
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
await mediaGroupProcessing;
|
|
||||||
}, mediaGroupTimeoutMs),
|
|
||||||
};
|
|
||||||
mediaGroupBuffer.set(mediaGroupId, entry);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,7 +954,6 @@ export const registerTelegramHandlers = ({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
|
||||||
const conversationKey =
|
const conversationKey =
|
||||||
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
resolvedThreadId != null ? `${chatId}:topic:${resolvedThreadId}` : String(chatId);
|
||||||
const debounceKey = senderId
|
const debounceKey = senderId
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
buildTelegramGroupPeerId,
|
buildTelegramGroupPeerId,
|
||||||
buildTelegramParentPeer,
|
buildTelegramParentPeer,
|
||||||
buildTypingThreadParams,
|
buildTypingThreadParams,
|
||||||
|
resolveTelegramMediaPlaceholder,
|
||||||
expandTextLinks,
|
expandTextLinks,
|
||||||
normalizeForwardedContext,
|
normalizeForwardedContext,
|
||||||
describeReplyTarget,
|
describeReplyTarget,
|
||||||
@@ -56,6 +57,7 @@ import {
|
|||||||
hasBotMention,
|
hasBotMention,
|
||||||
resolveTelegramThreadSpec,
|
resolveTelegramThreadSpec,
|
||||||
} from "./bot/helpers.js";
|
} from "./bot/helpers.js";
|
||||||
|
import { evaluateTelegramGroupBaseAccess } from "./group-access.js";
|
||||||
|
|
||||||
export type TelegramMediaRef = {
|
export type TelegramMediaRef = {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -192,15 +194,31 @@ export const buildTelegramMessageContext = async ({
|
|||||||
storeAllowFrom,
|
storeAllowFrom,
|
||||||
});
|
});
|
||||||
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
const hasGroupAllowOverride = typeof groupAllowOverride !== "undefined";
|
||||||
|
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||||
if (isGroup && groupConfig?.enabled === false) {
|
const senderUsername = msg.from?.username ?? "";
|
||||||
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
const baseAccess = evaluateTelegramGroupBaseAccess({
|
||||||
return null;
|
isGroup,
|
||||||
}
|
groupConfig,
|
||||||
if (isGroup && topicConfig?.enabled === false) {
|
topicConfig,
|
||||||
logVerbose(
|
hasGroupAllowOverride,
|
||||||
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
effectiveGroupAllow,
|
||||||
);
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
enforceAllowOverride: true,
|
||||||
|
requireSenderForAllowOverride: false,
|
||||||
|
});
|
||||||
|
if (!baseAccess.allowed) {
|
||||||
|
if (baseAccess.reason === "group-disabled") {
|
||||||
|
logVerbose(`Blocked telegram group ${chatId} (group disabled)`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (baseAccess.reason === "topic-disabled") {
|
||||||
|
logVerbose(
|
||||||
|
`Blocked telegram topic ${chatId} (${resolvedThreadId ?? "unknown"}) (topic disabled)`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,21 +338,6 @@ export const buildTelegramMessageContext = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const botUsername = primaryCtx.me?.username?.toLowerCase();
|
const botUsername = primaryCtx.me?.username?.toLowerCase();
|
||||||
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
|
||||||
const senderUsername = msg.from?.username ?? "";
|
|
||||||
if (isGroup && hasGroupAllowOverride) {
|
|
||||||
const allowed = isSenderAllowed({
|
|
||||||
allow: effectiveGroupAllow,
|
|
||||||
senderId,
|
|
||||||
senderUsername,
|
|
||||||
});
|
|
||||||
if (!allowed) {
|
|
||||||
logVerbose(
|
|
||||||
`Blocked telegram group sender ${senderId || "unknown"} (group allowFrom override)`,
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow;
|
const allowForCommands = isGroup ? effectiveGroupAllow : effectiveDmAllow;
|
||||||
const senderAllowedForCommands = isSenderAllowed({
|
const senderAllowedForCommands = isSenderAllowed({
|
||||||
allow: allowForCommands,
|
allow: allowForCommands,
|
||||||
@@ -354,20 +357,7 @@ export const buildTelegramMessageContext = async ({
|
|||||||
const commandAuthorized = commandGate.commandAuthorized;
|
const commandAuthorized = commandGate.commandAuthorized;
|
||||||
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
|
const historyKey = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : undefined;
|
||||||
|
|
||||||
let placeholder = "";
|
let placeholder = resolveTelegramMediaPlaceholder(msg) ?? "";
|
||||||
if (msg.photo) {
|
|
||||||
placeholder = "<media:image>";
|
|
||||||
} else if (msg.video) {
|
|
||||||
placeholder = "<media:video>";
|
|
||||||
} else if (msg.video_note) {
|
|
||||||
placeholder = "<media:video>";
|
|
||||||
} else if (msg.audio || msg.voice) {
|
|
||||||
placeholder = "<media:audio>";
|
|
||||||
} else if (msg.document) {
|
|
||||||
placeholder = "<media:document>";
|
|
||||||
} else if (msg.sticker) {
|
|
||||||
placeholder = "<media:sticker>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if sticker has a cached description - if so, use it instead of sending the image
|
// Check if sticker has a cached description - if so, use it instead of sending the image
|
||||||
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
|
const cachedStickerDescription = allMedia[0]?.stickerMetadata?.cachedDescription;
|
||||||
|
|||||||
@@ -249,6 +249,25 @@ export const dispatchTelegramMessage = async ({
|
|||||||
skippedNonSilent: 0,
|
skippedNonSilent: 0,
|
||||||
};
|
};
|
||||||
let finalizedViaPreviewMessage = false;
|
let finalizedViaPreviewMessage = false;
|
||||||
|
const clearGroupHistory = () => {
|
||||||
|
if (isGroup && historyKey) {
|
||||||
|
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const deliveryBaseOptions = {
|
||||||
|
chatId: String(chatId),
|
||||||
|
token: opts.token,
|
||||||
|
runtime,
|
||||||
|
bot,
|
||||||
|
mediaLocalRoots,
|
||||||
|
replyToMode,
|
||||||
|
textLimit,
|
||||||
|
thread: threadSpec,
|
||||||
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
|
linkPreview: telegramCfg.linkPreview,
|
||||||
|
replyQuoteText,
|
||||||
|
};
|
||||||
|
|
||||||
let queuedFinal = false;
|
let queuedFinal = false;
|
||||||
try {
|
try {
|
||||||
@@ -300,20 +319,9 @@ export const dispatchTelegramMessage = async ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result = await deliverReplies({
|
const result = await deliverReplies({
|
||||||
|
...deliveryBaseOptions,
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
|
||||||
token: opts.token,
|
|
||||||
runtime,
|
|
||||||
bot,
|
|
||||||
mediaLocalRoots,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
thread: threadSpec,
|
|
||||||
tableMode,
|
|
||||||
chunkMode,
|
|
||||||
onVoiceRecording: sendRecordVoice,
|
onVoiceRecording: sendRecordVoice,
|
||||||
linkPreview: telegramCfg.linkPreview,
|
|
||||||
replyQuoteText,
|
|
||||||
});
|
});
|
||||||
if (result.delivered) {
|
if (result.delivered) {
|
||||||
deliveryState.delivered = true;
|
deliveryState.delivered = true;
|
||||||
@@ -356,27 +364,14 @@ export const dispatchTelegramMessage = async ({
|
|||||||
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
||||||
const result = await deliverReplies({
|
const result = await deliverReplies({
|
||||||
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
||||||
chatId: String(chatId),
|
...deliveryBaseOptions,
|
||||||
token: opts.token,
|
|
||||||
runtime,
|
|
||||||
bot,
|
|
||||||
mediaLocalRoots,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
thread: threadSpec,
|
|
||||||
tableMode,
|
|
||||||
chunkMode,
|
|
||||||
linkPreview: telegramCfg.linkPreview,
|
|
||||||
replyQuoteText,
|
|
||||||
});
|
});
|
||||||
sentFallback = result.delivered;
|
sentFallback = result.delivered;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFinalResponse = queuedFinal || sentFallback;
|
const hasFinalResponse = queuedFinal || sentFallback;
|
||||||
if (!hasFinalResponse) {
|
if (!hasFinalResponse) {
|
||||||
if (isGroup && historyKey) {
|
clearGroupHistory();
|
||||||
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
removeAckReactionAfterReply({
|
removeAckReactionAfterReply({
|
||||||
@@ -396,7 +391,5 @@ export const dispatchTelegramMessage = async ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (isGroup && historyKey) {
|
clearGroupHistory();
|
||||||
clearHistoryEntriesIfEnabled({ historyMap: groupHistories, historyKey, limit: historyLimit });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ import {
|
|||||||
resolveTelegramGroupAllowFromContext,
|
resolveTelegramGroupAllowFromContext,
|
||||||
resolveTelegramThreadSpec,
|
resolveTelegramThreadSpec,
|
||||||
} from "./bot/helpers.js";
|
} from "./bot/helpers.js";
|
||||||
|
import {
|
||||||
|
evaluateTelegramGroupBaseAccess,
|
||||||
|
evaluateTelegramGroupPolicyAccess,
|
||||||
|
} from "./group-access.js";
|
||||||
import { buildInlineKeyboard } from "./send.js";
|
import { buildInlineKeyboard } from "./send.js";
|
||||||
|
|
||||||
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
const EMPTY_RESPONSE_FALLBACK = "No response generated. Please try again.";
|
||||||
@@ -172,68 +176,71 @@ async function resolveTelegramCommandAuth(params: {
|
|||||||
effectiveGroupAllow,
|
effectiveGroupAllow,
|
||||||
hasGroupAllowOverride,
|
hasGroupAllowOverride,
|
||||||
} = groupAllowContext;
|
} = groupAllowContext;
|
||||||
const senderIdRaw = msg.from?.id;
|
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
||||||
const senderId = senderIdRaw ? String(senderIdRaw) : "";
|
|
||||||
const senderUsername = msg.from?.username ?? "";
|
const senderUsername = msg.from?.username ?? "";
|
||||||
|
|
||||||
const isGroupSenderAllowed = () =>
|
const sendAuthMessage = async (text: string) => {
|
||||||
senderIdRaw != null &&
|
|
||||||
isSenderAllowed({
|
|
||||||
allow: effectiveGroupAllow,
|
|
||||||
senderId: String(senderIdRaw),
|
|
||||||
senderUsername,
|
|
||||||
});
|
|
||||||
|
|
||||||
const rejectNotAuthorized = async () => {
|
|
||||||
await withTelegramApiErrorLogging({
|
await withTelegramApiErrorLogging({
|
||||||
operation: "sendMessage",
|
operation: "sendMessage",
|
||||||
fn: () => bot.api.sendMessage(chatId, "You are not authorized to use this command."),
|
fn: () => bot.api.sendMessage(chatId, text),
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
const rejectNotAuthorized = async () => {
|
||||||
|
return await sendAuthMessage("You are not authorized to use this command.");
|
||||||
|
};
|
||||||
|
|
||||||
if (isGroup && groupConfig?.enabled === false) {
|
const baseAccess = evaluateTelegramGroupBaseAccess({
|
||||||
await withTelegramApiErrorLogging({
|
isGroup,
|
||||||
operation: "sendMessage",
|
groupConfig,
|
||||||
fn: () => bot.api.sendMessage(chatId, "This group is disabled."),
|
topicConfig,
|
||||||
});
|
hasGroupAllowOverride,
|
||||||
return null;
|
effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
enforceAllowOverride: requireAuth,
|
||||||
|
requireSenderForAllowOverride: true,
|
||||||
|
});
|
||||||
|
if (!baseAccess.allowed) {
|
||||||
|
if (baseAccess.reason === "group-disabled") {
|
||||||
|
return await sendAuthMessage("This group is disabled.");
|
||||||
|
}
|
||||||
|
if (baseAccess.reason === "topic-disabled") {
|
||||||
|
return await sendAuthMessage("This topic is disabled.");
|
||||||
|
}
|
||||||
|
return await rejectNotAuthorized();
|
||||||
}
|
}
|
||||||
if (isGroup && topicConfig?.enabled === false) {
|
|
||||||
await withTelegramApiErrorLogging({
|
const policyAccess = evaluateTelegramGroupPolicyAccess({
|
||||||
operation: "sendMessage",
|
isGroup,
|
||||||
fn: () => bot.api.sendMessage(chatId, "This topic is disabled."),
|
chatId,
|
||||||
});
|
cfg,
|
||||||
return null;
|
telegramCfg,
|
||||||
}
|
topicConfig,
|
||||||
if (requireAuth && isGroup && hasGroupAllowOverride) {
|
groupConfig,
|
||||||
if (!isGroupSenderAllowed()) {
|
effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
resolveGroupPolicy,
|
||||||
|
enforcePolicy: useAccessGroups,
|
||||||
|
useTopicAndGroupOverrides: false,
|
||||||
|
enforceAllowlistAuthorization: requireAuth,
|
||||||
|
allowEmptyAllowlistEntries: true,
|
||||||
|
requireSenderForAllowlistAuthorization: true,
|
||||||
|
checkChatAllowlist: useAccessGroups,
|
||||||
|
});
|
||||||
|
if (!policyAccess.allowed) {
|
||||||
|
if (policyAccess.reason === "group-policy-disabled") {
|
||||||
|
return await sendAuthMessage("Telegram group commands are disabled.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
policyAccess.reason === "group-policy-allowlist-no-sender" ||
|
||||||
|
policyAccess.reason === "group-policy-allowlist-unauthorized"
|
||||||
|
) {
|
||||||
return await rejectNotAuthorized();
|
return await rejectNotAuthorized();
|
||||||
}
|
}
|
||||||
}
|
if (policyAccess.reason === "group-chat-not-allowed") {
|
||||||
|
return await sendAuthMessage("This group is not allowed.");
|
||||||
if (isGroup && useAccessGroups) {
|
|
||||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
||||||
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
|
|
||||||
if (groupPolicy === "disabled") {
|
|
||||||
await withTelegramApiErrorLogging({
|
|
||||||
operation: "sendMessage",
|
|
||||||
fn: () => bot.api.sendMessage(chatId, "Telegram group commands are disabled."),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (groupPolicy === "allowlist" && requireAuth) {
|
|
||||||
if (!isGroupSenderAllowed()) {
|
|
||||||
return await rejectNotAuthorized();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const groupAllowlist = resolveGroupPolicy(chatId);
|
|
||||||
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
|
||||||
await withTelegramApiErrorLogging({
|
|
||||||
operation: "sendMessage",
|
|
||||||
fn: () => bot.api.sendMessage(chatId, "This group is not allowed."),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,11 +259,7 @@ async function resolveTelegramCommandAuth(params: {
|
|||||||
modeWhenAccessGroupsOff: "configured",
|
modeWhenAccessGroupsOff: "configured",
|
||||||
});
|
});
|
||||||
if (requireAuth && !commandAuthorized) {
|
if (requireAuth && !commandAuthorized) {
|
||||||
await withTelegramApiErrorLogging({
|
return await rejectNotAuthorized();
|
||||||
operation: "sendMessage",
|
|
||||||
fn: () => bot.api.sendMessage(chatId, "You are not authorized to use this command."),
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -357,6 +360,60 @@ export const registerTelegramNativeCommands = ({
|
|||||||
// Keep hidden commands callable by registering handlers for the full catalog.
|
// Keep hidden commands callable by registering handlers for the full catalog.
|
||||||
syncTelegramMenuCommands({ bot, runtime, commandsToRegister });
|
syncTelegramMenuCommands({ bot, runtime, commandsToRegister });
|
||||||
|
|
||||||
|
const resolveCommandRuntimeContext = (params: {
|
||||||
|
msg: NonNullable<TelegramNativeCommandContext["message"]>;
|
||||||
|
isGroup: boolean;
|
||||||
|
isForum: boolean;
|
||||||
|
resolvedThreadId?: number;
|
||||||
|
}) => {
|
||||||
|
const { msg, isGroup, isForum, resolvedThreadId } = params;
|
||||||
|
const chatId = msg.chat.id;
|
||||||
|
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
||||||
|
const threadSpec = resolveTelegramThreadSpec({
|
||||||
|
isGroup,
|
||||||
|
isForum,
|
||||||
|
messageThreadId,
|
||||||
|
});
|
||||||
|
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||||
|
const route = resolveAgentRoute({
|
||||||
|
cfg,
|
||||||
|
channel: "telegram",
|
||||||
|
accountId,
|
||||||
|
peer: {
|
||||||
|
kind: isGroup ? "group" : "direct",
|
||||||
|
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
||||||
|
},
|
||||||
|
parentPeer,
|
||||||
|
});
|
||||||
|
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
||||||
|
const tableMode = resolveMarkdownTableMode({
|
||||||
|
cfg,
|
||||||
|
channel: "telegram",
|
||||||
|
accountId: route.accountId,
|
||||||
|
});
|
||||||
|
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
||||||
|
return { chatId, threadSpec, route, mediaLocalRoots, tableMode, chunkMode };
|
||||||
|
};
|
||||||
|
const buildCommandDeliveryBaseOptions = (params: {
|
||||||
|
chatId: string | number;
|
||||||
|
mediaLocalRoots?: readonly string[];
|
||||||
|
threadSpec: ReturnType<typeof resolveTelegramThreadSpec>;
|
||||||
|
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
|
||||||
|
chunkMode: ReturnType<typeof resolveChunkMode>;
|
||||||
|
}) => ({
|
||||||
|
chatId: String(params.chatId),
|
||||||
|
token: opts.token,
|
||||||
|
runtime,
|
||||||
|
bot,
|
||||||
|
mediaLocalRoots: params.mediaLocalRoots,
|
||||||
|
replyToMode,
|
||||||
|
textLimit,
|
||||||
|
thread: params.threadSpec,
|
||||||
|
tableMode: params.tableMode,
|
||||||
|
chunkMode: params.chunkMode,
|
||||||
|
linkPreview: telegramCfg.linkPreview,
|
||||||
|
});
|
||||||
|
|
||||||
if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) {
|
if (commandsToRegister.length > 0 || pluginCatalog.commands.length > 0) {
|
||||||
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
|
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
|
||||||
logVerbose("telegram: bot.command unavailable; skipping native handlers");
|
logVerbose("telegram: bot.command unavailable; skipping native handlers");
|
||||||
@@ -397,11 +454,19 @@ export const registerTelegramNativeCommands = ({
|
|||||||
topicConfig,
|
topicConfig,
|
||||||
commandAuthorized,
|
commandAuthorized,
|
||||||
} = auth;
|
} = auth;
|
||||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
const { threadSpec, route, mediaLocalRoots, tableMode, chunkMode } =
|
||||||
const threadSpec = resolveTelegramThreadSpec({
|
resolveCommandRuntimeContext({
|
||||||
isGroup,
|
msg,
|
||||||
isForum,
|
isGroup,
|
||||||
messageThreadId,
|
isForum,
|
||||||
|
resolvedThreadId,
|
||||||
|
});
|
||||||
|
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
|
||||||
|
chatId,
|
||||||
|
mediaLocalRoots,
|
||||||
|
threadSpec,
|
||||||
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
|
const threadParams = buildTelegramThreadParams(threadSpec) ?? {};
|
||||||
|
|
||||||
@@ -455,18 +520,6 @@ export const registerTelegramNativeCommands = ({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
|
||||||
const route = resolveAgentRoute({
|
|
||||||
cfg,
|
|
||||||
channel: "telegram",
|
|
||||||
accountId,
|
|
||||||
peer: {
|
|
||||||
kind: isGroup ? "group" : "direct",
|
|
||||||
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
|
||||||
},
|
|
||||||
parentPeer,
|
|
||||||
});
|
|
||||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
|
||||||
const baseSessionKey = route.sessionKey;
|
const baseSessionKey = route.sessionKey;
|
||||||
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
// DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums)
|
||||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||||
@@ -478,11 +531,6 @@ export const registerTelegramNativeCommands = ({
|
|||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
||||||
const tableMode = resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "telegram",
|
|
||||||
accountId: route.accountId,
|
|
||||||
});
|
|
||||||
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
|
const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills);
|
||||||
const systemPromptParts = [
|
const systemPromptParts = [
|
||||||
groupConfig?.systemPrompt?.trim() || null,
|
groupConfig?.systemPrompt?.trim() || null,
|
||||||
@@ -530,7 +578,6 @@ export const registerTelegramNativeCommands = ({
|
|||||||
typeof telegramCfg.blockStreaming === "boolean"
|
typeof telegramCfg.blockStreaming === "boolean"
|
||||||
? !telegramCfg.blockStreaming
|
? !telegramCfg.blockStreaming
|
||||||
: undefined;
|
: undefined;
|
||||||
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
|
||||||
|
|
||||||
const deliveryState = {
|
const deliveryState = {
|
||||||
delivered: false,
|
delivered: false,
|
||||||
@@ -552,17 +599,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
deliver: async (payload, _info) => {
|
deliver: async (payload, _info) => {
|
||||||
const result = await deliverReplies({
|
const result = await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
chatId: String(chatId),
|
...deliveryBaseOptions,
|
||||||
token: opts.token,
|
|
||||||
runtime,
|
|
||||||
bot,
|
|
||||||
mediaLocalRoots,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
thread: threadSpec,
|
|
||||||
tableMode,
|
|
||||||
chunkMode,
|
|
||||||
linkPreview: telegramCfg.linkPreview,
|
|
||||||
});
|
});
|
||||||
if (result.delivered) {
|
if (result.delivered) {
|
||||||
deliveryState.delivered = true;
|
deliveryState.delivered = true;
|
||||||
@@ -586,17 +623,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
if (!deliveryState.delivered && deliveryState.skippedNonSilent > 0) {
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
replies: [{ text: EMPTY_RESPONSE_FALLBACK }],
|
||||||
chatId: String(chatId),
|
...deliveryBaseOptions,
|
||||||
token: opts.token,
|
|
||||||
runtime,
|
|
||||||
bot,
|
|
||||||
mediaLocalRoots,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
thread: threadSpec,
|
|
||||||
tableMode,
|
|
||||||
chunkMode,
|
|
||||||
linkPreview: telegramCfg.linkPreview,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -640,24 +667,20 @@ export const registerTelegramNativeCommands = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
|
const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth;
|
||||||
const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id;
|
const { threadSpec, mediaLocalRoots, tableMode, chunkMode } =
|
||||||
const threadSpec = resolveTelegramThreadSpec({
|
resolveCommandRuntimeContext({
|
||||||
isGroup,
|
msg,
|
||||||
isForum,
|
isGroup,
|
||||||
messageThreadId,
|
isForum,
|
||||||
|
resolvedThreadId,
|
||||||
|
});
|
||||||
|
const deliveryBaseOptions = buildCommandDeliveryBaseOptions({
|
||||||
|
chatId,
|
||||||
|
mediaLocalRoots,
|
||||||
|
threadSpec,
|
||||||
|
tableMode,
|
||||||
|
chunkMode,
|
||||||
});
|
});
|
||||||
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
|
||||||
const route = resolveAgentRoute({
|
|
||||||
cfg,
|
|
||||||
channel: "telegram",
|
|
||||||
accountId,
|
|
||||||
peer: {
|
|
||||||
kind: isGroup ? "group" : "direct",
|
|
||||||
id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId),
|
|
||||||
},
|
|
||||||
parentPeer,
|
|
||||||
});
|
|
||||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
|
||||||
const from = isGroup
|
const from = isGroup
|
||||||
? buildTelegramGroupFrom(chatId, threadSpec.id)
|
? buildTelegramGroupFrom(chatId, threadSpec.id)
|
||||||
: `telegram:${chatId}`;
|
: `telegram:${chatId}`;
|
||||||
@@ -676,26 +699,10 @@ export const registerTelegramNativeCommands = ({
|
|||||||
accountId,
|
accountId,
|
||||||
messageThreadId: threadSpec.id,
|
messageThreadId: threadSpec.id,
|
||||||
});
|
});
|
||||||
const tableMode = resolveMarkdownTableMode({
|
|
||||||
cfg,
|
|
||||||
channel: "telegram",
|
|
||||||
accountId: route.accountId,
|
|
||||||
});
|
|
||||||
const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId);
|
|
||||||
|
|
||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [result],
|
replies: [result],
|
||||||
chatId: String(chatId),
|
...deliveryBaseOptions,
|
||||||
token: opts.token,
|
|
||||||
runtime,
|
|
||||||
bot,
|
|
||||||
mediaLocalRoots,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
thread: threadSpec,
|
|
||||||
tableMode,
|
|
||||||
chunkMode,
|
|
||||||
linkPreview: telegramCfg.linkPreview,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
|||||||
import { resolveTelegramVoiceSend } from "../voice.js";
|
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||||
import {
|
import {
|
||||||
buildTelegramThreadParams,
|
buildTelegramThreadParams,
|
||||||
|
resolveTelegramMediaPlaceholder,
|
||||||
resolveTelegramReplyId,
|
resolveTelegramReplyId,
|
||||||
type TelegramThreadSpec,
|
type TelegramThreadSpec,
|
||||||
} from "./helpers.js";
|
} from "./helpers.js";
|
||||||
@@ -429,16 +430,7 @@ export async function resolveMedia(
|
|||||||
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
||||||
}
|
}
|
||||||
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
||||||
let placeholder = "<media:document>";
|
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
||||||
if (msg.photo) {
|
|
||||||
placeholder = "<media:image>";
|
|
||||||
} else if (msg.video) {
|
|
||||||
placeholder = "<media:video>";
|
|
||||||
} else if (msg.video_note) {
|
|
||||||
placeholder = "<media:video>";
|
|
||||||
} else if (msg.audio || msg.voice) {
|
|
||||||
placeholder = "<media:audio>";
|
|
||||||
}
|
|
||||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,6 +197,33 @@ export function buildSenderName(msg: Message) {
|
|||||||
return name || undefined;
|
return name || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveTelegramMediaPlaceholder(
|
||||||
|
msg:
|
||||||
|
| Pick<Message, "photo" | "video" | "video_note" | "audio" | "voice" | "document" | "sticker">
|
||||||
|
| undefined
|
||||||
|
| null,
|
||||||
|
): string | undefined {
|
||||||
|
if (!msg) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (msg.photo) {
|
||||||
|
return "<media:image>";
|
||||||
|
}
|
||||||
|
if (msg.video || msg.video_note) {
|
||||||
|
return "<media:video>";
|
||||||
|
}
|
||||||
|
if (msg.audio || msg.voice) {
|
||||||
|
return "<media:audio>";
|
||||||
|
}
|
||||||
|
if (msg.document) {
|
||||||
|
return "<media:document>";
|
||||||
|
}
|
||||||
|
if (msg.sticker) {
|
||||||
|
return "<media:sticker>";
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildSenderLabel(msg: Message, senderId?: number | string) {
|
export function buildSenderLabel(msg: Message, senderId?: number | string) {
|
||||||
const name = buildSenderName(msg);
|
const name = buildSenderName(msg);
|
||||||
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
|
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
|
||||||
@@ -318,15 +345,8 @@ export function describeReplyTarget(msg: Message): TelegramReplyTarget | null {
|
|||||||
const replyBody = (replyLike.text ?? replyLike.caption ?? "").trim();
|
const replyBody = (replyLike.text ?? replyLike.caption ?? "").trim();
|
||||||
body = replyBody;
|
body = replyBody;
|
||||||
if (!body) {
|
if (!body) {
|
||||||
if (replyLike.photo) {
|
body = resolveTelegramMediaPlaceholder(replyLike) ?? "";
|
||||||
body = "<media:image>";
|
if (!body) {
|
||||||
} else if (replyLike.video) {
|
|
||||||
body = "<media:video>";
|
|
||||||
} else if (replyLike.audio || replyLike.voice) {
|
|
||||||
body = "<media:audio>";
|
|
||||||
} else if (replyLike.document) {
|
|
||||||
body = "<media:document>";
|
|
||||||
} else {
|
|
||||||
const locationData = extractTelegramLocation(replyLike);
|
const locationData = extractTelegramLocation(replyLike);
|
||||||
if (locationData) {
|
if (locationData) {
|
||||||
body = formatLocationText(locationData);
|
body = formatLocationText(locationData);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { detectMime } from "../media/mime.js";
|
|
||||||
import { type SavedMedia, saveMediaBuffer } from "../media/store.js";
|
|
||||||
|
|
||||||
export type TelegramFileInfo = {
|
|
||||||
file_id: string;
|
|
||||||
file_unique_id?: string;
|
|
||||||
file_size?: number;
|
|
||||||
file_path?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getTelegramFile(
|
|
||||||
token: string,
|
|
||||||
fileId: string,
|
|
||||||
timeoutMs = 30_000,
|
|
||||||
): Promise<TelegramFileInfo> {
|
|
||||||
const res = await fetch(
|
|
||||||
`https://api.telegram.org/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`,
|
|
||||||
{ signal: AbortSignal.timeout(timeoutMs) },
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`getFile failed: ${res.status} ${res.statusText}`);
|
|
||||||
}
|
|
||||||
const json = (await res.json()) as { ok: boolean; result?: TelegramFileInfo };
|
|
||||||
if (!json.ok || !json.result?.file_path) {
|
|
||||||
throw new Error("getFile returned no file_path");
|
|
||||||
}
|
|
||||||
return json.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function downloadTelegramFile(
|
|
||||||
token: string,
|
|
||||||
info: TelegramFileInfo,
|
|
||||||
maxBytes?: number,
|
|
||||||
timeoutMs = 60_000,
|
|
||||||
): Promise<SavedMedia> {
|
|
||||||
if (!info.file_path) {
|
|
||||||
throw new Error("file_path missing");
|
|
||||||
}
|
|
||||||
const url = `https://api.telegram.org/file/bot${token}/${info.file_path}`;
|
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
|
||||||
if (!res.ok || !res.body) {
|
|
||||||
throw new Error(`Failed to download telegram file: HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
const array = Buffer.from(await res.arrayBuffer());
|
|
||||||
const mime = await detectMime({
|
|
||||||
buffer: array,
|
|
||||||
headerMime: res.headers.get("content-type"),
|
|
||||||
filePath: info.file_path,
|
|
||||||
});
|
|
||||||
// save with inbound subdir
|
|
||||||
const saved = await saveMediaBuffer(array, mime, "inbound", maxBytes, info.file_path);
|
|
||||||
// Ensure extension matches mime if possible
|
|
||||||
if (!saved.contentType && mime) {
|
|
||||||
saved.contentType = mime;
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { downloadTelegramFile, getTelegramFile, type TelegramFileInfo } from "./download.js";
|
|
||||||
import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.js";
|
import { resetTelegramFetchStateForTests, resolveTelegramFetch } from "./fetch.js";
|
||||||
|
|
||||||
const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn());
|
const setDefaultAutoSelectFamily = vi.hoisted(() => vi.fn());
|
||||||
@@ -61,36 +60,3 @@ describe("resolveTelegramFetch", () => {
|
|||||||
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false);
|
expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("telegram download", () => {
|
|
||||||
it("fetches file info", async () => {
|
|
||||||
const json = vi.fn().mockResolvedValue({ ok: true, result: { file_path: "photos/1.jpg" } });
|
|
||||||
vi.spyOn(globalThis, "fetch" as never).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
json,
|
|
||||||
} as Response);
|
|
||||||
const info = await getTelegramFile("tok", "fid");
|
|
||||||
expect(info.file_path).toBe("photos/1.jpg");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("downloads and saves", async () => {
|
|
||||||
const info: TelegramFileInfo = {
|
|
||||||
file_id: "fid",
|
|
||||||
file_path: "photos/1.jpg",
|
|
||||||
};
|
|
||||||
const arrayBuffer = async () => new Uint8Array([1, 2, 3, 4]).buffer;
|
|
||||||
vi.spyOn(globalThis, "fetch" as never).mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
body: true,
|
|
||||||
arrayBuffer,
|
|
||||||
headers: { get: () => "image/jpeg" },
|
|
||||||
} as Response);
|
|
||||||
const saved = await downloadTelegramFile("tok", info, 1024 * 1024);
|
|
||||||
expect(saved.path).toBeTruthy();
|
|
||||||
expect(saved.contentType).toBe("image/jpeg");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
141
src/telegram/group-access.ts
Normal file
141
src/telegram/group-access.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
|
import type { ChannelGroupPolicy } from "../config/group-policy.js";
|
||||||
|
import type {
|
||||||
|
TelegramAccountConfig,
|
||||||
|
TelegramGroupConfig,
|
||||||
|
TelegramTopicConfig,
|
||||||
|
} from "../config/types.js";
|
||||||
|
import { isSenderAllowed, type NormalizedAllowFrom } from "./bot-access.js";
|
||||||
|
import { firstDefined } from "./bot-access.js";
|
||||||
|
|
||||||
|
export type TelegramGroupBaseBlockReason =
|
||||||
|
| "group-disabled"
|
||||||
|
| "topic-disabled"
|
||||||
|
| "group-override-unauthorized";
|
||||||
|
|
||||||
|
export type TelegramGroupBaseAccessResult =
|
||||||
|
| { allowed: true }
|
||||||
|
| { allowed: false; reason: TelegramGroupBaseBlockReason };
|
||||||
|
|
||||||
|
export const evaluateTelegramGroupBaseAccess = (params: {
|
||||||
|
isGroup: boolean;
|
||||||
|
groupConfig?: TelegramGroupConfig;
|
||||||
|
topicConfig?: TelegramTopicConfig;
|
||||||
|
hasGroupAllowOverride: boolean;
|
||||||
|
effectiveGroupAllow: NormalizedAllowFrom;
|
||||||
|
senderId?: string;
|
||||||
|
senderUsername?: string;
|
||||||
|
enforceAllowOverride: boolean;
|
||||||
|
requireSenderForAllowOverride: boolean;
|
||||||
|
}): TelegramGroupBaseAccessResult => {
|
||||||
|
if (!params.isGroup) {
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
if (params.groupConfig?.enabled === false) {
|
||||||
|
return { allowed: false, reason: "group-disabled" };
|
||||||
|
}
|
||||||
|
if (params.topicConfig?.enabled === false) {
|
||||||
|
return { allowed: false, reason: "topic-disabled" };
|
||||||
|
}
|
||||||
|
if (!params.enforceAllowOverride || !params.hasGroupAllowOverride) {
|
||||||
|
return { allowed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TelegramGroupPolicyBlockReason =
|
||||||
|
| "group-policy-disabled"
|
||||||
|
| "group-policy-allowlist-no-sender"
|
||||||
|
| "group-policy-allowlist-empty"
|
||||||
|
| "group-policy-allowlist-unauthorized"
|
||||||
|
| "group-chat-not-allowed";
|
||||||
|
|
||||||
|
export type TelegramGroupPolicyAccessResult =
|
||||||
|
| { allowed: true; groupPolicy: "open" | "disabled" | "allowlist" }
|
||||||
|
| {
|
||||||
|
allowed: false;
|
||||||
|
reason: TelegramGroupPolicyBlockReason;
|
||||||
|
groupPolicy: "open" | "disabled" | "allowlist";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const evaluateTelegramGroupPolicyAccess = (params: {
|
||||||
|
isGroup: boolean;
|
||||||
|
chatId: string | number;
|
||||||
|
cfg: OpenClawConfig;
|
||||||
|
telegramCfg: TelegramAccountConfig;
|
||||||
|
topicConfig?: TelegramTopicConfig;
|
||||||
|
groupConfig?: TelegramGroupConfig;
|
||||||
|
effectiveGroupAllow: NormalizedAllowFrom;
|
||||||
|
senderId?: string;
|
||||||
|
senderUsername?: string;
|
||||||
|
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||||
|
enforcePolicy: boolean;
|
||||||
|
useTopicAndGroupOverrides: boolean;
|
||||||
|
enforceAllowlistAuthorization: boolean;
|
||||||
|
allowEmptyAllowlistEntries: boolean;
|
||||||
|
requireSenderForAllowlistAuthorization: boolean;
|
||||||
|
checkChatAllowlist: boolean;
|
||||||
|
}): TelegramGroupPolicyAccessResult => {
|
||||||
|
const fallbackPolicy =
|
||||||
|
firstDefined(
|
||||||
|
params.telegramCfg.groupPolicy,
|
||||||
|
params.cfg.channels?.defaults?.groupPolicy,
|
||||||
|
"open",
|
||||||
|
) ?? "open";
|
||||||
|
const groupPolicy = params.useTopicAndGroupOverrides
|
||||||
|
? (firstDefined(
|
||||||
|
params.topicConfig?.groupPolicy,
|
||||||
|
params.groupConfig?.groupPolicy,
|
||||||
|
params.telegramCfg.groupPolicy,
|
||||||
|
params.cfg.channels?.defaults?.groupPolicy,
|
||||||
|
"open",
|
||||||
|
) ?? "open")
|
||||||
|
: fallbackPolicy;
|
||||||
|
|
||||||
|
if (!params.isGroup || !params.enforcePolicy) {
|
||||||
|
return { allowed: true, groupPolicy };
|
||||||
|
}
|
||||||
|
if (groupPolicy === "disabled") {
|
||||||
|
return { allowed: false, reason: "group-policy-disabled", groupPolicy };
|
||||||
|
}
|
||||||
|
if (groupPolicy === "allowlist" && params.enforceAllowlistAuthorization) {
|
||||||
|
const senderId = params.senderId ?? "";
|
||||||
|
if (params.requireSenderForAllowlistAuthorization && !senderId) {
|
||||||
|
return { allowed: false, reason: "group-policy-allowlist-no-sender", groupPolicy };
|
||||||
|
}
|
||||||
|
if (!params.allowEmptyAllowlistEntries && !params.effectiveGroupAllow.hasEntries) {
|
||||||
|
return { allowed: false, reason: "group-policy-allowlist-empty", groupPolicy };
|
||||||
|
}
|
||||||
|
const senderUsername = params.senderUsername ?? "";
|
||||||
|
if (
|
||||||
|
!isSenderAllowed({
|
||||||
|
allow: params.effectiveGroupAllow,
|
||||||
|
senderId,
|
||||||
|
senderUsername,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return { allowed: false, reason: "group-policy-allowlist-unauthorized", groupPolicy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.checkChatAllowlist) {
|
||||||
|
const groupAllowlist = params.resolveGroupPolicy(params.chatId);
|
||||||
|
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
|
||||||
|
return { allowed: false, reason: "group-chat-not-allowed", groupPolicy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { allowed: true, groupPolicy };
|
||||||
|
};
|
||||||
@@ -66,24 +66,19 @@ export function migrateTelegramGroupConfig(params: {
|
|||||||
let migrated = false;
|
let migrated = false;
|
||||||
let skippedExisting = false;
|
let skippedExisting = false;
|
||||||
|
|
||||||
const accountGroups = resolveAccountGroups(params.cfg, params.accountId).groups;
|
const migrationTargets: Array<{
|
||||||
if (accountGroups) {
|
scope: MigrationScope;
|
||||||
const result = migrateTelegramGroupsInPlace(accountGroups, params.oldChatId, params.newChatId);
|
groups: TelegramGroups | undefined;
|
||||||
if (result.migrated) {
|
}> = [
|
||||||
migrated = true;
|
{ scope: "account", groups: resolveAccountGroups(params.cfg, params.accountId).groups },
|
||||||
scopes.push("account");
|
{ scope: "global", groups: params.cfg.channels?.telegram?.groups },
|
||||||
}
|
];
|
||||||
if (result.skippedExisting) {
|
|
||||||
skippedExisting = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalGroups = params.cfg.channels?.telegram?.groups;
|
for (const target of migrationTargets) {
|
||||||
if (globalGroups) {
|
const result = migrateTelegramGroupsInPlace(target.groups, params.oldChatId, params.newChatId);
|
||||||
const result = migrateTelegramGroupsInPlace(globalGroups, params.oldChatId, params.newChatId);
|
|
||||||
if (result.migrated) {
|
if (result.migrated) {
|
||||||
migrated = true;
|
migrated = true;
|
||||||
scopes.push("global");
|
scopes.push(target.scope);
|
||||||
}
|
}
|
||||||
if (result.skippedExisting) {
|
if (result.skippedExisting) {
|
||||||
skippedExisting = true;
|
skippedExisting = true;
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js";
|
|
||||||
export { monitorTelegramProvider } from "./monitor.js";
|
|
||||||
export { reactMessageTelegram, sendMessageTelegram, sendPollTelegram } from "./send.js";
|
|
||||||
export { startTelegramWebhook } from "./webhook.js";
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { OpenClawConfig } from "../config/config.js";
|
import type { OpenClawConfig } from "../config/config.js";
|
||||||
import type { TelegramInlineButtonsScope } from "../config/types.telegram.js";
|
import type { TelegramInlineButtonsScope } from "../config/types.telegram.js";
|
||||||
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
|
import { listTelegramAccountIds, resolveTelegramAccount } from "./accounts.js";
|
||||||
import { parseTelegramTarget } from "./targets.js";
|
|
||||||
|
|
||||||
const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist";
|
const DEFAULT_INLINE_BUTTONS_SCOPE: TelegramInlineButtonsScope = "allowlist";
|
||||||
|
|
||||||
@@ -65,17 +64,4 @@ export function isTelegramInlineButtonsEnabled(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveTelegramTargetChatType(target: string): "direct" | "group" | "unknown" {
|
export { resolveTelegramTargetChatType } from "./targets.js";
|
||||||
if (!target.trim()) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
const parsed = parseTelegramTarget(target);
|
|
||||||
const chatId = parsed.chatId.trim();
|
|
||||||
if (!chatId) {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
if (/^-?\d+$/.test(chatId)) {
|
|
||||||
return chatId.startsWith("-") ? "group" : "direct";
|
|
||||||
}
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,14 @@ describe("parseTelegramTarget", () => {
|
|||||||
it("parses plain chatId", () => {
|
it("parses plain chatId", () => {
|
||||||
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
expect(parseTelegramTarget("-1001234567890")).toEqual({
|
||||||
chatId: "-1001234567890",
|
chatId: "-1001234567890",
|
||||||
|
chatType: "group",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses @username", () => {
|
it("parses @username", () => {
|
||||||
expect(parseTelegramTarget("@mychannel")).toEqual({
|
expect(parseTelegramTarget("@mychannel")).toEqual({
|
||||||
chatId: "@mychannel",
|
chatId: "@mychannel",
|
||||||
|
chatType: "unknown",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ describe("parseTelegramTarget", () => {
|
|||||||
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
expect(parseTelegramTarget("-1001234567890:123")).toEqual({
|
||||||
chatId: "-1001234567890",
|
chatId: "-1001234567890",
|
||||||
messageThreadId: 123,
|
messageThreadId: 123,
|
||||||
|
chatType: "group",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +46,7 @@ describe("parseTelegramTarget", () => {
|
|||||||
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
expect(parseTelegramTarget("-1001234567890:topic:456")).toEqual({
|
||||||
chatId: "-1001234567890",
|
chatId: "-1001234567890",
|
||||||
messageThreadId: 456,
|
messageThreadId: 456,
|
||||||
|
chatType: "group",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -50,12 +54,14 @@ describe("parseTelegramTarget", () => {
|
|||||||
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
expect(parseTelegramTarget(" -1001234567890:99 ")).toEqual({
|
||||||
chatId: "-1001234567890",
|
chatId: "-1001234567890",
|
||||||
messageThreadId: 99,
|
messageThreadId: 99,
|
||||||
|
chatType: "group",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not treat non-numeric suffix as topicId", () => {
|
it("does not treat non-numeric suffix as topicId", () => {
|
||||||
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
expect(parseTelegramTarget("-1001234567890:abc")).toEqual({
|
||||||
chatId: "-1001234567890:abc",
|
chatId: "-1001234567890:abc",
|
||||||
|
chatType: "unknown",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +69,7 @@ describe("parseTelegramTarget", () => {
|
|||||||
expect(parseTelegramTarget("telegram:group:-1001234567890:topic:456")).toEqual({
|
expect(parseTelegramTarget("telegram:group:-1001234567890:topic:456")).toEqual({
|
||||||
chatId: "-1001234567890",
|
chatId: "-1001234567890",
|
||||||
messageThreadId: 456,
|
messageThreadId: 456,
|
||||||
|
chatType: "group",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type TelegramTarget = {
|
export type TelegramTarget = {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
messageThreadId?: number;
|
messageThreadId?: number;
|
||||||
|
chatType: "direct" | "group" | "unknown";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function stripTelegramInternalPrefixes(to: string): string {
|
export function stripTelegramInternalPrefixes(to: string): string {
|
||||||
@@ -33,6 +34,17 @@ export function stripTelegramInternalPrefixes(to: string): string {
|
|||||||
* - `chatId:topicId` (numeric topic/thread ID)
|
* - `chatId:topicId` (numeric topic/thread ID)
|
||||||
* - `chatId:topic:topicId` (explicit topic marker; preferred)
|
* - `chatId:topic:topicId` (explicit topic marker; preferred)
|
||||||
*/
|
*/
|
||||||
|
function resolveTelegramChatType(chatId: string): "direct" | "group" | "unknown" {
|
||||||
|
const trimmed = chatId.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
if (/^-?\d+$/.test(trimmed)) {
|
||||||
|
return trimmed.startsWith("-") ? "group" : "direct";
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
export function parseTelegramTarget(to: string): TelegramTarget {
|
export function parseTelegramTarget(to: string): TelegramTarget {
|
||||||
const normalized = stripTelegramInternalPrefixes(to);
|
const normalized = stripTelegramInternalPrefixes(to);
|
||||||
|
|
||||||
@@ -41,6 +53,7 @@ export function parseTelegramTarget(to: string): TelegramTarget {
|
|||||||
return {
|
return {
|
||||||
chatId: topicMatch[1],
|
chatId: topicMatch[1],
|
||||||
messageThreadId: Number.parseInt(topicMatch[2], 10),
|
messageThreadId: Number.parseInt(topicMatch[2], 10),
|
||||||
|
chatType: resolveTelegramChatType(topicMatch[1]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +62,16 @@ export function parseTelegramTarget(to: string): TelegramTarget {
|
|||||||
return {
|
return {
|
||||||
chatId: colonMatch[1],
|
chatId: colonMatch[1],
|
||||||
messageThreadId: Number.parseInt(colonMatch[2], 10),
|
messageThreadId: Number.parseInt(colonMatch[2], 10),
|
||||||
|
chatType: resolveTelegramChatType(colonMatch[1]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { chatId: normalized };
|
return {
|
||||||
|
chatId: normalized,
|
||||||
|
chatType: resolveTelegramChatType(normalized),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTelegramTargetChatType(target: string): "direct" | "group" | "unknown" {
|
||||||
|
return parseTelegramTarget(target).chatType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { type ApiClientOptions, Bot } from "grammy";
|
|
||||||
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
|
|
||||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
|
||||||
|
|
||||||
export async function setTelegramWebhook(opts: {
|
|
||||||
token: string;
|
|
||||||
url: string;
|
|
||||||
secret?: string;
|
|
||||||
dropPendingUpdates?: boolean;
|
|
||||||
network?: TelegramNetworkConfig;
|
|
||||||
}) {
|
|
||||||
const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
|
|
||||||
const client: ApiClientOptions | undefined = fetchImpl
|
|
||||||
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
|
||||||
: undefined;
|
|
||||||
const bot = new Bot(opts.token, client ? { client } : undefined);
|
|
||||||
await withTelegramApiErrorLogging({
|
|
||||||
operation: "setWebhook",
|
|
||||||
fn: () =>
|
|
||||||
bot.api.setWebhook(opts.url, {
|
|
||||||
secret_token: opts.secret,
|
|
||||||
drop_pending_updates: opts.dropPendingUpdates ?? false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTelegramWebhook(opts: {
|
|
||||||
token: string;
|
|
||||||
network?: TelegramNetworkConfig;
|
|
||||||
}) {
|
|
||||||
const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network });
|
|
||||||
const client: ApiClientOptions | undefined = fetchImpl
|
|
||||||
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
|
|
||||||
: undefined;
|
|
||||||
const bot = new Bot(opts.token, client ? { client } : undefined);
|
|
||||||
await withTelegramApiErrorLogging({
|
|
||||||
operation: "deleteWebhook",
|
|
||||||
fn: () => bot.api.deleteWebhook(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user