refactor(telegram): split bot helpers
This commit is contained in:
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