Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
791 lines
23 KiB
TypeScript
791 lines
23 KiB
TypeScript
/**
|
|
* Telegram media and text extraction helpers
|
|
* Zones: telegram inbound, media groups, filesystem paths
|
|
* Normalizes inbound Telegram messages into reusable file, text, id, history, and media-group metadata
|
|
*/
|
|
|
|
import { basename, dirname } from "node:path";
|
|
|
|
const TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS = 1200;
|
|
const TELEGRAM_REPLY_CONTEXT_MAX_LENGTH = 1000;
|
|
|
|
export interface TelegramPhotoSize {
|
|
file_id: string;
|
|
file_size?: number;
|
|
}
|
|
|
|
export interface TelegramDocument {
|
|
file_id: string;
|
|
file_name?: string;
|
|
mime_type?: string;
|
|
}
|
|
|
|
export type TelegramVideo = TelegramDocument;
|
|
export type TelegramAudio = TelegramDocument;
|
|
export type TelegramAnimation = TelegramDocument;
|
|
|
|
export interface TelegramVoice {
|
|
file_id: string;
|
|
mime_type?: string;
|
|
}
|
|
|
|
export interface TelegramRichMessage {
|
|
blocks?: unknown[];
|
|
}
|
|
|
|
export interface TelegramMessageUser {
|
|
id?: number;
|
|
is_bot?: boolean;
|
|
first_name?: string;
|
|
last_name?: string;
|
|
username?: string;
|
|
}
|
|
|
|
export interface TelegramMessageForwardOrigin {
|
|
type?: string;
|
|
sender_user?: TelegramMessageUser;
|
|
sender_user_name?: string;
|
|
sender_chat?: { title?: string; username?: string; id?: number };
|
|
chat?: { title?: string; username?: string; id?: number };
|
|
author_signature?: string;
|
|
}
|
|
|
|
export interface TelegramReplyToMessage {
|
|
message_id?: number;
|
|
from?: TelegramMessageUser;
|
|
text?: string;
|
|
caption?: string;
|
|
rich_message?: TelegramRichMessage;
|
|
photo?: TelegramPhotoSize[];
|
|
document?: TelegramDocument;
|
|
video?: TelegramVideo;
|
|
audio?: TelegramAudio;
|
|
voice?: TelegramVoice;
|
|
animation?: TelegramAnimation;
|
|
sticker?: TelegramSticker;
|
|
}
|
|
|
|
export interface TelegramSticker {
|
|
file_id: string;
|
|
}
|
|
|
|
export interface TelegramMediaMessage {
|
|
message_id: number;
|
|
from?: TelegramMessageUser;
|
|
forward_origin?: TelegramMessageForwardOrigin;
|
|
forward_from?: TelegramMessageUser;
|
|
forward_sender_name?: string;
|
|
text?: string;
|
|
caption?: string;
|
|
rich_message?: TelegramRichMessage;
|
|
reply_to_message?: TelegramReplyToMessage;
|
|
media_group_id?: string;
|
|
photo?: TelegramPhotoSize[];
|
|
document?: TelegramDocument;
|
|
video?: TelegramVideo;
|
|
audio?: TelegramAudio;
|
|
voice?: TelegramVoice;
|
|
animation?: TelegramAnimation;
|
|
sticker?: TelegramSticker;
|
|
}
|
|
|
|
export interface TelegramMediaGroupMessage {
|
|
message_id: number;
|
|
chat: { id: number };
|
|
message_thread_id?: number;
|
|
media_group_id?: string;
|
|
}
|
|
|
|
export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
|
|
messages: TMessage[];
|
|
context?: TContext;
|
|
flushTimer?: ReturnType<typeof setTimeout>;
|
|
dispatching?: boolean;
|
|
suspended?: boolean;
|
|
reschedule?: () => void;
|
|
}
|
|
|
|
export interface TelegramMediaGroupController<
|
|
TMessage extends TelegramMediaGroupMessage,
|
|
TContext = unknown,
|
|
> {
|
|
queueMessage: (options: {
|
|
message: TMessage;
|
|
context?: TContext;
|
|
dispatchMessages: (
|
|
messages: TMessage[],
|
|
ctx?: TContext,
|
|
) => unknown | Promise<unknown>;
|
|
}) => boolean;
|
|
removeMessages: (messageIds: number[]) => number;
|
|
suspend: () => void;
|
|
resume: (context: TContext) => void;
|
|
clear: () => void;
|
|
}
|
|
|
|
export interface TelegramMediaGroupDispatchRuntimeDeps<
|
|
TMessage extends TelegramMediaGroupMessage,
|
|
TContext,
|
|
> {
|
|
mediaGroups: TelegramMediaGroupController<TMessage, TContext>;
|
|
dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
|
|
}
|
|
|
|
export interface TelegramMediaGroupDispatchRuntime<
|
|
TMessage extends TelegramMediaGroupMessage,
|
|
TContext,
|
|
> {
|
|
handleMessage: (message: TMessage, ctx: TContext) => Promise<void>;
|
|
}
|
|
|
|
export interface TelegramMediaGroupControllerOptions {
|
|
debounceMs?: number;
|
|
setTimer?: (
|
|
callback: () => void,
|
|
ms: number,
|
|
) => ReturnType<typeof setTimeout>;
|
|
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
}
|
|
|
|
export type TelegramAttachmentKind =
|
|
"photo" | "document" | "video" | "audio" | "voice" | "animation" | "sticker";
|
|
|
|
export interface TelegramFileInfo {
|
|
file_id: string;
|
|
fileName: string;
|
|
mimeType?: string;
|
|
kind: TelegramAttachmentKind;
|
|
isImage: boolean;
|
|
}
|
|
|
|
export interface DownloadedTelegramFile {
|
|
path: string;
|
|
fileName?: string;
|
|
isImage?: boolean;
|
|
mimeType?: string;
|
|
kind?: TelegramAttachmentKind;
|
|
}
|
|
|
|
export interface DownloadedTelegramMessageFile {
|
|
path: string;
|
|
fileName: string;
|
|
isImage: boolean;
|
|
mimeType?: string;
|
|
kind?: TelegramAttachmentKind;
|
|
}
|
|
|
|
export interface DownloadTelegramMessageFilesDeps {
|
|
downloadFile: (fileId: string, fileName: string) => Promise<string>;
|
|
}
|
|
|
|
export function guessExtensionFromMime(
|
|
mimeType: string | undefined,
|
|
fallback: string,
|
|
): string {
|
|
if (!mimeType) return fallback;
|
|
const normalized = mimeType.toLowerCase();
|
|
if (normalized === "image/jpeg") return ".jpg";
|
|
if (normalized === "image/png") return ".png";
|
|
if (normalized === "image/webp") return ".webp";
|
|
if (normalized === "image/gif") return ".gif";
|
|
if (normalized === "audio/ogg") return ".ogg";
|
|
if (normalized === "audio/mpeg") return ".mp3";
|
|
if (normalized === "audio/wav") return ".wav";
|
|
if (normalized === "video/mp4") return ".mp4";
|
|
if (normalized === "application/pdf") return ".pdf";
|
|
return fallback;
|
|
}
|
|
|
|
export function guessMediaType(path: string): string | undefined {
|
|
const normalized = path.toLowerCase();
|
|
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) {
|
|
return "image/jpeg";
|
|
}
|
|
if (normalized.endsWith(".png")) return "image/png";
|
|
if (normalized.endsWith(".webp")) return "image/webp";
|
|
if (normalized.endsWith(".gif")) return "image/gif";
|
|
return undefined;
|
|
}
|
|
|
|
function isImageMimeType(mimeType: string | undefined): boolean {
|
|
return mimeType?.toLowerCase().startsWith("image/") ?? false;
|
|
}
|
|
|
|
function getObjectField(value: unknown, field: string): unknown {
|
|
if (typeof value !== "object" || value === null || !(field in value)) {
|
|
return undefined;
|
|
}
|
|
return Reflect.get(value, field);
|
|
}
|
|
|
|
function joinRichTextParts(parts: string[], separator = ""): string {
|
|
return parts.filter(Boolean).join(separator).trim();
|
|
}
|
|
|
|
function extractTelegramRichText(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
if (Array.isArray(value)) {
|
|
return joinRichTextParts(value.map(extractTelegramRichText));
|
|
}
|
|
if (typeof value !== "object" || value === null) return "";
|
|
const text = getObjectField(value, "text");
|
|
if (text !== undefined) return extractTelegramRichText(text);
|
|
const expression = getObjectField(value, "expression");
|
|
if (typeof expression === "string") return expression;
|
|
const alternativeText = getObjectField(value, "alternative_text");
|
|
if (typeof alternativeText === "string") return alternativeText;
|
|
return "";
|
|
}
|
|
|
|
function extractTelegramRichBlockText(block: unknown): string {
|
|
if (typeof block !== "object" || block === null) return "";
|
|
const directText = extractTelegramRichText(getObjectField(block, "text"));
|
|
if (directText) return directText;
|
|
const summary = extractTelegramRichText(getObjectField(block, "summary"));
|
|
const nestedBlocks = extractTelegramRichMessageBlocksText(
|
|
getObjectField(block, "blocks"),
|
|
);
|
|
const items = getObjectField(block, "items");
|
|
const itemText = Array.isArray(items)
|
|
? items
|
|
.map((item) => {
|
|
const label = getObjectField(item, "label");
|
|
const body = extractTelegramRichMessageBlocksText(
|
|
getObjectField(item, "blocks"),
|
|
);
|
|
return typeof label === "string" && body ? `${label} ${body}` : body;
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
: "";
|
|
const cells = getObjectField(block, "cells");
|
|
const cellText = Array.isArray(cells)
|
|
? cells
|
|
.map((row) =>
|
|
Array.isArray(row)
|
|
? row
|
|
.map((cell) =>
|
|
extractTelegramRichText(getObjectField(cell, "text")),
|
|
)
|
|
.filter(Boolean)
|
|
.join(" | ")
|
|
: "",
|
|
)
|
|
.filter(Boolean)
|
|
.join("\n")
|
|
: "";
|
|
const caption = extractTelegramRichText(getObjectField(block, "caption"));
|
|
return joinRichTextParts(
|
|
[summary, nestedBlocks, itemText, cellText, caption],
|
|
"\n",
|
|
);
|
|
}
|
|
|
|
function extractTelegramRichMessageBlocksText(blocks: unknown): string {
|
|
if (!Array.isArray(blocks)) return "";
|
|
return joinRichTextParts(blocks.map(extractTelegramRichBlockText), "\n\n");
|
|
}
|
|
|
|
function extractTelegramRichMessageText(
|
|
richMessage: TelegramRichMessage | undefined,
|
|
): string {
|
|
return extractTelegramRichMessageBlocksText(richMessage?.blocks);
|
|
}
|
|
|
|
export function extractTelegramMessageText(
|
|
message: TelegramMediaMessage,
|
|
): string {
|
|
return (
|
|
extractTelegramRichMessageText(message.rich_message) ||
|
|
message.text ||
|
|
message.caption ||
|
|
""
|
|
).trim();
|
|
}
|
|
|
|
function truncateTelegramReplyContextText(text: string): string {
|
|
if (text.length <= TELEGRAM_REPLY_CONTEXT_MAX_LENGTH) return text;
|
|
return `${text.slice(0, TELEGRAM_REPLY_CONTEXT_MAX_LENGTH).trimEnd()}…`;
|
|
}
|
|
|
|
function formatTelegramUser(
|
|
user: TelegramMessageUser | undefined,
|
|
): string | undefined {
|
|
if (!user) return undefined;
|
|
if (user.username) return user.username;
|
|
if (typeof user.id === "number") return String(user.id);
|
|
const name = [user.first_name, user.last_name]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.trim();
|
|
return name || undefined;
|
|
}
|
|
|
|
function formatTelegramForwardOriginIdentifier(
|
|
message: TelegramMediaMessage,
|
|
): string | undefined {
|
|
const origin = message.forward_origin;
|
|
const user = origin?.sender_user ?? message.forward_from;
|
|
if (user?.username) return user.username;
|
|
if (typeof user?.id === "number") return String(user.id);
|
|
const chat = origin?.sender_chat ?? origin?.chat;
|
|
if (chat?.username) return chat.username;
|
|
if (typeof chat?.id === "number") return String(chat.id);
|
|
return origin?.sender_user_name ?? message.forward_sender_name;
|
|
}
|
|
|
|
export function extractTelegramForwardContextText(
|
|
message: TelegramMediaMessage,
|
|
allowedUserId?: number,
|
|
): string {
|
|
const originUser =
|
|
message.forward_origin?.sender_user ?? message.forward_from;
|
|
const isOwnerOrigin =
|
|
typeof allowedUserId === "number" && originUser?.id === allowedUserId;
|
|
const origin = formatTelegramForwardOriginIdentifier(message);
|
|
if (!origin || isOwnerOrigin) return "";
|
|
return `from: ${origin}`;
|
|
}
|
|
|
|
export function extractTelegramReplyContextText(
|
|
message: TelegramMediaMessage,
|
|
): string {
|
|
const quoted = (
|
|
extractTelegramRichMessageText(message.reply_to_message?.rich_message) ||
|
|
message.reply_to_message?.text ||
|
|
message.reply_to_message?.caption ||
|
|
""
|
|
).trim();
|
|
return quoted ? truncateTelegramReplyContextText(quoted) : "";
|
|
}
|
|
|
|
export function buildTelegramReplyContextBlock(
|
|
message: TelegramMediaMessage,
|
|
replyFiles: Pick<DownloadedTelegramFile, "path">[] = [],
|
|
): string {
|
|
const from = formatTelegramUser(message.reply_to_message?.from);
|
|
const header = from ? `[reply|from:${from}]` : "[reply]";
|
|
const text = extractTelegramReplyContextText(message);
|
|
const dirs = [...new Set(replyFiles.map((file) => dirname(file.path)))];
|
|
const sameDir = dirs.length === 1;
|
|
const attachmentHeader = sameDir
|
|
? `[attachments${from ? `|from:${from}` : ""}] ${dirs[0]}`
|
|
: `[attachments${from ? `|from:${from}` : ""}]`;
|
|
const fileLines = sameDir
|
|
? replyFiles.map((file) => `- /${basename(file.path)}`)
|
|
: replyFiles.map((file) => `- ${file.path}`);
|
|
const replyBlock = text ? `${header} ${text}` : header;
|
|
if (fileLines.length > 0) {
|
|
return `${replyBlock}\n\n${attachmentHeader}\n${fileLines.join("\n")}`;
|
|
}
|
|
if (text) return replyBlock;
|
|
return "";
|
|
}
|
|
|
|
export function appendTelegramReplyContext(
|
|
text: string,
|
|
replyContext: string,
|
|
): string {
|
|
if (!replyContext) return text;
|
|
return text ? `${text}\n\n${replyContext}` : `_\n\n${replyContext}`;
|
|
}
|
|
|
|
export function appendTelegramForwardContext(
|
|
text: string,
|
|
forwardContext: string,
|
|
): string {
|
|
if (!forwardContext) return text;
|
|
const forwardBlock = `[forward|${forwardContext.replace(/:\s+/g, ":")}]`;
|
|
return text ? `\n\n${forwardBlock} ${text}` : `\n\n${forwardBlock}`;
|
|
}
|
|
|
|
export function extractTelegramMessagePromptText(
|
|
message: TelegramMediaMessage,
|
|
): string {
|
|
return appendTelegramReplyContext(
|
|
extractTelegramMessageText(message),
|
|
buildTelegramReplyContextBlock(message),
|
|
);
|
|
}
|
|
|
|
export function extractTelegramMessagesText(
|
|
messages: TelegramMediaMessage[],
|
|
): string {
|
|
return messages.map(extractTelegramMessageText).filter(Boolean).join("\n\n");
|
|
}
|
|
|
|
export function extractTelegramMessagesPromptText(
|
|
messages: TelegramMediaMessage[],
|
|
): string {
|
|
const text = extractTelegramMessagesText(messages);
|
|
const firstMessage = messages[0];
|
|
if (!firstMessage) return text;
|
|
return appendTelegramReplyContext(
|
|
text,
|
|
buildTelegramReplyContextBlock(firstMessage),
|
|
);
|
|
}
|
|
|
|
export function extractFirstTelegramMessageText(
|
|
messages: TelegramMediaMessage[],
|
|
): string {
|
|
return messages.map(extractTelegramMessageText).find(Boolean) ?? "";
|
|
}
|
|
|
|
export function hasTelegramMessagePromptContent(
|
|
message: TelegramMediaMessage,
|
|
): boolean {
|
|
return (
|
|
!!extractTelegramMessageText(message) ||
|
|
(Array.isArray(message.photo) && message.photo.length > 0) ||
|
|
!!message.document ||
|
|
!!message.video ||
|
|
!!message.audio ||
|
|
!!message.voice ||
|
|
!!message.animation ||
|
|
!!message.sticker
|
|
);
|
|
}
|
|
|
|
export function hasTelegramMessagesPromptContent(
|
|
messages: TelegramMediaMessage[],
|
|
): boolean {
|
|
return messages.some(hasTelegramMessagePromptContent);
|
|
}
|
|
|
|
export function collectTelegramMessageIds(
|
|
messages: TelegramMediaMessage[],
|
|
): number[] {
|
|
return [...new Set(messages.map((message) => message.message_id))];
|
|
}
|
|
|
|
export function getTelegramMediaGroupKey(
|
|
message: TelegramMediaGroupMessage,
|
|
): string | undefined {
|
|
if (!message.media_group_id) return undefined;
|
|
const threadKey =
|
|
typeof message.message_thread_id === "number"
|
|
? `thread:${message.message_thread_id}`
|
|
: "private";
|
|
return `${message.chat.id}:${threadKey}:${message.media_group_id}`;
|
|
}
|
|
|
|
export function removePendingTelegramMediaGroupMessages<
|
|
TMessage extends TelegramMediaGroupMessage,
|
|
>(
|
|
groups: Map<string, TelegramMediaGroupState<TMessage, unknown>>,
|
|
messageIds: number[],
|
|
clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
|
|
): number {
|
|
if (messageIds.length === 0 || groups.size === 0) return 0;
|
|
const deletedMessageIds = new Set(messageIds);
|
|
let removedGroups = 0;
|
|
for (const [key, state] of groups.entries()) {
|
|
if (
|
|
!state.messages.some((message) =>
|
|
deletedMessageIds.has(message.message_id),
|
|
)
|
|
) {
|
|
continue;
|
|
}
|
|
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
groups.delete(key);
|
|
removedGroups += 1;
|
|
}
|
|
return removedGroups;
|
|
}
|
|
|
|
export function queueTelegramMediaGroupMessage<
|
|
TMessage extends TelegramMediaGroupMessage,
|
|
TContext = unknown,
|
|
>(options: {
|
|
message: TMessage;
|
|
context?: TContext;
|
|
groups: Map<string, TelegramMediaGroupState<TMessage, TContext>>;
|
|
debounceMs: number;
|
|
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
|
|
dispatchMessages: (
|
|
messages: TMessage[],
|
|
ctx?: TContext,
|
|
) => unknown | Promise<unknown>;
|
|
}): boolean {
|
|
const key = getTelegramMediaGroupKey(options.message);
|
|
if (!key) return false;
|
|
const existing = options.groups.get(key) ?? { messages: [] };
|
|
existing.messages.push(options.message);
|
|
existing.context = options.context;
|
|
const scheduleDispatch = (): void => {
|
|
if (existing.suspended) return;
|
|
existing.flushTimer = options.setTimer(() => {
|
|
existing.flushTimer = undefined;
|
|
const state = options.groups.get(key);
|
|
if (!state) return;
|
|
if (state.dispatching) {
|
|
scheduleDispatch();
|
|
return;
|
|
}
|
|
const dispatchedMessages = [...state.messages];
|
|
const dispatchedIds = new Set(
|
|
dispatchedMessages.map((message) => message.message_id),
|
|
);
|
|
state.dispatching = true;
|
|
void Promise.resolve(
|
|
options.dispatchMessages(dispatchedMessages, state.context),
|
|
).then(
|
|
() => {
|
|
if (options.groups.get(key) !== state) return;
|
|
state.messages = state.messages.filter(
|
|
(message) => !dispatchedIds.has(message.message_id),
|
|
);
|
|
state.dispatching = false;
|
|
if (state.messages.length === 0) options.groups.delete(key);
|
|
else if (!state.flushTimer) scheduleDispatch();
|
|
},
|
|
() => {
|
|
if (options.groups.get(key) !== state) return;
|
|
state.dispatching = false;
|
|
if (!state.flushTimer) scheduleDispatch();
|
|
},
|
|
);
|
|
}, options.debounceMs);
|
|
existing.flushTimer.unref?.();
|
|
};
|
|
existing.reschedule = scheduleDispatch;
|
|
if (existing.flushTimer) options.clearTimer(existing.flushTimer);
|
|
scheduleDispatch();
|
|
options.groups.set(key, existing);
|
|
return true;
|
|
}
|
|
|
|
export function createTelegramMediaGroupController<
|
|
TMessage extends TelegramMediaGroupMessage,
|
|
TContext = unknown,
|
|
>(
|
|
options: TelegramMediaGroupControllerOptions = {},
|
|
): TelegramMediaGroupController<TMessage, TContext> {
|
|
const groups = new Map<string, TelegramMediaGroupState<TMessage, TContext>>();
|
|
const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
|
|
const setTimer =
|
|
options.setTimer ??
|
|
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
|
|
setTimeout(callback, ms));
|
|
const clearTimer = options.clearTimer ?? clearTimeout;
|
|
return {
|
|
queueMessage: ({ message, context, dispatchMessages }) =>
|
|
queueTelegramMediaGroupMessage({
|
|
message,
|
|
context,
|
|
groups,
|
|
debounceMs,
|
|
setTimer,
|
|
clearTimer,
|
|
dispatchMessages,
|
|
}),
|
|
removeMessages: (messageIds) =>
|
|
removePendingTelegramMediaGroupMessages(groups, messageIds, clearTimer),
|
|
suspend: () => {
|
|
for (const state of groups.values()) {
|
|
state.suspended = true;
|
|
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
state.flushTimer = undefined;
|
|
}
|
|
},
|
|
resume: (context) => {
|
|
for (const state of groups.values()) {
|
|
state.context = context;
|
|
state.suspended = false;
|
|
if (!state.dispatching && !state.flushTimer) state.reschedule?.();
|
|
}
|
|
},
|
|
clear: () => {
|
|
for (const state of groups.values()) {
|
|
if (state.flushTimer) clearTimer(state.flushTimer);
|
|
}
|
|
groups.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createTelegramMediaGroupDispatchRuntime<
|
|
TMessage extends TelegramMediaGroupMessage,
|
|
TContext,
|
|
>(
|
|
deps: TelegramMediaGroupDispatchRuntimeDeps<TMessage, TContext>,
|
|
): TelegramMediaGroupDispatchRuntime<TMessage, TContext> {
|
|
return {
|
|
handleMessage: async (message, ctx) => {
|
|
const queuedMediaGroup = deps.mediaGroups.queueMessage({
|
|
message,
|
|
context: ctx,
|
|
dispatchMessages: (messages, queuedCtx) =>
|
|
queuedCtx === undefined
|
|
? Promise.resolve()
|
|
: deps.dispatchMessages(messages, queuedCtx),
|
|
});
|
|
if (queuedMediaGroup) return;
|
|
await deps.dispatchMessages([message], ctx);
|
|
},
|
|
};
|
|
}
|
|
|
|
function appendTelegramListSection(
|
|
text: string,
|
|
title: string,
|
|
items: string[],
|
|
): string {
|
|
if (items.length === 0) return text;
|
|
const prefix = text.length > 0 ? `${text}\n\n` : "";
|
|
return `${prefix}[${title}]\n${items.map((item) => `- ${item}`).join("\n")}`;
|
|
}
|
|
|
|
function appendTelegramAttachmentSection(
|
|
text: string,
|
|
files: Pick<DownloadedTelegramFile, "path">[],
|
|
): string {
|
|
if (files.length === 0) return text;
|
|
const dirs = [...new Set(files.map((file) => dirname(file.path)))];
|
|
const sameDir = dirs.length === 1;
|
|
const header = sameDir ? `[attachments] ${dirs[0]}` : "[attachments]";
|
|
const items = sameDir
|
|
? files.map((file) => `/${basename(file.path)}`)
|
|
: files.map((file) => file.path);
|
|
const prefix = text.length > 0 ? `${text}\n\n` : "";
|
|
return `${prefix}${header}\n${items.map((item) => `- ${item}`).join("\n")}`;
|
|
}
|
|
|
|
export function formatTelegramHistoryText(
|
|
rawText: string,
|
|
files: DownloadedTelegramFile[],
|
|
handlerOutputs: string[] = [],
|
|
): string {
|
|
let summary = rawText.length > 0 ? rawText : "(no text)";
|
|
summary = appendTelegramAttachmentSection(summary, files);
|
|
summary = appendTelegramListSection(summary, "outputs", handlerOutputs);
|
|
return summary;
|
|
}
|
|
|
|
export async function downloadTelegramMessageFiles(
|
|
messages: TelegramMediaMessage[],
|
|
deps: DownloadTelegramMessageFilesDeps,
|
|
): Promise<DownloadedTelegramMessageFile[]> {
|
|
const downloaded: DownloadedTelegramMessageFile[] = [];
|
|
for (const file of collectTelegramFileInfos(messages)) {
|
|
downloaded.push({
|
|
path: await deps.downloadFile(file.file_id, file.fileName),
|
|
fileName: file.fileName,
|
|
isImage: file.isImage,
|
|
mimeType: file.mimeType,
|
|
kind: file.kind,
|
|
});
|
|
}
|
|
return downloaded;
|
|
}
|
|
|
|
export function collectTelegramFileInfos(
|
|
messages: TelegramMediaMessage[],
|
|
): TelegramFileInfo[] {
|
|
const files: TelegramFileInfo[] = [];
|
|
for (const message of messages) {
|
|
if (Array.isArray(message.photo) && message.photo.length > 0) {
|
|
const photo = [...message.photo]
|
|
.sort((a, b) => (a.file_size ?? 0) - (b.file_size ?? 0))
|
|
.pop();
|
|
if (photo) {
|
|
files.push({
|
|
file_id: photo.file_id,
|
|
fileName: `photo-${message.message_id}.jpg`,
|
|
mimeType: "image/jpeg",
|
|
kind: "photo",
|
|
isImage: true,
|
|
});
|
|
}
|
|
}
|
|
if (message.document) {
|
|
const fileName =
|
|
message.document.file_name ||
|
|
`document-${message.message_id}${guessExtensionFromMime(
|
|
message.document.mime_type,
|
|
"",
|
|
)}`;
|
|
files.push({
|
|
file_id: message.document.file_id,
|
|
fileName,
|
|
mimeType: message.document.mime_type,
|
|
kind: "document",
|
|
isImage: isImageMimeType(message.document.mime_type),
|
|
});
|
|
}
|
|
if (message.video) {
|
|
const fileName =
|
|
message.video.file_name ||
|
|
`video-${message.message_id}${guessExtensionFromMime(
|
|
message.video.mime_type,
|
|
".mp4",
|
|
)}`;
|
|
files.push({
|
|
file_id: message.video.file_id,
|
|
fileName,
|
|
mimeType: message.video.mime_type,
|
|
kind: "video",
|
|
isImage: false,
|
|
});
|
|
}
|
|
// Generic audio files (e.g. MP3 uploads) — can also trigger voice replies in "mirror" mode
|
|
if (message.audio) {
|
|
const fileName =
|
|
message.audio.file_name ||
|
|
`audio-${message.message_id}${guessExtensionFromMime(
|
|
message.audio.mime_type,
|
|
".mp3",
|
|
)}`;
|
|
files.push({
|
|
file_id: message.audio.file_id,
|
|
fileName,
|
|
mimeType: message.audio.mime_type,
|
|
kind: "audio",
|
|
isImage: false,
|
|
});
|
|
}
|
|
|
|
// Voice messages (recorded via microphone) — primary trigger for "mirror" voice reply mode
|
|
if (message.voice) {
|
|
files.push({
|
|
file_id: message.voice.file_id,
|
|
fileName: `voice-${message.message_id}${guessExtensionFromMime(
|
|
message.voice.mime_type,
|
|
".ogg",
|
|
)}`,
|
|
mimeType: message.voice.mime_type,
|
|
kind: "voice",
|
|
isImage: false,
|
|
});
|
|
}
|
|
if (message.animation) {
|
|
const fileName =
|
|
message.animation.file_name ||
|
|
`animation-${message.message_id}${guessExtensionFromMime(
|
|
message.animation.mime_type,
|
|
".mp4",
|
|
)}`;
|
|
files.push({
|
|
file_id: message.animation.file_id,
|
|
fileName,
|
|
mimeType: message.animation.mime_type,
|
|
kind: "animation",
|
|
isImage: false,
|
|
});
|
|
}
|
|
if (message.sticker) {
|
|
files.push({
|
|
file_id: message.sticker.file_id,
|
|
fileName: `sticker-${message.message_id}.webp`,
|
|
mimeType: "image/webp",
|
|
kind: "sticker",
|
|
isImage: true,
|
|
});
|
|
}
|
|
}
|
|
return files;
|
|
}
|