refactor(telegram): split bot helpers
This commit is contained in:
@@ -1,18 +1,15 @@
|
|||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
import { sequentialize } from "@grammyjs/runner";
|
import { sequentialize } from "@grammyjs/runner";
|
||||||
import { apiThrottler } from "@grammyjs/transformer-throttler";
|
import { apiThrottler } from "@grammyjs/transformer-throttler";
|
||||||
import type { ApiClientOptions, Message } from "grammy";
|
import type { ApiClientOptions } from "grammy";
|
||||||
import { Bot, InputFile, webhookCallback } from "grammy";
|
import { Bot, webhookCallback } from "grammy";
|
||||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import {
|
import {
|
||||||
resolveAckReaction,
|
resolveAckReaction,
|
||||||
resolveEffectiveMessagesConfig,
|
resolveEffectiveMessagesConfig,
|
||||||
} from "../agents/identity.js";
|
} from "../agents/identity.js";
|
||||||
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js";
|
||||||
import {
|
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
|
||||||
chunkMarkdownText,
|
|
||||||
resolveTextChunkLimit,
|
|
||||||
} from "../auto-reply/chunk.js";
|
|
||||||
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
import { hasControlCommand } from "../auto-reply/command-detection.js";
|
||||||
import {
|
import {
|
||||||
buildCommandText,
|
buildCommandText,
|
||||||
@@ -31,12 +28,7 @@ import {
|
|||||||
matchesMentionPatterns,
|
matchesMentionPatterns,
|
||||||
} from "../auto-reply/reply/mentions.js";
|
} from "../auto-reply/reply/mentions.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import { formatLocationText, toLocationContext } from "../channels/location.js";
|
||||||
import {
|
|
||||||
formatLocationText,
|
|
||||||
type NormalizedLocation,
|
|
||||||
toLocationContext,
|
|
||||||
} from "../channels/location.js";
|
|
||||||
import {
|
import {
|
||||||
isNativeCommandsExplicitlyDisabled,
|
isNativeCommandsExplicitlyDisabled,
|
||||||
resolveNativeCommandsEnabled,
|
resolveNativeCommandsEnabled,
|
||||||
@@ -55,30 +47,33 @@ import {
|
|||||||
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
|
||||||
import { recordChannelActivity } from "../infra/channel-activity.js";
|
import { recordChannelActivity } from "../infra/channel-activity.js";
|
||||||
import { createDedupeCache } from "../infra/dedupe.js";
|
import { createDedupeCache } from "../infra/dedupe.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.js";
|
|
||||||
import { fetchRemoteMedia } from "../media/fetch.js";
|
|
||||||
import { isGifMedia } from "../media/mime.js";
|
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
|
||||||
import { resolveTelegramAccount } from "./accounts.js";
|
import { resolveTelegramAccount } from "./accounts.js";
|
||||||
|
import { deliverReplies, resolveMedia } from "./bot/delivery.js";
|
||||||
|
import {
|
||||||
|
buildGroupFromLabel,
|
||||||
|
buildGroupLabel,
|
||||||
|
buildSenderLabel,
|
||||||
|
buildSenderName,
|
||||||
|
buildTelegramGroupFrom,
|
||||||
|
buildTelegramGroupPeerId,
|
||||||
|
buildTelegramThreadParams,
|
||||||
|
describeReplyTarget,
|
||||||
|
extractTelegramLocation,
|
||||||
|
hasBotMention,
|
||||||
|
resolveTelegramForumThreadId,
|
||||||
|
resolveTelegramStreamMode,
|
||||||
|
} from "./bot/helpers.js";
|
||||||
|
import type { TelegramContext, TelegramMessage } from "./bot/types.js";
|
||||||
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js";
|
||||||
import { createTelegramDraftStream } from "./draft-stream.js";
|
import { createTelegramDraftStream } from "./draft-stream.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
import { markdownToTelegramHtml } from "./format.js";
|
|
||||||
import {
|
import {
|
||||||
readTelegramAllowFromStore,
|
readTelegramAllowFromStore,
|
||||||
upsertTelegramPairingRequest,
|
upsertTelegramPairingRequest,
|
||||||
} from "./pairing-store.js";
|
} from "./pairing-store.js";
|
||||||
import { resolveTelegramVoiceSend } from "./voice.js";
|
|
||||||
|
|
||||||
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
|
||||||
|
|
||||||
const PARSE_ERR_RE =
|
|
||||||
/can't parse entities|parse entities|find end of the entity/i;
|
|
||||||
|
|
||||||
// Media group aggregation - Telegram sends multi-image messages as separate updates
|
// Media group aggregation - Telegram sends multi-image messages as separate updates
|
||||||
// with a shared media_group_id. We buffer them and process as a single message after a short delay.
|
// with a shared media_group_id. We buffer them and process as a single message after a short delay.
|
||||||
@@ -86,10 +81,6 @@ const MEDIA_GROUP_TIMEOUT_MS = 500;
|
|||||||
const RECENT_TELEGRAM_UPDATE_TTL_MS = 5 * 60_000;
|
const RECENT_TELEGRAM_UPDATE_TTL_MS = 5 * 60_000;
|
||||||
const RECENT_TELEGRAM_UPDATE_MAX = 2000;
|
const RECENT_TELEGRAM_UPDATE_MAX = 2000;
|
||||||
|
|
||||||
type TelegramMessage = Message.CommonMessage;
|
|
||||||
|
|
||||||
type TelegramStreamMode = "off" | "partial" | "block";
|
|
||||||
|
|
||||||
type MediaGroupEntry = {
|
type MediaGroupEntry = {
|
||||||
messages: Array<{
|
messages: Array<{
|
||||||
msg: TelegramMessage;
|
msg: TelegramMessage;
|
||||||
@@ -136,34 +127,6 @@ const createTelegramUpdateDedupe = () =>
|
|||||||
maxSize: RECENT_TELEGRAM_UPDATE_MAX,
|
maxSize: RECENT_TELEGRAM_UPDATE_MAX,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Telegram Location object */
|
|
||||||
interface TelegramLocation {
|
|
||||||
latitude: number;
|
|
||||||
longitude: number;
|
|
||||||
horizontal_accuracy?: number;
|
|
||||||
live_period?: number;
|
|
||||||
heading?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Telegram Venue object */
|
|
||||||
interface TelegramVenue {
|
|
||||||
location: TelegramLocation;
|
|
||||||
title: string;
|
|
||||||
address: string;
|
|
||||||
foursquare_id?: string;
|
|
||||||
foursquare_type?: string;
|
|
||||||
google_place_id?: string;
|
|
||||||
google_place_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TelegramContext = {
|
|
||||||
message: TelegramMessage;
|
|
||||||
me?: { username?: string };
|
|
||||||
getFile: () => Promise<{
|
|
||||||
file_path?: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TelegramBotOptions = {
|
export type TelegramBotOptions = {
|
||||||
token: string;
|
token: string;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -209,16 +172,6 @@ export function getTelegramSequentialKey(ctx: {
|
|||||||
return "telegram:unknown";
|
return "telegram:unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTelegramForumThreadId(params: {
|
|
||||||
isForum?: boolean;
|
|
||||||
messageThreadId?: number | null;
|
|
||||||
}) {
|
|
||||||
if (params.isForum && params.messageThreadId == null) {
|
|
||||||
return TELEGRAM_GENERAL_TOPIC_ID;
|
|
||||||
}
|
|
||||||
return params.messageThreadId ?? undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createTelegramBot(opts: TelegramBotOptions) {
|
export function createTelegramBot(opts: TelegramBotOptions) {
|
||||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||||
log: console.log,
|
log: console.log,
|
||||||
@@ -1475,365 +1428,3 @@ export function createTelegramWebhookCallback(
|
|||||||
) {
|
) {
|
||||||
return { path, handler: webhookCallback(bot, "http") };
|
return { path, handler: webhookCallback(bot, "http") };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deliverReplies(params: {
|
|
||||||
replies: ReplyPayload[];
|
|
||||||
chatId: string;
|
|
||||||
token: string;
|
|
||||||
runtime: RuntimeEnv;
|
|
||||||
bot: Bot;
|
|
||||||
replyToMode: ReplyToMode;
|
|
||||||
textLimit: number;
|
|
||||||
messageThreadId?: number;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
replies,
|
|
||||||
chatId,
|
|
||||||
runtime,
|
|
||||||
bot,
|
|
||||||
replyToMode,
|
|
||||||
textLimit,
|
|
||||||
messageThreadId,
|
|
||||||
} = params;
|
|
||||||
const threadParams = buildTelegramThreadParams(messageThreadId);
|
|
||||||
let hasReplied = false;
|
|
||||||
for (const reply of replies) {
|
|
||||||
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
|
|
||||||
runtime.error?.(danger("reply missing text/media"));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const replyToId =
|
|
||||||
replyToMode === "off"
|
|
||||||
? undefined
|
|
||||||
: resolveTelegramReplyId(reply.replyToId);
|
|
||||||
const mediaList = reply.mediaUrls?.length
|
|
||||||
? reply.mediaUrls
|
|
||||||
: reply.mediaUrl
|
|
||||||
? [reply.mediaUrl]
|
|
||||||
: [];
|
|
||||||
if (mediaList.length === 0) {
|
|
||||||
for (const chunk of chunkMarkdownText(reply.text || "", textLimit)) {
|
|
||||||
await sendTelegramText(bot, chatId, chunk, runtime, {
|
|
||||||
replyToMessageId:
|
|
||||||
replyToId && (replyToMode === "all" || !hasReplied)
|
|
||||||
? replyToId
|
|
||||||
: undefined,
|
|
||||||
messageThreadId,
|
|
||||||
});
|
|
||||||
if (replyToId && !hasReplied) {
|
|
||||||
hasReplied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// media with optional caption on first item
|
|
||||||
let first = true;
|
|
||||||
for (const mediaUrl of mediaList) {
|
|
||||||
const media = await loadWebMedia(mediaUrl);
|
|
||||||
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
|
||||||
const isGif = isGifMedia({
|
|
||||||
contentType: media.contentType,
|
|
||||||
fileName: media.fileName,
|
|
||||||
});
|
|
||||||
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
|
|
||||||
const file = new InputFile(media.buffer, fileName);
|
|
||||||
const caption = first ? (reply.text ?? undefined) : undefined;
|
|
||||||
first = false;
|
|
||||||
const replyToMessageId =
|
|
||||||
replyToId && (replyToMode === "all" || !hasReplied)
|
|
||||||
? replyToId
|
|
||||||
: undefined;
|
|
||||||
const mediaParams: Record<string, unknown> = {
|
|
||||||
caption,
|
|
||||||
reply_to_message_id: replyToMessageId,
|
|
||||||
};
|
|
||||||
if (threadParams) {
|
|
||||||
mediaParams.message_thread_id = threadParams.message_thread_id;
|
|
||||||
}
|
|
||||||
if (isGif) {
|
|
||||||
await bot.api.sendAnimation(chatId, file, {
|
|
||||||
...mediaParams,
|
|
||||||
});
|
|
||||||
} else if (kind === "image") {
|
|
||||||
await bot.api.sendPhoto(chatId, file, {
|
|
||||||
...mediaParams,
|
|
||||||
});
|
|
||||||
} else if (kind === "video") {
|
|
||||||
await bot.api.sendVideo(chatId, file, {
|
|
||||||
...mediaParams,
|
|
||||||
});
|
|
||||||
} else if (kind === "audio") {
|
|
||||||
const { useVoice } = resolveTelegramVoiceSend({
|
|
||||||
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
|
|
||||||
contentType: media.contentType,
|
|
||||||
fileName,
|
|
||||||
logFallback: logVerbose,
|
|
||||||
});
|
|
||||||
if (useVoice) {
|
|
||||||
// Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]])
|
|
||||||
await bot.api.sendVoice(chatId, file, {
|
|
||||||
...mediaParams,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Audio file - displays with metadata (title, duration) - DEFAULT
|
|
||||||
await bot.api.sendAudio(chatId, file, {
|
|
||||||
...mediaParams,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await bot.api.sendDocument(chatId, file, {
|
|
||||||
...mediaParams,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (replyToId && !hasReplied) {
|
|
||||||
hasReplied = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTelegramThreadParams(messageThreadId?: number) {
|
|
||||||
return messageThreadId != null
|
|
||||||
? { message_thread_id: messageThreadId }
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTelegramStreamMode(
|
|
||||||
telegramCfg: ClawdbotConfig["telegram"],
|
|
||||||
): TelegramStreamMode {
|
|
||||||
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
|
|
||||||
if (raw === "off" || raw === "partial" || raw === "block") return raw;
|
|
||||||
return "partial";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTelegramGroupPeerId(
|
|
||||||
chatId: number | string,
|
|
||||||
messageThreadId?: number,
|
|
||||||
) {
|
|
||||||
return messageThreadId != null
|
|
||||||
? `${chatId}:topic:${messageThreadId}`
|
|
||||||
: String(chatId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTelegramGroupFrom(
|
|
||||||
chatId: number | string,
|
|
||||||
messageThreadId?: number,
|
|
||||||
) {
|
|
||||||
return messageThreadId != null
|
|
||||||
? `group:${chatId}:topic:${messageThreadId}`
|
|
||||||
: `group:${chatId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSenderName(msg: TelegramMessage) {
|
|
||||||
const name =
|
|
||||||
[msg.from?.first_name, msg.from?.last_name]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ")
|
|
||||||
.trim() || msg.from?.username;
|
|
||||||
return name || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSenderLabel(msg: TelegramMessage, senderId?: number | string) {
|
|
||||||
const name = buildSenderName(msg);
|
|
||||||
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
|
|
||||||
let label = name;
|
|
||||||
if (name && username) {
|
|
||||||
label = `${name} (${username})`;
|
|
||||||
} else if (!name && username) {
|
|
||||||
label = username;
|
|
||||||
}
|
|
||||||
const normalizedSenderId =
|
|
||||||
senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : undefined;
|
|
||||||
const fallbackId =
|
|
||||||
normalizedSenderId ??
|
|
||||||
(msg.from?.id != null ? String(msg.from.id) : undefined);
|
|
||||||
const idPart = fallbackId ? `id:${fallbackId}` : undefined;
|
|
||||||
if (label && idPart) return `${label} ${idPart}`;
|
|
||||||
if (label) return label;
|
|
||||||
return idPart ?? "id:unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGroupLabel(
|
|
||||||
msg: TelegramMessage,
|
|
||||||
chatId: number | string,
|
|
||||||
messageThreadId?: number,
|
|
||||||
) {
|
|
||||||
const title = msg.chat?.title;
|
|
||||||
const topicSuffix =
|
|
||||||
messageThreadId != null ? ` topic:${messageThreadId}` : "";
|
|
||||||
if (title) return `${title} id:${chatId}${topicSuffix}`;
|
|
||||||
return `group:${chatId}${topicSuffix}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGroupFromLabel(
|
|
||||||
msg: TelegramMessage,
|
|
||||||
chatId: number | string,
|
|
||||||
senderId?: number | string,
|
|
||||||
messageThreadId?: number,
|
|
||||||
) {
|
|
||||||
const groupLabel = buildGroupLabel(msg, chatId, messageThreadId);
|
|
||||||
const senderLabel = buildSenderLabel(msg, senderId);
|
|
||||||
return `${groupLabel} from ${senderLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasBotMention(msg: TelegramMessage, botUsername: string) {
|
|
||||||
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
|
|
||||||
if (text.includes(`@${botUsername}`)) return true;
|
|
||||||
const entities = msg.entities ?? msg.caption_entities ?? [];
|
|
||||||
for (const ent of entities) {
|
|
||||||
if (ent.type !== "mention") continue;
|
|
||||||
const slice = (msg.text ?? msg.caption ?? "").slice(
|
|
||||||
ent.offset,
|
|
||||||
ent.offset + ent.length,
|
|
||||||
);
|
|
||||||
if (slice.toLowerCase() === `@${botUsername}`) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTelegramReplyId(raw?: string): number | undefined {
|
|
||||||
if (!raw) return undefined;
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (!Number.isFinite(parsed)) return undefined;
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveMedia(
|
|
||||||
ctx: TelegramContext,
|
|
||||||
maxBytes: number,
|
|
||||||
token: string,
|
|
||||||
proxyFetch?: typeof fetch,
|
|
||||||
): Promise<{ path: string; contentType?: string; placeholder: string } | null> {
|
|
||||||
const msg = ctx.message;
|
|
||||||
const m =
|
|
||||||
msg.photo?.[msg.photo.length - 1] ??
|
|
||||||
msg.video ??
|
|
||||||
msg.document ??
|
|
||||||
msg.audio ??
|
|
||||||
msg.voice;
|
|
||||||
if (!m?.file_id) return null;
|
|
||||||
const file = await ctx.getFile();
|
|
||||||
if (!file.file_path) {
|
|
||||||
throw new Error("Telegram getFile returned no file_path");
|
|
||||||
}
|
|
||||||
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
|
||||||
if (!fetchImpl) {
|
|
||||||
throw new Error(
|
|
||||||
"fetch is not available; set channels.telegram.proxy in config",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
|
||||||
const fetched = await fetchRemoteMedia({
|
|
||||||
url,
|
|
||||||
fetchImpl,
|
|
||||||
filePathHint: file.file_path,
|
|
||||||
});
|
|
||||||
const saved = await saveMediaBuffer(
|
|
||||||
fetched.buffer,
|
|
||||||
fetched.contentType,
|
|
||||||
"inbound",
|
|
||||||
maxBytes,
|
|
||||||
);
|
|
||||||
let placeholder = "<media:document>";
|
|
||||||
if (msg.photo) placeholder = "<media:image>";
|
|
||||||
else if (msg.video) placeholder = "<media:video>";
|
|
||||||
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
|
||||||
return { path: saved.path, contentType: saved.contentType, placeholder };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendTelegramText(
|
|
||||||
bot: Bot,
|
|
||||||
chatId: string,
|
|
||||||
text: string,
|
|
||||||
runtime: RuntimeEnv,
|
|
||||||
opts?: { replyToMessageId?: number; messageThreadId?: number },
|
|
||||||
): Promise<number | undefined> {
|
|
||||||
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
|
|
||||||
const baseParams: Record<string, unknown> = {
|
|
||||||
reply_to_message_id: opts?.replyToMessageId,
|
|
||||||
};
|
|
||||||
if (threadParams) {
|
|
||||||
baseParams.message_thread_id = threadParams.message_thread_id;
|
|
||||||
}
|
|
||||||
const htmlText = markdownToTelegramHtml(text);
|
|
||||||
try {
|
|
||||||
const res = await bot.api.sendMessage(chatId, htmlText, {
|
|
||||||
parse_mode: "HTML",
|
|
||||||
...baseParams,
|
|
||||||
});
|
|
||||||
return res.message_id;
|
|
||||||
} catch (err) {
|
|
||||||
const errText = formatErrorMessage(err);
|
|
||||||
if (PARSE_ERR_RE.test(errText)) {
|
|
||||||
runtime.log?.(
|
|
||||||
`telegram HTML parse failed; retrying without formatting: ${errText}`,
|
|
||||||
);
|
|
||||||
const res = await bot.api.sendMessage(chatId, text, {
|
|
||||||
...baseParams,
|
|
||||||
});
|
|
||||||
return res.message_id;
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function describeReplyTarget(msg: TelegramMessage) {
|
|
||||||
const reply = msg.reply_to_message;
|
|
||||||
if (!reply) return null;
|
|
||||||
const replyBody = (reply.text ?? reply.caption ?? "").trim();
|
|
||||||
let body = replyBody;
|
|
||||||
if (!body) {
|
|
||||||
if (reply.photo) body = "<media:image>";
|
|
||||||
else if (reply.video) body = "<media:video>";
|
|
||||||
else if (reply.audio || reply.voice) body = "<media:audio>";
|
|
||||||
else if (reply.document) body = "<media:document>";
|
|
||||||
else {
|
|
||||||
const locationData = extractTelegramLocation(reply);
|
|
||||||
if (locationData) body = formatLocationText(locationData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!body) return null;
|
|
||||||
const sender = buildSenderName(reply);
|
|
||||||
const senderLabel = sender ? `${sender}` : "unknown sender";
|
|
||||||
return {
|
|
||||||
id: reply.message_id ? String(reply.message_id) : undefined,
|
|
||||||
sender: senderLabel,
|
|
||||||
body,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractTelegramLocation(
|
|
||||||
msg: TelegramMessage,
|
|
||||||
): NormalizedLocation | null {
|
|
||||||
const msgWithLocation = msg as {
|
|
||||||
location?: TelegramLocation;
|
|
||||||
venue?: TelegramVenue;
|
|
||||||
};
|
|
||||||
const { venue, location } = msgWithLocation;
|
|
||||||
|
|
||||||
if (venue) {
|
|
||||||
return {
|
|
||||||
latitude: venue.location.latitude,
|
|
||||||
longitude: venue.location.longitude,
|
|
||||||
accuracy: venue.location.horizontal_accuracy,
|
|
||||||
name: venue.title,
|
|
||||||
address: venue.address,
|
|
||||||
source: "place",
|
|
||||||
isLive: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (location) {
|
|
||||||
const isLive =
|
|
||||||
typeof location.live_period === "number" && location.live_period > 0;
|
|
||||||
return {
|
|
||||||
latitude: location.latitude,
|
|
||||||
longitude: location.longitude,
|
|
||||||
accuracy: location.horizontal_accuracy,
|
|
||||||
source: isLive ? "live" : "pin",
|
|
||||||
isLive,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
217
src/telegram/bot/delivery.ts
Normal file
217
src/telegram/bot/delivery.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { type Bot, InputFile } from "grammy";
|
||||||
|
import { chunkMarkdownText } from "../../auto-reply/chunk.js";
|
||||||
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||||
|
import type { ReplyToMode } from "../../config/config.js";
|
||||||
|
import { danger, logVerbose } from "../../globals.js";
|
||||||
|
import { formatErrorMessage } from "../../infra/errors.js";
|
||||||
|
import { mediaKindFromMime } from "../../media/constants.js";
|
||||||
|
import { fetchRemoteMedia } from "../../media/fetch.js";
|
||||||
|
import { isGifMedia } from "../../media/mime.js";
|
||||||
|
import { saveMediaBuffer } from "../../media/store.js";
|
||||||
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
|
import { markdownToTelegramHtml } from "../format.js";
|
||||||
|
import { resolveTelegramVoiceSend } from "../voice.js";
|
||||||
|
import {
|
||||||
|
buildTelegramThreadParams,
|
||||||
|
resolveTelegramReplyId,
|
||||||
|
} from "./helpers.js";
|
||||||
|
import type { TelegramContext } from "./types.js";
|
||||||
|
|
||||||
|
const PARSE_ERR_RE =
|
||||||
|
/can't parse entities|parse entities|find end of the entity/i;
|
||||||
|
|
||||||
|
export async function deliverReplies(params: {
|
||||||
|
replies: ReplyPayload[];
|
||||||
|
chatId: string;
|
||||||
|
token: string;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
bot: Bot;
|
||||||
|
replyToMode: ReplyToMode;
|
||||||
|
textLimit: number;
|
||||||
|
messageThreadId?: number;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
replies,
|
||||||
|
chatId,
|
||||||
|
runtime,
|
||||||
|
bot,
|
||||||
|
replyToMode,
|
||||||
|
textLimit,
|
||||||
|
messageThreadId,
|
||||||
|
} = params;
|
||||||
|
const threadParams = buildTelegramThreadParams(messageThreadId);
|
||||||
|
let hasReplied = false;
|
||||||
|
for (const reply of replies) {
|
||||||
|
if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
|
||||||
|
runtime.error?.(danger("reply missing text/media"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const replyToId =
|
||||||
|
replyToMode === "off"
|
||||||
|
? undefined
|
||||||
|
: resolveTelegramReplyId(reply.replyToId);
|
||||||
|
const mediaList = reply.mediaUrls?.length
|
||||||
|
? reply.mediaUrls
|
||||||
|
: reply.mediaUrl
|
||||||
|
? [reply.mediaUrl]
|
||||||
|
: [];
|
||||||
|
if (mediaList.length === 0) {
|
||||||
|
for (const chunk of chunkMarkdownText(reply.text || "", textLimit)) {
|
||||||
|
await sendTelegramText(bot, chatId, chunk, runtime, {
|
||||||
|
replyToMessageId:
|
||||||
|
replyToId && (replyToMode === "all" || !hasReplied)
|
||||||
|
? replyToId
|
||||||
|
: undefined,
|
||||||
|
messageThreadId,
|
||||||
|
});
|
||||||
|
if (replyToId && !hasReplied) {
|
||||||
|
hasReplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// media with optional caption on first item
|
||||||
|
let first = true;
|
||||||
|
for (const mediaUrl of mediaList) {
|
||||||
|
const media = await loadWebMedia(mediaUrl);
|
||||||
|
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
||||||
|
const isGif = isGifMedia({
|
||||||
|
contentType: media.contentType,
|
||||||
|
fileName: media.fileName,
|
||||||
|
});
|
||||||
|
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
|
||||||
|
const file = new InputFile(media.buffer, fileName);
|
||||||
|
const caption = first ? (reply.text ?? undefined) : undefined;
|
||||||
|
first = false;
|
||||||
|
const replyToMessageId =
|
||||||
|
replyToId && (replyToMode === "all" || !hasReplied)
|
||||||
|
? replyToId
|
||||||
|
: undefined;
|
||||||
|
const mediaParams: Record<string, unknown> = {
|
||||||
|
caption,
|
||||||
|
reply_to_message_id: replyToMessageId,
|
||||||
|
};
|
||||||
|
if (threadParams) {
|
||||||
|
mediaParams.message_thread_id = threadParams.message_thread_id;
|
||||||
|
}
|
||||||
|
if (isGif) {
|
||||||
|
await bot.api.sendAnimation(chatId, file, {
|
||||||
|
...mediaParams,
|
||||||
|
});
|
||||||
|
} else if (kind === "image") {
|
||||||
|
await bot.api.sendPhoto(chatId, file, {
|
||||||
|
...mediaParams,
|
||||||
|
});
|
||||||
|
} else if (kind === "video") {
|
||||||
|
await bot.api.sendVideo(chatId, file, {
|
||||||
|
...mediaParams,
|
||||||
|
});
|
||||||
|
} else if (kind === "audio") {
|
||||||
|
const { useVoice } = resolveTelegramVoiceSend({
|
||||||
|
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
|
||||||
|
contentType: media.contentType,
|
||||||
|
fileName,
|
||||||
|
logFallback: logVerbose,
|
||||||
|
});
|
||||||
|
if (useVoice) {
|
||||||
|
// Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]])
|
||||||
|
await bot.api.sendVoice(chatId, file, {
|
||||||
|
...mediaParams,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Audio file - displays with metadata (title, duration) - DEFAULT
|
||||||
|
await bot.api.sendAudio(chatId, file, {
|
||||||
|
...mediaParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await bot.api.sendDocument(chatId, file, {
|
||||||
|
...mediaParams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (replyToId && !hasReplied) {
|
||||||
|
hasReplied = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveMedia(
|
||||||
|
ctx: TelegramContext,
|
||||||
|
maxBytes: number,
|
||||||
|
token: string,
|
||||||
|
proxyFetch?: typeof fetch,
|
||||||
|
): Promise<{ path: string; contentType?: string; placeholder: string } | null> {
|
||||||
|
const msg = ctx.message;
|
||||||
|
const m =
|
||||||
|
msg.photo?.[msg.photo.length - 1] ??
|
||||||
|
msg.video ??
|
||||||
|
msg.document ??
|
||||||
|
msg.audio ??
|
||||||
|
msg.voice;
|
||||||
|
if (!m?.file_id) return null;
|
||||||
|
const file = await ctx.getFile();
|
||||||
|
if (!file.file_path) {
|
||||||
|
throw new Error("Telegram getFile returned no file_path");
|
||||||
|
}
|
||||||
|
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
||||||
|
if (!fetchImpl) {
|
||||||
|
throw new Error(
|
||||||
|
"fetch is not available; set channels.telegram.proxy in config",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`;
|
||||||
|
const fetched = await fetchRemoteMedia({
|
||||||
|
url,
|
||||||
|
fetchImpl,
|
||||||
|
filePathHint: file.file_path,
|
||||||
|
});
|
||||||
|
const saved = await saveMediaBuffer(
|
||||||
|
fetched.buffer,
|
||||||
|
fetched.contentType,
|
||||||
|
"inbound",
|
||||||
|
maxBytes,
|
||||||
|
);
|
||||||
|
let placeholder = "<media:document>";
|
||||||
|
if (msg.photo) placeholder = "<media:image>";
|
||||||
|
else if (msg.video) placeholder = "<media:video>";
|
||||||
|
else if (msg.audio || msg.voice) placeholder = "<media:audio>";
|
||||||
|
return { path: saved.path, contentType: saved.contentType, placeholder };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTelegramText(
|
||||||
|
bot: Bot,
|
||||||
|
chatId: string,
|
||||||
|
text: string,
|
||||||
|
runtime: RuntimeEnv,
|
||||||
|
opts?: { replyToMessageId?: number; messageThreadId?: number },
|
||||||
|
): Promise<number | undefined> {
|
||||||
|
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
|
||||||
|
const baseParams: Record<string, unknown> = {
|
||||||
|
reply_to_message_id: opts?.replyToMessageId,
|
||||||
|
};
|
||||||
|
if (threadParams) {
|
||||||
|
baseParams.message_thread_id = threadParams.message_thread_id;
|
||||||
|
}
|
||||||
|
const htmlText = markdownToTelegramHtml(text);
|
||||||
|
try {
|
||||||
|
const res = await bot.api.sendMessage(chatId, htmlText, {
|
||||||
|
parse_mode: "HTML",
|
||||||
|
...baseParams,
|
||||||
|
});
|
||||||
|
return res.message_id;
|
||||||
|
} catch (err) {
|
||||||
|
const errText = formatErrorMessage(err);
|
||||||
|
if (PARSE_ERR_RE.test(errText)) {
|
||||||
|
runtime.log?.(
|
||||||
|
`telegram HTML parse failed; retrying without formatting: ${errText}`,
|
||||||
|
);
|
||||||
|
const res = await bot.api.sendMessage(chatId, text, {
|
||||||
|
...baseParams,
|
||||||
|
});
|
||||||
|
return res.message_id;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
193
src/telegram/bot/helpers.ts
Normal file
193
src/telegram/bot/helpers.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import {
|
||||||
|
formatLocationText,
|
||||||
|
type NormalizedLocation,
|
||||||
|
} from "../../channels/location.js";
|
||||||
|
import type { TelegramAccountConfig } from "../../config/types.telegram.js";
|
||||||
|
import type {
|
||||||
|
TelegramLocation,
|
||||||
|
TelegramMessage,
|
||||||
|
TelegramStreamMode,
|
||||||
|
TelegramVenue,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const TELEGRAM_GENERAL_TOPIC_ID = 1;
|
||||||
|
|
||||||
|
export function resolveTelegramForumThreadId(params: {
|
||||||
|
isForum?: boolean;
|
||||||
|
messageThreadId?: number | null;
|
||||||
|
}) {
|
||||||
|
if (params.isForum && params.messageThreadId == null) {
|
||||||
|
return TELEGRAM_GENERAL_TOPIC_ID;
|
||||||
|
}
|
||||||
|
return params.messageThreadId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTelegramThreadParams(messageThreadId?: number) {
|
||||||
|
return messageThreadId != null
|
||||||
|
? { message_thread_id: messageThreadId }
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTelegramStreamMode(
|
||||||
|
telegramCfg: Pick<TelegramAccountConfig, "streamMode"> | undefined,
|
||||||
|
): TelegramStreamMode {
|
||||||
|
const raw = telegramCfg?.streamMode?.trim().toLowerCase();
|
||||||
|
if (raw === "off" || raw === "partial" || raw === "block") return raw;
|
||||||
|
return "partial";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTelegramGroupPeerId(
|
||||||
|
chatId: number | string,
|
||||||
|
messageThreadId?: number,
|
||||||
|
) {
|
||||||
|
return messageThreadId != null
|
||||||
|
? `${chatId}:topic:${messageThreadId}`
|
||||||
|
: String(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTelegramGroupFrom(
|
||||||
|
chatId: number | string,
|
||||||
|
messageThreadId?: number,
|
||||||
|
) {
|
||||||
|
return messageThreadId != null
|
||||||
|
? `group:${chatId}:topic:${messageThreadId}`
|
||||||
|
: `group:${chatId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSenderName(msg: TelegramMessage) {
|
||||||
|
const name =
|
||||||
|
[msg.from?.first_name, msg.from?.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.trim() || msg.from?.username;
|
||||||
|
return name || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSenderLabel(
|
||||||
|
msg: TelegramMessage,
|
||||||
|
senderId?: number | string,
|
||||||
|
) {
|
||||||
|
const name = buildSenderName(msg);
|
||||||
|
const username = msg.from?.username ? `@${msg.from.username}` : undefined;
|
||||||
|
let label = name;
|
||||||
|
if (name && username) {
|
||||||
|
label = `${name} (${username})`;
|
||||||
|
} else if (!name && username) {
|
||||||
|
label = username;
|
||||||
|
}
|
||||||
|
const normalizedSenderId =
|
||||||
|
senderId != null && `${senderId}`.trim() ? `${senderId}`.trim() : undefined;
|
||||||
|
const fallbackId =
|
||||||
|
normalizedSenderId ??
|
||||||
|
(msg.from?.id != null ? String(msg.from.id) : undefined);
|
||||||
|
const idPart = fallbackId ? `id:${fallbackId}` : undefined;
|
||||||
|
if (label && idPart) return `${label} ${idPart}`;
|
||||||
|
if (label) return label;
|
||||||
|
return idPart ?? "id:unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGroupLabel(
|
||||||
|
msg: TelegramMessage,
|
||||||
|
chatId: number | string,
|
||||||
|
messageThreadId?: number,
|
||||||
|
) {
|
||||||
|
const title = msg.chat?.title;
|
||||||
|
const topicSuffix =
|
||||||
|
messageThreadId != null ? ` topic:${messageThreadId}` : "";
|
||||||
|
if (title) return `${title} id:${chatId}${topicSuffix}`;
|
||||||
|
return `group:${chatId}${topicSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGroupFromLabel(
|
||||||
|
msg: TelegramMessage,
|
||||||
|
chatId: number | string,
|
||||||
|
senderId?: number | string,
|
||||||
|
messageThreadId?: number,
|
||||||
|
) {
|
||||||
|
const groupLabel = buildGroupLabel(msg, chatId, messageThreadId);
|
||||||
|
const senderLabel = buildSenderLabel(msg, senderId);
|
||||||
|
return `${groupLabel} from ${senderLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBotMention(msg: TelegramMessage, botUsername: string) {
|
||||||
|
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
|
||||||
|
if (text.includes(`@${botUsername}`)) return true;
|
||||||
|
const entities = msg.entities ?? msg.caption_entities ?? [];
|
||||||
|
for (const ent of entities) {
|
||||||
|
if (ent.type !== "mention") continue;
|
||||||
|
const slice = (msg.text ?? msg.caption ?? "").slice(
|
||||||
|
ent.offset,
|
||||||
|
ent.offset + ent.length,
|
||||||
|
);
|
||||||
|
if (slice.toLowerCase() === `@${botUsername}`) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTelegramReplyId(raw?: string): number | undefined {
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const parsed = Number(raw);
|
||||||
|
if (!Number.isFinite(parsed)) return undefined;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeReplyTarget(msg: TelegramMessage) {
|
||||||
|
const reply = msg.reply_to_message;
|
||||||
|
if (!reply) return null;
|
||||||
|
const replyBody = (reply.text ?? reply.caption ?? "").trim();
|
||||||
|
let body = replyBody;
|
||||||
|
if (!body) {
|
||||||
|
if (reply.photo) body = "<media:image>";
|
||||||
|
else if (reply.video) body = "<media:video>";
|
||||||
|
else if (reply.audio || reply.voice) body = "<media:audio>";
|
||||||
|
else if (reply.document) body = "<media:document>";
|
||||||
|
else {
|
||||||
|
const locationData = extractTelegramLocation(reply);
|
||||||
|
if (locationData) body = formatLocationText(locationData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!body) return null;
|
||||||
|
const sender = buildSenderName(reply);
|
||||||
|
const senderLabel = sender ? `${sender}` : "unknown sender";
|
||||||
|
return {
|
||||||
|
id: reply.message_id ? String(reply.message_id) : undefined,
|
||||||
|
sender: senderLabel,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTelegramLocation(
|
||||||
|
msg: TelegramMessage,
|
||||||
|
): NormalizedLocation | null {
|
||||||
|
const msgWithLocation = msg as {
|
||||||
|
location?: TelegramLocation;
|
||||||
|
venue?: TelegramVenue;
|
||||||
|
};
|
||||||
|
const { venue, location } = msgWithLocation;
|
||||||
|
|
||||||
|
if (venue) {
|
||||||
|
return {
|
||||||
|
latitude: venue.location.latitude,
|
||||||
|
longitude: venue.location.longitude,
|
||||||
|
accuracy: venue.location.horizontal_accuracy,
|
||||||
|
name: venue.title,
|
||||||
|
address: venue.address,
|
||||||
|
source: "place",
|
||||||
|
isLive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location) {
|
||||||
|
const isLive =
|
||||||
|
typeof location.live_period === "number" && location.live_period > 0;
|
||||||
|
return {
|
||||||
|
latitude: location.latitude,
|
||||||
|
longitude: location.longitude,
|
||||||
|
accuracy: location.horizontal_accuracy,
|
||||||
|
source: isLive ? "live" : "pin",
|
||||||
|
isLive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
33
src/telegram/bot/types.ts
Normal file
33
src/telegram/bot/types.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Message } from "@grammyjs/types";
|
||||||
|
|
||||||
|
export type TelegramMessage = Message;
|
||||||
|
|
||||||
|
export type TelegramStreamMode = "off" | "partial" | "block";
|
||||||
|
|
||||||
|
export type TelegramContext = {
|
||||||
|
message: TelegramMessage;
|
||||||
|
me?: { username?: string };
|
||||||
|
getFile: () => Promise<{
|
||||||
|
file_path?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Telegram Location object */
|
||||||
|
export interface TelegramLocation {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
horizontal_accuracy?: number;
|
||||||
|
live_period?: number;
|
||||||
|
heading?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Telegram Venue object */
|
||||||
|
export interface TelegramVenue {
|
||||||
|
location: TelegramLocation;
|
||||||
|
title: string;
|
||||||
|
address: string;
|
||||||
|
foursquare_id?: string;
|
||||||
|
foursquare_type?: string;
|
||||||
|
google_place_id?: string;
|
||||||
|
google_place_type?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user