mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 11:51:22 +00:00
1821 lines
61 KiB
TypeScript
1821 lines
61 KiB
TypeScript
import type { Message, ReactionTypeEmoji } from "@grammyjs/types";
|
|
import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
|
|
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
|
|
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-writes";
|
|
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound";
|
|
import {
|
|
createInboundDebouncer,
|
|
resolveInboundDebounceMs,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import {
|
|
buildCommandsMessagePaginated,
|
|
buildCommandsPaginationKeyboard,
|
|
formatModelsAvailableHeader,
|
|
resolveStoredModelOverride,
|
|
} from "openclaw/plugin-sdk/command-auth";
|
|
import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
|
|
import {
|
|
loadSessionStore,
|
|
resolveSessionStoreEntry,
|
|
updateSessionStore,
|
|
} from "openclaw/plugin-sdk/config-runtime";
|
|
import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
|
|
import type {
|
|
TelegramDirectConfig,
|
|
TelegramGroupConfig,
|
|
TelegramTopicConfig,
|
|
} from "openclaw/plugin-sdk/config-runtime";
|
|
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime";
|
|
import {
|
|
buildPluginBindingResolvedText,
|
|
parsePluginBindingApprovalCustomId,
|
|
resolvePluginConversationBindingApproval,
|
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
|
|
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
|
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
|
|
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
|
import {
|
|
isSenderAllowed,
|
|
normalizeDmAllowFromWithStore,
|
|
type NormalizedAllowFrom,
|
|
} from "./bot-access.js";
|
|
import { defaultTelegramBotDeps } from "./bot-deps.js";
|
|
import {
|
|
APPROVE_CALLBACK_DATA_RE,
|
|
hasInboundMedia,
|
|
hasReplyTargetMedia,
|
|
isMediaSizeLimitError,
|
|
isRecoverableMediaGroupError,
|
|
resolveInboundMediaFileId,
|
|
} from "./bot-handlers.media.js";
|
|
import type { TelegramMediaRef } from "./bot-message-context.js";
|
|
import { RegisterTelegramHandlerParams } from "./bot-native-commands.js";
|
|
import {
|
|
MEDIA_GROUP_TIMEOUT_MS,
|
|
type MediaGroupEntry,
|
|
type TelegramUpdateKeyContext,
|
|
} from "./bot-updates.js";
|
|
import { resolveMedia } from "./bot/delivery.js";
|
|
import {
|
|
getTelegramTextParts,
|
|
buildTelegramGroupPeerId,
|
|
buildTelegramParentPeer,
|
|
resolveTelegramForumFlag,
|
|
resolveTelegramForumThreadId,
|
|
resolveTelegramGroupAllowFromContext,
|
|
withResolvedTelegramForumFlag,
|
|
} from "./bot/helpers.js";
|
|
import type { TelegramContext } from "./bot/types.js";
|
|
import {
|
|
resolveTelegramConversationBaseSessionKey,
|
|
resolveTelegramConversationRoute,
|
|
} from "./conversation-route.js";
|
|
import { enforceTelegramDmAccess } from "./dm-access.js";
|
|
import {
|
|
isTelegramExecApprovalApprover,
|
|
isTelegramExecApprovalClientEnabled,
|
|
shouldEnableTelegramExecApprovalButtons,
|
|
} from "./exec-approvals.js";
|
|
import {
|
|
evaluateTelegramGroupBaseAccess,
|
|
evaluateTelegramGroupPolicyAccess,
|
|
} from "./group-access.js";
|
|
import { migrateTelegramGroupConfig } from "./group-migration.js";
|
|
import { resolveTelegramInlineButtonsScope } from "./inline-buttons.js";
|
|
import {
|
|
buildModelsKeyboard,
|
|
buildProviderKeyboard,
|
|
calculateTotalPages,
|
|
getModelsPageSize,
|
|
parseModelCallbackData,
|
|
resolveModelSelection,
|
|
type ProviderInfo,
|
|
} from "./model-buttons.js";
|
|
import { buildInlineKeyboard } from "./send.js";
|
|
|
|
export const registerTelegramHandlers = ({
|
|
cfg,
|
|
accountId,
|
|
bot,
|
|
opts,
|
|
telegramTransport,
|
|
runtime,
|
|
mediaMaxBytes,
|
|
telegramCfg,
|
|
allowFrom,
|
|
groupAllowFrom,
|
|
resolveGroupPolicy,
|
|
resolveTelegramGroupConfig,
|
|
shouldSkipUpdate,
|
|
processMessage,
|
|
logger,
|
|
telegramDeps = defaultTelegramBotDeps,
|
|
}: RegisterTelegramHandlerParams) => {
|
|
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
|
|
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
|
|
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS =
|
|
typeof opts.testTimings?.textFragmentGapMs === "number" &&
|
|
Number.isFinite(opts.testTimings.textFragmentGapMs)
|
|
? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs))
|
|
: DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS;
|
|
const TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP = 1;
|
|
const TELEGRAM_TEXT_FRAGMENT_MAX_PARTS = 12;
|
|
const TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS = 50_000;
|
|
const mediaGroupTimeoutMs =
|
|
typeof opts.testTimings?.mediaGroupFlushMs === "number" &&
|
|
Number.isFinite(opts.testTimings.mediaGroupFlushMs)
|
|
? Math.max(10, Math.floor(opts.testTimings.mediaGroupFlushMs))
|
|
: MEDIA_GROUP_TIMEOUT_MS;
|
|
|
|
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
|
|
let mediaGroupProcessing: Promise<void> = Promise.resolve();
|
|
|
|
type TextFragmentEntry = {
|
|
key: string;
|
|
messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
};
|
|
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
|
|
let textFragmentProcessing: Promise<void> = Promise.resolve();
|
|
|
|
const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" });
|
|
const FORWARD_BURST_DEBOUNCE_MS = 80;
|
|
type TelegramDebounceLane = "default" | "forward";
|
|
type TelegramDebounceEntry = {
|
|
ctx: TelegramContext;
|
|
msg: Message;
|
|
allMedia: TelegramMediaRef[];
|
|
storeAllowFrom: string[];
|
|
receivedAtMs: number;
|
|
debounceKey: string | null;
|
|
debounceLane: TelegramDebounceLane;
|
|
botUsername?: string;
|
|
};
|
|
const resolveTelegramDebounceLane = (msg: Message): TelegramDebounceLane => {
|
|
const forwardMeta = msg as {
|
|
forward_origin?: unknown;
|
|
forward_from?: unknown;
|
|
forward_from_chat?: unknown;
|
|
forward_sender_name?: unknown;
|
|
forward_date?: unknown;
|
|
};
|
|
return (forwardMeta.forward_origin ??
|
|
forwardMeta.forward_from ??
|
|
forwardMeta.forward_from_chat ??
|
|
forwardMeta.forward_sender_name ??
|
|
forwardMeta.forward_date)
|
|
? "forward"
|
|
: "default";
|
|
};
|
|
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 isSelfAuthoredTelegramMessage = (
|
|
ctx: Pick<TelegramContext, "me">,
|
|
message: Message,
|
|
): boolean => {
|
|
const botId = ctx.me?.id;
|
|
return typeof botId === "number" && message.from?.id === botId;
|
|
};
|
|
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
|
|
debounceMs,
|
|
resolveDebounceMs: (entry) =>
|
|
entry.debounceLane === "forward" ? FORWARD_BURST_DEBOUNCE_MS : debounceMs,
|
|
buildKey: (entry) => entry.debounceKey,
|
|
shouldDebounce: (entry) => {
|
|
const text = entry.msg.text ?? entry.msg.caption ?? "";
|
|
const hasDebounceableText = shouldDebounceTextInbound({
|
|
text,
|
|
cfg,
|
|
commandOptions: { botUsername: entry.botUsername },
|
|
});
|
|
if (entry.debounceLane === "forward") {
|
|
// Forwarded bursts often split text + media into adjacent updates.
|
|
// Debounce media-only forward entries too so they can coalesce.
|
|
return hasDebounceableText || entry.allMedia.length > 0;
|
|
}
|
|
if (!hasDebounceableText) {
|
|
return false;
|
|
}
|
|
return entry.allMedia.length === 0;
|
|
},
|
|
onFlush: async (entries) => {
|
|
const last = entries.at(-1);
|
|
if (!last) {
|
|
return;
|
|
}
|
|
if (entries.length === 1) {
|
|
const replyMedia = await resolveReplyMediaForMessage(last.ctx, last.msg);
|
|
await processMessage(
|
|
last.ctx,
|
|
last.allMedia,
|
|
last.storeAllowFrom,
|
|
{
|
|
receivedAtMs: last.receivedAtMs,
|
|
ingressBuffer: "inbound-debounce",
|
|
},
|
|
replyMedia,
|
|
);
|
|
return;
|
|
}
|
|
const combinedText = entries
|
|
.map((entry) => entry.msg.text ?? entry.msg.caption ?? "")
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const combinedMedia = entries.flatMap((entry) => entry.allMedia);
|
|
if (!combinedText.trim() && combinedMedia.length === 0) {
|
|
return;
|
|
}
|
|
const first = entries[0];
|
|
const baseCtx = first.ctx;
|
|
const syntheticMessage = buildSyntheticTextMessage({
|
|
base: first.msg,
|
|
text: combinedText,
|
|
date: last.msg.date ?? first.msg.date,
|
|
});
|
|
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
|
|
const syntheticCtx = buildSyntheticContext(baseCtx, syntheticMessage);
|
|
const replyMedia = await resolveReplyMediaForMessage(baseCtx, syntheticMessage);
|
|
await processMessage(
|
|
syntheticCtx,
|
|
combinedMedia,
|
|
first.storeAllowFrom,
|
|
{
|
|
...(messageIdOverride ? { messageIdOverride } : {}),
|
|
receivedAtMs: first.receivedAtMs,
|
|
ingressBuffer: "inbound-debounce",
|
|
},
|
|
replyMedia,
|
|
);
|
|
},
|
|
onError: (err, items) => {
|
|
runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`));
|
|
const chatId = items[0]?.msg.chat.id;
|
|
if (chatId != null) {
|
|
const threadId = items[0]?.msg.message_thread_id;
|
|
void bot.api
|
|
.sendMessage(
|
|
chatId,
|
|
"Something went wrong while processing your message. Please try again.",
|
|
threadId != null ? { message_thread_id: threadId } : undefined,
|
|
)
|
|
.catch((sendErr) => {
|
|
logVerbose(`telegram: error fallback send failed: ${String(sendErr)}`);
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
const resolveTelegramSessionState = (params: {
|
|
chatId: number | string;
|
|
isGroup: boolean;
|
|
isForum: boolean;
|
|
messageThreadId?: number;
|
|
resolvedThreadId?: number;
|
|
senderId?: string | number;
|
|
}): {
|
|
agentId: string;
|
|
sessionEntry: ReturnType<typeof loadSessionStore>[string] | undefined;
|
|
sessionKey: string;
|
|
model?: string;
|
|
} => {
|
|
const runtimeCfg = telegramDeps.loadConfig();
|
|
const resolvedThreadId =
|
|
params.resolvedThreadId ??
|
|
resolveTelegramForumThreadId({
|
|
isForum: params.isForum,
|
|
messageThreadId: params.messageThreadId,
|
|
});
|
|
const dmThreadId = !params.isGroup ? params.messageThreadId : undefined;
|
|
const topicThreadId = resolvedThreadId ?? dmThreadId;
|
|
const { topicConfig } = resolveTelegramGroupConfig(params.chatId, topicThreadId);
|
|
const { route } = resolveTelegramConversationRoute({
|
|
cfg: runtimeCfg,
|
|
accountId,
|
|
chatId: params.chatId,
|
|
isGroup: params.isGroup,
|
|
resolvedThreadId,
|
|
replyThreadId: topicThreadId,
|
|
senderId: params.senderId,
|
|
topicAgentId: topicConfig?.agentId,
|
|
});
|
|
const baseSessionKey = resolveTelegramConversationBaseSessionKey({
|
|
cfg: runtimeCfg,
|
|
route,
|
|
chatId: params.chatId,
|
|
isGroup: params.isGroup,
|
|
senderId: params.senderId,
|
|
});
|
|
const threadKeys =
|
|
dmThreadId != null
|
|
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` })
|
|
: null;
|
|
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
|
const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, {
|
|
agentId: route.agentId,
|
|
});
|
|
const store = loadSessionStore(storePath);
|
|
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
|
const storedOverride = resolveStoredModelOverride({
|
|
sessionEntry: entry,
|
|
sessionStore: store,
|
|
sessionKey,
|
|
});
|
|
if (storedOverride) {
|
|
return {
|
|
agentId: route.agentId,
|
|
sessionEntry: entry,
|
|
sessionKey,
|
|
model: storedOverride.provider
|
|
? `${storedOverride.provider}/${storedOverride.model}`
|
|
: storedOverride.model,
|
|
};
|
|
}
|
|
const provider = entry?.modelProvider?.trim();
|
|
const model = entry?.model?.trim();
|
|
if (provider && model) {
|
|
return {
|
|
agentId: route.agentId,
|
|
sessionEntry: entry,
|
|
sessionKey,
|
|
model: `${provider}/${model}`,
|
|
};
|
|
}
|
|
const modelCfg = runtimeCfg.agents?.defaults?.model;
|
|
return {
|
|
agentId: route.agentId,
|
|
sessionEntry: entry,
|
|
sessionKey,
|
|
model: typeof modelCfg === "string" ? modelCfg : modelCfg?.primary,
|
|
};
|
|
};
|
|
|
|
const processMediaGroup = async (entry: MediaGroupEntry) => {
|
|
try {
|
|
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
|
|
|
|
const captionMsg = entry.messages.find((m) => m.msg.caption || m.msg.text);
|
|
const primaryEntry = captionMsg ?? entry.messages[0];
|
|
|
|
const allMedia: TelegramMediaRef[] = [];
|
|
for (const { ctx } of entry.messages) {
|
|
let media;
|
|
try {
|
|
media = await resolveMedia(
|
|
ctx,
|
|
mediaMaxBytes,
|
|
opts.token,
|
|
telegramTransport,
|
|
telegramCfg.apiRoot,
|
|
);
|
|
} catch (mediaErr) {
|
|
if (!isRecoverableMediaGroupError(mediaErr)) {
|
|
throw mediaErr;
|
|
}
|
|
runtime.log?.(
|
|
warn(`media group: skipping photo that failed to fetch: ${String(mediaErr)}`),
|
|
);
|
|
continue;
|
|
}
|
|
if (media) {
|
|
allMedia.push({
|
|
path: media.path,
|
|
contentType: media.contentType,
|
|
stickerMetadata: media.stickerMetadata,
|
|
});
|
|
}
|
|
}
|
|
|
|
const storeAllowFrom = await loadStoreAllowFrom();
|
|
const replyMedia = await resolveReplyMediaForMessage(primaryEntry.ctx, primaryEntry.msg);
|
|
await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom, undefined, replyMedia);
|
|
} catch (err) {
|
|
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
|
|
}
|
|
};
|
|
|
|
const flushTextFragments = async (entry: TextFragmentEntry) => {
|
|
try {
|
|
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
|
|
|
|
const first = entry.messages[0];
|
|
const last = entry.messages.at(-1);
|
|
if (!first || !last) {
|
|
return;
|
|
}
|
|
|
|
const combinedText = entry.messages.map((m) => m.msg.text ?? "").join("");
|
|
if (!combinedText.trim()) {
|
|
return;
|
|
}
|
|
|
|
const syntheticMessage = buildSyntheticTextMessage({
|
|
base: first.msg,
|
|
text: combinedText,
|
|
date: last.msg.date ?? first.msg.date,
|
|
});
|
|
|
|
const storeAllowFrom = await loadStoreAllowFrom();
|
|
const baseCtx = first.ctx;
|
|
|
|
await processMessage(buildSyntheticContext(baseCtx, syntheticMessage), [], storeAllowFrom, {
|
|
messageIdOverride: String(last.msg.message_id),
|
|
receivedAtMs: first.receivedAtMs,
|
|
ingressBuffer: "text-fragment",
|
|
});
|
|
} catch (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) => {
|
|
clearTimeout(entry.timer);
|
|
entry.timer = setTimeout(async () => {
|
|
await runTextFragmentFlush(entry);
|
|
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
|
|
};
|
|
|
|
const loadStoreAllowFrom = async () =>
|
|
telegramDeps.readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []);
|
|
|
|
const resolveReplyMediaForMessage = async (
|
|
ctx: TelegramContext,
|
|
msg: Message,
|
|
): Promise<TelegramMediaRef[]> => {
|
|
const replyMessage = msg.reply_to_message;
|
|
if (!replyMessage || !hasInboundMedia(replyMessage)) {
|
|
return [];
|
|
}
|
|
const replyFileId = resolveInboundMediaFileId(replyMessage);
|
|
if (!replyFileId) {
|
|
return [];
|
|
}
|
|
try {
|
|
const media = await resolveMedia(
|
|
{
|
|
message: replyMessage,
|
|
me: ctx.me,
|
|
getFile: async () => await bot.api.getFile(replyFileId),
|
|
},
|
|
mediaMaxBytes,
|
|
opts.token,
|
|
telegramTransport,
|
|
telegramCfg.apiRoot,
|
|
);
|
|
if (!media) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
path: media.path,
|
|
contentType: media.contentType,
|
|
stickerMetadata: media.stickerMetadata,
|
|
},
|
|
];
|
|
} catch (err) {
|
|
logger.warn({ chatId: msg.chat.id, error: String(err) }, "reply media fetch failed");
|
|
return [];
|
|
}
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
type TelegramGroupAllowContext = Awaited<ReturnType<typeof resolveTelegramGroupAllowFromContext>>;
|
|
type TelegramEventAuthorizationMode = "reaction" | "callback-scope" | "callback-allowlist";
|
|
type TelegramEventAuthorizationResult = { allowed: true } | { allowed: false; reason: string };
|
|
type TelegramEventAuthorizationContext = TelegramGroupAllowContext & { dmPolicy: DmPolicy };
|
|
const getChat =
|
|
typeof (bot.api as { getChat?: unknown }).getChat === "function"
|
|
? ((bot.api as { getChat: (chatId: string | number) => Promise<unknown> }).getChat.bind(
|
|
bot.api,
|
|
) as (chatId: string | number) => Promise<unknown>)
|
|
: undefined;
|
|
|
|
const TELEGRAM_EVENT_AUTH_RULES: Record<
|
|
TelegramEventAuthorizationMode,
|
|
{
|
|
enforceDirectAuthorization: boolean;
|
|
enforceGroupAllowlistAuthorization: boolean;
|
|
deniedDmReason: string;
|
|
deniedGroupReason: string;
|
|
}
|
|
> = {
|
|
reaction: {
|
|
enforceDirectAuthorization: true,
|
|
enforceGroupAllowlistAuthorization: false,
|
|
deniedDmReason: "reaction unauthorized by dm policy/allowlist",
|
|
deniedGroupReason: "reaction unauthorized by group allowlist",
|
|
},
|
|
"callback-scope": {
|
|
enforceDirectAuthorization: false,
|
|
enforceGroupAllowlistAuthorization: false,
|
|
deniedDmReason: "callback unauthorized by inlineButtonsScope",
|
|
deniedGroupReason: "callback unauthorized by inlineButtonsScope",
|
|
},
|
|
"callback-allowlist": {
|
|
enforceDirectAuthorization: true,
|
|
// Group auth is already enforced by shouldSkipGroupMessage (group policy + allowlist).
|
|
// An extra allowlist gate here would block users whose original command was authorized.
|
|
enforceGroupAllowlistAuthorization: false,
|
|
deniedDmReason: "callback unauthorized by inlineButtonsScope allowlist",
|
|
deniedGroupReason: "callback unauthorized by inlineButtonsScope allowlist",
|
|
},
|
|
};
|
|
|
|
const resolveTelegramEventAuthorizationContext = async (params: {
|
|
chatId: number;
|
|
isGroup: boolean;
|
|
isForum: boolean;
|
|
messageThreadId?: number;
|
|
groupAllowContext?: TelegramGroupAllowContext;
|
|
}): Promise<TelegramEventAuthorizationContext> => {
|
|
const groupAllowContext =
|
|
params.groupAllowContext ??
|
|
(await resolveTelegramGroupAllowFromContext({
|
|
chatId: params.chatId,
|
|
accountId,
|
|
isGroup: params.isGroup,
|
|
isForum: params.isForum,
|
|
messageThreadId: params.messageThreadId,
|
|
groupAllowFrom,
|
|
readChannelAllowFromStore: telegramDeps.readChannelAllowFromStore,
|
|
resolveTelegramGroupConfig,
|
|
}));
|
|
// 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: {
|
|
chatId: number;
|
|
chatTitle?: string;
|
|
isGroup: boolean;
|
|
senderId: string;
|
|
senderUsername: string;
|
|
mode: TelegramEventAuthorizationMode;
|
|
context: TelegramEventAuthorizationContext;
|
|
}): TelegramEventAuthorizationResult => {
|
|
const { chatId, chatTitle, isGroup, senderId, senderUsername, mode, context } = params;
|
|
const {
|
|
dmPolicy,
|
|
resolvedThreadId,
|
|
storeAllowFrom,
|
|
groupConfig,
|
|
topicConfig,
|
|
groupAllowOverride,
|
|
effectiveGroupAllow,
|
|
hasGroupAllowOverride,
|
|
} = context;
|
|
const authRules = TELEGRAM_EVENT_AUTH_RULES[mode];
|
|
const {
|
|
enforceDirectAuthorization,
|
|
enforceGroupAllowlistAuthorization,
|
|
deniedDmReason,
|
|
deniedGroupReason,
|
|
} = authRules;
|
|
if (
|
|
shouldSkipGroupMessage({
|
|
isGroup,
|
|
chatId,
|
|
chatTitle,
|
|
resolvedThreadId,
|
|
senderId,
|
|
senderUsername,
|
|
effectiveGroupAllow,
|
|
hasGroupAllowOverride,
|
|
groupConfig,
|
|
topicConfig,
|
|
})
|
|
) {
|
|
return { allowed: false, reason: "group-policy" };
|
|
}
|
|
|
|
if (!isGroup && enforceDirectAuthorization) {
|
|
if (dmPolicy === "disabled") {
|
|
logVerbose(
|
|
`Blocked telegram direct event from ${senderId || "unknown"} (${deniedDmReason})`,
|
|
);
|
|
return { allowed: false, reason: "direct-disabled" };
|
|
}
|
|
if (dmPolicy !== "open") {
|
|
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
|
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
|
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
|
allowFrom: dmAllowFrom,
|
|
storeAllowFrom,
|
|
dmPolicy,
|
|
});
|
|
if (!isAllowlistAuthorized(effectiveDmAllow, senderId, senderUsername)) {
|
|
logVerbose(`Blocked telegram direct sender ${senderId || "unknown"} (${deniedDmReason})`);
|
|
return { allowed: false, reason: "direct-unauthorized" };
|
|
}
|
|
}
|
|
}
|
|
if (isGroup && enforceGroupAllowlistAuthorization) {
|
|
if (!isAllowlistAuthorized(effectiveGroupAllow, senderId, senderUsername)) {
|
|
logVerbose(`Blocked telegram group sender ${senderId || "unknown"} (${deniedGroupReason})`);
|
|
return { allowed: false, reason: "group-unauthorized" };
|
|
}
|
|
}
|
|
return { allowed: true };
|
|
};
|
|
|
|
// Handle emoji reactions to messages.
|
|
bot.on("message_reaction", async (ctx) => {
|
|
try {
|
|
const reaction = ctx.messageReaction;
|
|
if (!reaction) {
|
|
return;
|
|
}
|
|
if (shouldSkipUpdate(ctx)) {
|
|
return;
|
|
}
|
|
|
|
const chatId = reaction.chat.id;
|
|
const messageId = reaction.message_id;
|
|
const user = reaction.user;
|
|
const senderId = user?.id != null ? String(user.id) : "";
|
|
const senderUsername = user?.username ?? "";
|
|
const isGroup = reaction.chat.type === "group" || reaction.chat.type === "supergroup";
|
|
const isForum = reaction.chat.is_forum === true;
|
|
|
|
// Resolve reaction notification mode (default: "own").
|
|
const reactionMode = telegramCfg.reactionNotifications ?? "own";
|
|
if (reactionMode === "off") {
|
|
return;
|
|
}
|
|
if (user?.is_bot) {
|
|
return;
|
|
}
|
|
if (reactionMode === "own" && !telegramDeps.wasSentByBot(chatId, messageId)) {
|
|
return;
|
|
}
|
|
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
|
chatId,
|
|
isGroup,
|
|
isForum,
|
|
});
|
|
const senderAuthorization = authorizeTelegramEventSender({
|
|
chatId,
|
|
chatTitle: reaction.chat.title,
|
|
isGroup,
|
|
senderId,
|
|
senderUsername,
|
|
mode: "reaction",
|
|
context: eventAuthContext,
|
|
});
|
|
if (!senderAuthorization.allowed) {
|
|
return;
|
|
}
|
|
|
|
// Enforce requireTopic for DM reactions: since Telegram doesn't provide messageThreadId
|
|
// for reactions, we cannot determine if the reaction came from a topic, so block all
|
|
// reactions if requireTopic is enabled for this DM.
|
|
if (!isGroup) {
|
|
const requireTopic = (eventAuthContext.groupConfig as TelegramDirectConfig | undefined)
|
|
?.requireTopic;
|
|
if (requireTopic === true) {
|
|
logVerbose(
|
|
`Blocked telegram reaction in DM ${chatId}: requireTopic=true but topic unknown for reactions`,
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Detect added reactions.
|
|
const oldEmojis = new Set(
|
|
reaction.old_reaction
|
|
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
|
|
.map((r) => r.emoji),
|
|
);
|
|
const addedReactions = reaction.new_reaction
|
|
.filter((r): r is ReactionTypeEmoji => r.type === "emoji")
|
|
.filter((r) => !oldEmojis.has(r.emoji));
|
|
|
|
if (addedReactions.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Build sender label.
|
|
const senderName = user
|
|
? [user.first_name, user.last_name].filter(Boolean).join(" ").trim() || user.username
|
|
: undefined;
|
|
const senderUsernameLabel = user?.username ? `@${user.username}` : undefined;
|
|
let senderLabel = senderName;
|
|
if (senderName && senderUsernameLabel) {
|
|
senderLabel = `${senderName} (${senderUsernameLabel})`;
|
|
} else if (!senderName && senderUsernameLabel) {
|
|
senderLabel = senderUsernameLabel;
|
|
}
|
|
if (!senderLabel && user?.id) {
|
|
senderLabel = `id:${user.id}`;
|
|
}
|
|
senderLabel = senderLabel || "unknown";
|
|
|
|
// Reactions target a specific message_id; the Telegram Bot API does not include
|
|
// message_thread_id on MessageReactionUpdated, so we route to the chat-level
|
|
// session (forum topic routing is not available for reactions).
|
|
const resolvedThreadId = isForum
|
|
? resolveTelegramForumThreadId({ isForum, messageThreadId: undefined })
|
|
: undefined;
|
|
const peerId = isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId);
|
|
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
|
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
|
const route = resolveAgentRoute({
|
|
cfg: telegramDeps.loadConfig(),
|
|
channel: "telegram",
|
|
accountId,
|
|
peer: { kind: isGroup ? "group" : "direct", id: peerId },
|
|
parentPeer,
|
|
});
|
|
const sessionKey = route.sessionKey;
|
|
|
|
// Enqueue system event for each added reaction.
|
|
for (const r of addedReactions) {
|
|
const emoji = r.emoji;
|
|
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
|
|
telegramDeps.enqueueSystemEvent(text, {
|
|
sessionKey,
|
|
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
|
|
});
|
|
logVerbose(`telegram: reaction event enqueued: ${text}`);
|
|
}
|
|
} catch (err) {
|
|
runtime.error?.(danger(`telegram reaction handler failed: ${String(err)}`));
|
|
}
|
|
});
|
|
const processInboundMessage = async (params: {
|
|
ctx: TelegramContext;
|
|
msg: Message;
|
|
chatId: number;
|
|
resolvedThreadId?: number;
|
|
dmThreadId?: number;
|
|
storeAllowFrom: string[];
|
|
sendOversizeWarning: boolean;
|
|
oversizeLogMessage: string;
|
|
}) => {
|
|
const {
|
|
ctx,
|
|
msg,
|
|
chatId,
|
|
resolvedThreadId,
|
|
dmThreadId,
|
|
storeAllowFrom,
|
|
sendOversizeWarning,
|
|
oversizeLogMessage,
|
|
} = params;
|
|
|
|
// Text fragment handling - Telegram splits long pastes into multiple inbound messages (~4096 chars).
|
|
// We buffer “near-limit” messages and append immediately-following parts.
|
|
const text = typeof msg.text === "string" ? msg.text : undefined;
|
|
const isCommandLike = (text ?? "").trim().startsWith("/");
|
|
if (text && !isCommandLike) {
|
|
const nowMs = Date.now();
|
|
const senderId = msg.from?.id != null ? String(msg.from.id) : "unknown";
|
|
// Use resolvedThreadId for forum groups, dmThreadId for DM topics
|
|
const threadId = resolvedThreadId ?? dmThreadId;
|
|
const key = `text:${chatId}:${threadId ?? "main"}:${senderId}`;
|
|
const existing = textFragmentBuffer.get(key);
|
|
|
|
if (existing) {
|
|
const last = existing.messages.at(-1);
|
|
const lastMsgId = last?.msg.message_id;
|
|
const lastReceivedAtMs = last?.receivedAtMs ?? nowMs;
|
|
const idGap = typeof lastMsgId === "number" ? msg.message_id - lastMsgId : Infinity;
|
|
const timeGapMs = nowMs - lastReceivedAtMs;
|
|
const canAppend =
|
|
idGap > 0 &&
|
|
idGap <= TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP &&
|
|
timeGapMs >= 0 &&
|
|
timeGapMs <= TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS;
|
|
|
|
if (canAppend) {
|
|
const currentTotalChars = existing.messages.reduce(
|
|
(sum, m) => sum + (m.msg.text?.length ?? 0),
|
|
0,
|
|
);
|
|
const nextTotalChars = currentTotalChars + text.length;
|
|
if (
|
|
existing.messages.length + 1 <= TELEGRAM_TEXT_FRAGMENT_MAX_PARTS &&
|
|
nextTotalChars <= TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS
|
|
) {
|
|
existing.messages.push({ msg, ctx, receivedAtMs: nowMs });
|
|
scheduleTextFragmentFlush(existing);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Not appendable (or limits exceeded): flush buffered entry first, then continue normally.
|
|
clearTimeout(existing.timer);
|
|
textFragmentBuffer.delete(key);
|
|
textFragmentProcessing = textFragmentProcessing
|
|
.then(async () => {
|
|
await flushTextFragments(existing);
|
|
})
|
|
.catch(() => undefined);
|
|
await textFragmentProcessing;
|
|
}
|
|
|
|
const shouldStart = text.length >= TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS;
|
|
if (shouldStart) {
|
|
const entry: TextFragmentEntry = {
|
|
key,
|
|
messages: [{ msg, ctx, receivedAtMs: nowMs }],
|
|
timer: setTimeout(() => {}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS),
|
|
};
|
|
textFragmentBuffer.set(key, entry);
|
|
scheduleTextFragmentFlush(entry);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Media group handling - buffer multi-image messages
|
|
const mediaGroupId = msg.media_group_id;
|
|
if (mediaGroupId) {
|
|
const existing = mediaGroupBuffer.get(mediaGroupId);
|
|
if (existing) {
|
|
clearTimeout(existing.timer);
|
|
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;
|
|
}
|
|
|
|
let media: Awaited<ReturnType<typeof resolveMedia>> = null;
|
|
try {
|
|
media = await resolveMedia(
|
|
ctx,
|
|
mediaMaxBytes,
|
|
opts.token,
|
|
telegramTransport,
|
|
telegramCfg.apiRoot,
|
|
);
|
|
} catch (mediaErr) {
|
|
if (isMediaSizeLimitError(mediaErr)) {
|
|
if (sendOversizeWarning) {
|
|
const limitMb = Math.round(mediaMaxBytes / (1024 * 1024));
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendMessage",
|
|
runtime,
|
|
fn: () =>
|
|
bot.api.sendMessage(chatId, `⚠️ File too large. Maximum size is ${limitMb}MB.`, {
|
|
reply_parameters: {
|
|
message_id: msg.message_id,
|
|
allow_sending_without_reply: true,
|
|
},
|
|
}),
|
|
}).catch(() => {});
|
|
}
|
|
logger.warn({ chatId, error: String(mediaErr) }, oversizeLogMessage);
|
|
return;
|
|
}
|
|
logger.warn({ chatId, error: String(mediaErr) }, "media fetch failed");
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendMessage",
|
|
runtime,
|
|
fn: () =>
|
|
bot.api.sendMessage(chatId, "⚠️ Failed to download media. Please try again.", {
|
|
reply_parameters: {
|
|
message_id: msg.message_id,
|
|
allow_sending_without_reply: true,
|
|
},
|
|
}),
|
|
}).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
// Skip sticker-only messages where the sticker was skipped (animated/video)
|
|
// These have no media and no text content to process.
|
|
const hasText = Boolean(getTelegramTextParts(msg).text.trim());
|
|
if (msg.sticker && !media && !hasText) {
|
|
logVerbose("telegram: skipping sticker-only message (unsupported sticker type)");
|
|
return;
|
|
}
|
|
|
|
const allMedia = media
|
|
? [
|
|
{
|
|
path: media.path,
|
|
contentType: media.contentType,
|
|
stickerMetadata: media.stickerMetadata,
|
|
},
|
|
]
|
|
: [];
|
|
const senderId = msg.from?.id ? String(msg.from.id) : "";
|
|
const conversationThreadId = resolvedThreadId ?? dmThreadId;
|
|
const conversationKey =
|
|
conversationThreadId != null ? `${chatId}:topic:${conversationThreadId}` : String(chatId);
|
|
const debounceLane = resolveTelegramDebounceLane(msg);
|
|
const debounceKey = senderId
|
|
? `telegram:${accountId ?? "default"}:${conversationKey}:${senderId}:${debounceLane}`
|
|
: null;
|
|
await inboundDebouncer.enqueue({
|
|
ctx,
|
|
msg,
|
|
allMedia,
|
|
storeAllowFrom,
|
|
receivedAtMs: Date.now(),
|
|
debounceKey,
|
|
debounceLane,
|
|
botUsername: ctx.me?.username,
|
|
});
|
|
};
|
|
bot.on("callback_query", async (ctx) => {
|
|
const callback = ctx.callbackQuery;
|
|
if (!callback) {
|
|
return;
|
|
}
|
|
if (shouldSkipUpdate(ctx)) {
|
|
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
|
|
await withTelegramApiErrorLogging({
|
|
operation: "answerCallbackQuery",
|
|
runtime,
|
|
fn: answerCallbackQuery,
|
|
}).catch(() => {});
|
|
try {
|
|
const data = (callback.data ?? "").trim();
|
|
const callbackMessage = callback.message;
|
|
if (!data || !callbackMessage) {
|
|
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 clearCallbackButtons = async () => {
|
|
const emptyKeyboard = { inline_keyboard: [] };
|
|
const replyMarkup = { reply_markup: emptyKeyboard };
|
|
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
|
|
.editMessageReplyMarkup;
|
|
if (typeof editReplyMarkupFn === "function") {
|
|
return await ctx.editMessageReplyMarkup(replyMarkup);
|
|
}
|
|
const apiEditReplyMarkupFn = (bot.api as { editMessageReplyMarkup?: unknown })
|
|
.editMessageReplyMarkup;
|
|
if (typeof apiEditReplyMarkupFn === "function") {
|
|
return await bot.api.editMessageReplyMarkup(
|
|
callbackMessage.chat.id,
|
|
callbackMessage.message_id,
|
|
replyMarkup,
|
|
);
|
|
}
|
|
// Fallback path for older clients that do not expose editMessageReplyMarkup.
|
|
const messageText = callbackMessage.text ?? callbackMessage.caption;
|
|
if (typeof messageText !== "string" || messageText.trim().length === 0) {
|
|
return undefined;
|
|
}
|
|
return await editCallbackMessage(messageText, replyMarkup);
|
|
};
|
|
const editCallbackButtons = async (
|
|
buttons: Array<
|
|
Array<{ text: string; callback_data: string; style?: "danger" | "success" | "primary" }>
|
|
>,
|
|
) => {
|
|
const keyboard = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] };
|
|
const replyMarkup = { reply_markup: keyboard };
|
|
const editReplyMarkupFn = (ctx as { editMessageReplyMarkup?: unknown })
|
|
.editMessageReplyMarkup;
|
|
if (typeof editReplyMarkupFn === "function") {
|
|
return await ctx.editMessageReplyMarkup(replyMarkup);
|
|
}
|
|
return await bot.api.editMessageReplyMarkup(
|
|
callbackMessage.chat.id,
|
|
callbackMessage.message_id,
|
|
replyMarkup,
|
|
);
|
|
};
|
|
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 chatId = callbackMessage.chat.id;
|
|
const isGroup =
|
|
callbackMessage.chat.type === "group" || callbackMessage.chat.type === "supergroup";
|
|
const isApprovalCallback = APPROVE_CALLBACK_DATA_RE.test(data);
|
|
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
|
|
cfg,
|
|
accountId,
|
|
});
|
|
const execApprovalButtonsEnabled =
|
|
isApprovalCallback &&
|
|
shouldEnableTelegramExecApprovalButtons({
|
|
cfg,
|
|
accountId,
|
|
to: String(chatId),
|
|
});
|
|
if (!execApprovalButtonsEnabled) {
|
|
if (inlineButtonsScope === "off") {
|
|
return;
|
|
}
|
|
if (inlineButtonsScope === "dm" && isGroup) {
|
|
return;
|
|
}
|
|
if (inlineButtonsScope === "group" && !isGroup) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const messageThreadId = callbackMessage.message_thread_id;
|
|
const isForum = await resolveTelegramForumFlag({
|
|
chatId,
|
|
chatType: callbackMessage.chat.type,
|
|
isGroup,
|
|
isForum: callbackMessage.chat.is_forum,
|
|
getChat,
|
|
});
|
|
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
|
chatId,
|
|
isGroup,
|
|
isForum,
|
|
messageThreadId,
|
|
});
|
|
const { resolvedThreadId, dmThreadId, storeAllowFrom, groupConfig } = eventAuthContext;
|
|
const requireTopic = (groupConfig as { requireTopic?: boolean } | undefined)?.requireTopic;
|
|
if (!isGroup && requireTopic === true && dmThreadId == null) {
|
|
logVerbose(
|
|
`Blocked telegram callback in DM ${chatId}: requireTopic=true but no topic present`,
|
|
);
|
|
return;
|
|
}
|
|
const senderId = callback.from?.id ? String(callback.from.id) : "";
|
|
const senderUsername = callback.from?.username ?? "";
|
|
// DM callbacks must enforce the same sender authorization gate as normal DM commands.
|
|
const authorizationMode: TelegramEventAuthorizationMode =
|
|
!isGroup || (!execApprovalButtonsEnabled && inlineButtonsScope === "allowlist")
|
|
? "callback-allowlist"
|
|
: "callback-scope";
|
|
const senderAuthorization = authorizeTelegramEventSender({
|
|
chatId,
|
|
chatTitle: callbackMessage.chat.title,
|
|
isGroup,
|
|
senderId,
|
|
senderUsername,
|
|
mode: authorizationMode,
|
|
context: eventAuthContext,
|
|
});
|
|
if (!senderAuthorization.allowed) {
|
|
return;
|
|
}
|
|
|
|
const callbackThreadId = resolvedThreadId ?? dmThreadId;
|
|
const callbackConversationId =
|
|
callbackThreadId != null ? `${chatId}:topic:${callbackThreadId}` : String(chatId);
|
|
const pluginBindingApproval = parsePluginBindingApprovalCustomId(data);
|
|
if (pluginBindingApproval) {
|
|
const resolved = await resolvePluginConversationBindingApproval({
|
|
approvalId: pluginBindingApproval.approvalId,
|
|
decision: pluginBindingApproval.decision,
|
|
senderId: senderId || undefined,
|
|
});
|
|
await clearCallbackButtons();
|
|
await replyToCallbackChat(buildPluginBindingResolvedText(resolved));
|
|
return;
|
|
}
|
|
const pluginCallback = await dispatchPluginInteractiveHandler({
|
|
channel: "telegram",
|
|
data,
|
|
callbackId: callback.id,
|
|
ctx: {
|
|
accountId,
|
|
callbackId: callback.id,
|
|
conversationId: callbackConversationId,
|
|
parentConversationId: callbackThreadId != null ? String(chatId) : undefined,
|
|
senderId: senderId || undefined,
|
|
senderUsername: senderUsername || undefined,
|
|
threadId: callbackThreadId,
|
|
isGroup,
|
|
isForum,
|
|
auth: {
|
|
isAuthorizedSender: true,
|
|
},
|
|
callbackMessage: {
|
|
messageId: callbackMessage.message_id,
|
|
chatId: String(chatId),
|
|
messageText: callbackMessage.text ?? callbackMessage.caption,
|
|
},
|
|
},
|
|
respond: {
|
|
reply: async ({ text, buttons }) => {
|
|
await replyToCallbackChat(
|
|
text,
|
|
buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined,
|
|
);
|
|
},
|
|
editMessage: async ({ text, buttons }) => {
|
|
await editCallbackMessage(
|
|
text,
|
|
buttons ? { reply_markup: buildInlineKeyboard(buttons) } : undefined,
|
|
);
|
|
},
|
|
editButtons: async ({ buttons }) => {
|
|
await editCallbackButtons(buttons);
|
|
},
|
|
clearButtons: async () => {
|
|
await clearCallbackButtons();
|
|
},
|
|
deleteMessage: async () => {
|
|
await deleteCallbackMessage();
|
|
},
|
|
},
|
|
});
|
|
if (pluginCallback.handled) {
|
|
return;
|
|
}
|
|
|
|
const runtimeCfg = telegramDeps.loadConfig();
|
|
if (isApprovalCallback) {
|
|
if (
|
|
!isTelegramExecApprovalClientEnabled({ cfg: runtimeCfg, accountId }) ||
|
|
!isTelegramExecApprovalApprover({ cfg: runtimeCfg, accountId, senderId })
|
|
) {
|
|
logVerbose(
|
|
`Blocked telegram exec approval callback from ${senderId || "unknown"} (not an approver)`,
|
|
);
|
|
return;
|
|
}
|
|
try {
|
|
await clearCallbackButtons();
|
|
} catch (editErr) {
|
|
const errStr = String(editErr);
|
|
if (
|
|
!errStr.includes("message is not modified") &&
|
|
!errStr.includes("there is no text in the message to edit")
|
|
) {
|
|
logVerbose(`telegram: failed to clear approval callback buttons: ${errStr}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const paginationMatch = data.match(/^commands_page_(\d+|noop)(?::(.+))?$/);
|
|
if (paginationMatch) {
|
|
const pageValue = paginationMatch[1];
|
|
if (pageValue === "noop") {
|
|
return;
|
|
}
|
|
|
|
const page = Number.parseInt(pageValue, 10);
|
|
if (Number.isNaN(page) || page < 1) {
|
|
return;
|
|
}
|
|
|
|
const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(runtimeCfg);
|
|
const skillCommands = telegramDeps.listSkillCommandsForAgents({
|
|
cfg: runtimeCfg,
|
|
agentIds: [agentId],
|
|
});
|
|
const result = buildCommandsMessagePaginated(runtimeCfg, skillCommands, {
|
|
page,
|
|
surface: "telegram",
|
|
});
|
|
|
|
const keyboard =
|
|
result.totalPages > 1
|
|
? buildInlineKeyboard(
|
|
buildCommandsPaginationKeyboard(result.currentPage, result.totalPages, agentId),
|
|
)
|
|
: undefined;
|
|
|
|
try {
|
|
await editCallbackMessage(result.text, keyboard ? { reply_markup: keyboard } : undefined);
|
|
} catch (editErr) {
|
|
const errStr = String(editErr);
|
|
if (!errStr.includes("message is not modified")) {
|
|
throw editErr;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Model selection callback handler (mdl_prov, mdl_list_*, mdl_sel_*, mdl_back)
|
|
const modelCallback = parseModelCallbackData(data);
|
|
if (modelCallback) {
|
|
const sessionState = resolveTelegramSessionState({
|
|
chatId,
|
|
isGroup,
|
|
isForum,
|
|
messageThreadId,
|
|
resolvedThreadId,
|
|
senderId,
|
|
});
|
|
const modelData = await telegramDeps.buildModelsProviderData(
|
|
runtimeCfg,
|
|
sessionState.agentId,
|
|
);
|
|
const { byProvider, providers } = modelData;
|
|
|
|
const editMessageWithButtons = async (
|
|
text: string,
|
|
buttons: ReturnType<typeof buildProviderKeyboard>,
|
|
) => {
|
|
const keyboard = buildInlineKeyboard(buttons);
|
|
try {
|
|
await editCallbackMessage(text, keyboard ? { reply_markup: keyboard } : undefined);
|
|
} catch (editErr) {
|
|
const errStr = String(editErr);
|
|
if (errStr.includes("no text in the message")) {
|
|
try {
|
|
await deleteCallbackMessage();
|
|
} catch {}
|
|
await replyToCallbackChat(text, keyboard ? { reply_markup: keyboard } : undefined);
|
|
} else if (!errStr.includes("message is not modified")) {
|
|
throw editErr;
|
|
}
|
|
}
|
|
};
|
|
|
|
if (modelCallback.type === "providers" || modelCallback.type === "back") {
|
|
if (providers.length === 0) {
|
|
await editMessageWithButtons("No providers available.", []);
|
|
return;
|
|
}
|
|
const providerInfos: ProviderInfo[] = providers.map((p) => ({
|
|
id: p,
|
|
count: byProvider.get(p)?.size ?? 0,
|
|
}));
|
|
const buttons = buildProviderKeyboard(providerInfos);
|
|
await editMessageWithButtons("Select a provider:", buttons);
|
|
return;
|
|
}
|
|
|
|
if (modelCallback.type === "list") {
|
|
const { provider, page } = modelCallback;
|
|
const modelSet = byProvider.get(provider);
|
|
if (!modelSet || modelSet.size === 0) {
|
|
// Provider not found or no models - show providers list
|
|
const providerInfos: ProviderInfo[] = providers.map((p) => ({
|
|
id: p,
|
|
count: byProvider.get(p)?.size ?? 0,
|
|
}));
|
|
const buttons = buildProviderKeyboard(providerInfos);
|
|
await editMessageWithButtons(
|
|
`Unknown provider: ${provider}\n\nSelect a provider:`,
|
|
buttons,
|
|
);
|
|
return;
|
|
}
|
|
const models = [...modelSet].toSorted();
|
|
const pageSize = getModelsPageSize();
|
|
const totalPages = calculateTotalPages(models.length, pageSize);
|
|
const safePage = Math.max(1, Math.min(page, totalPages));
|
|
|
|
// Resolve current model from session (prefer overrides)
|
|
const currentSessionState = resolveTelegramSessionState({
|
|
chatId,
|
|
isGroup,
|
|
isForum,
|
|
messageThreadId,
|
|
resolvedThreadId,
|
|
senderId,
|
|
});
|
|
const currentModel = currentSessionState.model;
|
|
|
|
const buttons = buildModelsKeyboard({
|
|
provider,
|
|
models,
|
|
currentModel,
|
|
currentPage: safePage,
|
|
totalPages,
|
|
pageSize,
|
|
});
|
|
const text = formatModelsAvailableHeader({
|
|
provider,
|
|
total: models.length,
|
|
cfg,
|
|
agentDir: resolveAgentDir(cfg, currentSessionState.agentId),
|
|
sessionEntry: currentSessionState.sessionEntry,
|
|
});
|
|
await editMessageWithButtons(text, buttons);
|
|
return;
|
|
}
|
|
|
|
if (modelCallback.type === "select") {
|
|
const selection = resolveModelSelection({
|
|
callback: modelCallback,
|
|
providers,
|
|
byProvider,
|
|
});
|
|
if (selection.kind !== "resolved") {
|
|
const providerInfos: ProviderInfo[] = providers.map((p) => ({
|
|
id: p,
|
|
count: byProvider.get(p)?.size ?? 0,
|
|
}));
|
|
const buttons = buildProviderKeyboard(providerInfos);
|
|
await editMessageWithButtons(
|
|
`Could not resolve model "${selection.model}".\n\nSelect a provider:`,
|
|
buttons,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const modelSet = byProvider.get(selection.provider);
|
|
if (!modelSet?.has(selection.model)) {
|
|
await editMessageWithButtons(
|
|
`❌ Model "${selection.provider}/${selection.model}" is not allowed.`,
|
|
[],
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Directly set model override in session
|
|
try {
|
|
// Get session store path
|
|
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
|
|
agentId: sessionState.agentId,
|
|
});
|
|
|
|
const resolvedDefault = resolveDefaultModelForAgent({
|
|
cfg,
|
|
agentId: sessionState.agentId,
|
|
});
|
|
const isDefaultSelection =
|
|
selection.provider === resolvedDefault.provider &&
|
|
selection.model === resolvedDefault.model;
|
|
|
|
await updateSessionStore(storePath, (store) => {
|
|
const sessionKey = sessionState.sessionKey;
|
|
const entry = store[sessionKey] ?? {};
|
|
store[sessionKey] = entry;
|
|
applyModelOverrideToSessionEntry({
|
|
entry,
|
|
selection: {
|
|
provider: selection.provider,
|
|
model: selection.model,
|
|
isDefault: isDefaultSelection,
|
|
},
|
|
});
|
|
});
|
|
|
|
// Update message to show success with visual feedback
|
|
const actionText = isDefaultSelection
|
|
? "reset to default"
|
|
: `changed to **${selection.provider}/${selection.model}**`;
|
|
await editMessageWithButtons(
|
|
`✅ Model ${actionText}\n\nThis model will be used for your next message.`,
|
|
[], // Empty buttons = remove inline keyboard
|
|
);
|
|
} catch (err) {
|
|
await editMessageWithButtons(`❌ Failed to change model: ${String(err)}`, []);
|
|
}
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
const syntheticMessage = buildSyntheticTextMessage({
|
|
base: withResolvedTelegramForumFlag(callbackMessage, isForum),
|
|
from: callback.from,
|
|
text: data,
|
|
});
|
|
await processMessage(buildSyntheticContext(ctx, syntheticMessage), [], storeAllowFrom, {
|
|
forceWasMentioned: true,
|
|
messageIdOverride: callback.id,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
|
|
}
|
|
});
|
|
|
|
// Handle group migration to supergroup (chat ID changes)
|
|
bot.on("message:migrate_to_chat_id", async (ctx) => {
|
|
try {
|
|
const msg = ctx.message;
|
|
if (!msg?.migrate_to_chat_id) {
|
|
return;
|
|
}
|
|
if (shouldSkipUpdate(ctx)) {
|
|
return;
|
|
}
|
|
|
|
const oldChatId = String(msg.chat.id);
|
|
const newChatId = String(msg.migrate_to_chat_id);
|
|
const chatTitle = msg.chat.title ?? "Unknown";
|
|
|
|
runtime.log?.(warn(`[telegram] Group migrated: "${chatTitle}" ${oldChatId} → ${newChatId}`));
|
|
|
|
if (!resolveChannelConfigWrites({ cfg, channelId: "telegram", accountId })) {
|
|
runtime.log?.(warn("[telegram] Config writes disabled; skipping group config migration."));
|
|
return;
|
|
}
|
|
|
|
// Check if old chat ID has config and migrate it
|
|
const currentConfig = telegramDeps.loadConfig();
|
|
const migration = migrateTelegramGroupConfig({
|
|
cfg: currentConfig,
|
|
accountId,
|
|
oldChatId,
|
|
newChatId,
|
|
});
|
|
|
|
if (migration.migrated) {
|
|
runtime.log?.(warn(`[telegram] Migrating group config from ${oldChatId} to ${newChatId}`));
|
|
migrateTelegramGroupConfig({ cfg, accountId, oldChatId, newChatId });
|
|
await writeConfigFile(currentConfig);
|
|
runtime.log?.(warn(`[telegram] Group config migrated and saved successfully`));
|
|
} else if (migration.skippedExisting) {
|
|
runtime.log?.(
|
|
warn(
|
|
`[telegram] Group config already exists for ${newChatId}; leaving ${oldChatId} unchanged`,
|
|
),
|
|
);
|
|
} else {
|
|
runtime.log?.(
|
|
warn(`[telegram] No config found for old group ID ${oldChatId}, migration logged only`),
|
|
);
|
|
}
|
|
} catch (err) {
|
|
runtime.error?.(danger(`[telegram] Group migration handler failed: ${String(err)}`));
|
|
}
|
|
});
|
|
|
|
type InboundTelegramEvent = {
|
|
ctxForDedupe: TelegramUpdateKeyContext;
|
|
ctx: TelegramContext;
|
|
msg: Message;
|
|
chatId: number;
|
|
isGroup: boolean;
|
|
isForum: boolean;
|
|
messageThreadId?: number;
|
|
senderId: string;
|
|
senderUsername: string;
|
|
requireConfiguredGroup: boolean;
|
|
sendOversizeWarning: boolean;
|
|
oversizeLogMessage: string;
|
|
errorMessage: string;
|
|
};
|
|
|
|
const handleInboundMessageLike = async (event: InboundTelegramEvent) => {
|
|
try {
|
|
if (shouldSkipUpdate(event.ctxForDedupe)) {
|
|
return;
|
|
}
|
|
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
|
chatId: event.chatId,
|
|
isGroup: event.isGroup,
|
|
isForum: event.isForum,
|
|
messageThreadId: event.messageThreadId,
|
|
});
|
|
const {
|
|
dmPolicy,
|
|
resolvedThreadId,
|
|
dmThreadId,
|
|
storeAllowFrom,
|
|
groupConfig,
|
|
topicConfig,
|
|
groupAllowOverride,
|
|
effectiveGroupAllow,
|
|
hasGroupAllowOverride,
|
|
} = eventAuthContext;
|
|
// For DMs, prefer per-DM/topic allowFrom (groupAllowOverride) over account-level allowFrom
|
|
const dmAllowFrom = groupAllowOverride ?? allowFrom;
|
|
const effectiveDmAllow = normalizeDmAllowFromWithStore({
|
|
allowFrom: dmAllowFrom,
|
|
storeAllowFrom,
|
|
dmPolicy,
|
|
});
|
|
|
|
if (event.requireConfiguredGroup && (!groupConfig || groupConfig.enabled === false)) {
|
|
logVerbose(`Blocked telegram channel ${event.chatId} (channel disabled)`);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
shouldSkipGroupMessage({
|
|
isGroup: event.isGroup,
|
|
chatId: event.chatId,
|
|
chatTitle: event.msg.chat.title,
|
|
resolvedThreadId,
|
|
senderId: event.senderId,
|
|
senderUsername: event.senderUsername,
|
|
effectiveGroupAllow,
|
|
hasGroupAllowOverride,
|
|
groupConfig,
|
|
topicConfig,
|
|
})
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!event.isGroup && (hasInboundMedia(event.msg) || hasReplyTargetMedia(event.msg))) {
|
|
const dmAuthorized = await enforceTelegramDmAccess({
|
|
isGroup: event.isGroup,
|
|
dmPolicy,
|
|
msg: event.msg,
|
|
chatId: event.chatId,
|
|
effectiveDmAllow,
|
|
accountId,
|
|
bot,
|
|
logger,
|
|
upsertPairingRequest: telegramDeps.upsertChannelPairingRequest,
|
|
});
|
|
if (!dmAuthorized) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await processInboundMessage({
|
|
ctx: event.ctx,
|
|
msg: event.msg,
|
|
chatId: event.chatId,
|
|
resolvedThreadId,
|
|
dmThreadId,
|
|
storeAllowFrom,
|
|
sendOversizeWarning: event.sendOversizeWarning,
|
|
oversizeLogMessage: event.oversizeLogMessage,
|
|
});
|
|
} catch (err) {
|
|
runtime.error?.(danger(`${event.errorMessage}: ${String(err)}`));
|
|
}
|
|
};
|
|
|
|
bot.on("message", async (ctx) => {
|
|
const msg = ctx.message;
|
|
if (!msg) {
|
|
return;
|
|
}
|
|
if (isSelfAuthoredTelegramMessage(ctx, msg)) {
|
|
return;
|
|
}
|
|
const isGroup = msg.chat.type === "group" || msg.chat.type === "supergroup";
|
|
const isForum = await resolveTelegramForumFlag({
|
|
chatId: msg.chat.id,
|
|
chatType: msg.chat.type,
|
|
isGroup,
|
|
isForum: msg.chat.is_forum,
|
|
getChat,
|
|
});
|
|
const normalizedMsg = withResolvedTelegramForumFlag(msg, isForum);
|
|
await handleInboundMessageLike({
|
|
ctxForDedupe: ctx,
|
|
ctx: buildSyntheticContext(ctx, normalizedMsg),
|
|
msg: normalizedMsg,
|
|
chatId: normalizedMsg.chat.id,
|
|
isGroup,
|
|
isForum,
|
|
messageThreadId: normalizedMsg.message_thread_id,
|
|
senderId: normalizedMsg.from?.id != null ? String(normalizedMsg.from.id) : "",
|
|
senderUsername: normalizedMsg.from?.username ?? "",
|
|
requireConfiguredGroup: false,
|
|
sendOversizeWarning: true,
|
|
oversizeLogMessage: "media exceeds size limit",
|
|
errorMessage: "handler failed",
|
|
});
|
|
});
|
|
|
|
// Handle channel posts — enables bot-to-bot communication via Telegram channels.
|
|
// Telegram bots cannot see other bot messages in groups, but CAN in channels.
|
|
// This handler normalizes channel_post updates into the standard message pipeline.
|
|
bot.on("channel_post", async (ctx) => {
|
|
const post = ctx.channelPost;
|
|
if (!post) {
|
|
return;
|
|
}
|
|
|
|
const chatId = post.chat.id;
|
|
const syntheticFrom = post.sender_chat
|
|
? {
|
|
id: post.sender_chat.id,
|
|
is_bot: true as const,
|
|
first_name: post.sender_chat.title || "Channel",
|
|
username: post.sender_chat.username,
|
|
}
|
|
: {
|
|
id: chatId,
|
|
is_bot: true as const,
|
|
first_name: post.chat.title || "Channel",
|
|
username: post.chat.username,
|
|
};
|
|
const syntheticMsg: Message = {
|
|
...post,
|
|
from: post.from ?? syntheticFrom,
|
|
chat: {
|
|
...post.chat,
|
|
type: "supergroup" as const,
|
|
},
|
|
} as Message;
|
|
|
|
await handleInboundMessageLike({
|
|
ctxForDedupe: ctx,
|
|
ctx: buildSyntheticContext(ctx, syntheticMsg),
|
|
msg: syntheticMsg,
|
|
chatId,
|
|
isGroup: true,
|
|
isForum: false,
|
|
senderId:
|
|
post.sender_chat?.id != null
|
|
? String(post.sender_chat.id)
|
|
: post.from?.id != null
|
|
? String(post.from.id)
|
|
: "",
|
|
senderUsername: post.sender_chat?.username ?? post.from?.username ?? "",
|
|
requireConfiguredGroup: true,
|
|
sendOversizeWarning: false,
|
|
oversizeLogMessage: "channel post media exceeds size limit",
|
|
errorMessage: "channel_post handler failed",
|
|
});
|
|
});
|
|
};
|