Files
VolcanicSecretary/src/telegram/inbound.ts

211 lines
7.8 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { AgentImage, AgentInput } from "../agent/events.js";
import type { Transcriber } from "../stt/openai.js";
export type ForwardOrigin =
| { type: "user"; sender_user: { first_name: string; last_name?: string; username?: string } }
| { type: "hidden_user"; sender_user_name: string }
| { type: "chat"; sender_chat: { title?: string; username?: string }; author_signature?: string }
| { type: "channel"; chat: { title?: string; username?: string }; author_signature?: string };
export interface RepliedMessage {
from?: { id: number; is_bot?: boolean };
text?: string;
caption?: string;
photo?: Array<unknown>;
voice?: unknown;
audio?: { file_name?: string };
document?: { file_name?: string };
}
export interface Quote {
text: string;
is_manual?: boolean;
}
/** Longest replied-to text (or quote) we inline into the header before truncating with an ellipsis. */
export const MAX_REPLY_CONTEXT_CHARS = 500;
export interface InboundMessage {
text?: string;
caption?: string;
photo?: Array<{ file_id: string; width: number; height: number; file_size?: number }>;
voice?: { file_id: string; mime_type?: string; duration?: number };
audio?: { file_id: string; mime_type?: string; file_name?: string };
document?: { file_id: string; file_name?: string; mime_type?: string };
forward_origin?: ForwardOrigin;
reply_to_message?: RepliedMessage;
quote?: Quote;
}
export interface InboundDeps {
download(fileId: string): Promise<{ bytes: Buffer; filePath: string }>;
transcriber?: Transcriber;
workspaceDir: string;
now?: () => Date;
/** The bot's own user id, for labelling replies to bot vs owner messages. */
botUserId?: number;
}
export interface InboundResult {
input: AgentInput | null;
transcript?: string;
notes: string[];
}
export async function normalizeMessage(msg: InboundMessage, deps: InboundDeps): Promise<InboundResult> {
const notes: string[] = [];
const parts: string[] = [];
const images: AgentImage[] = [];
let transcript: string | undefined;
const base = (msg.text ?? msg.caption ?? "").trim();
if (base) parts.push(base);
if (msg.photo && msg.photo.length > 0) {
const largest = msg.photo.reduce((a, b) => (b.width * b.height > a.width * a.height ? b : a));
try {
const { bytes } = await deps.download(largest.file_id);
images.push({ data: bytes.toString("base64"), mimeType: "image/jpeg" });
} catch (err) {
notes.push(`Could not download photo: ${errorText(err)}`);
}
}
const audio = msg.voice ?? msg.audio;
if (audio) {
if (!deps.transcriber) {
notes.push("Voice transcription is not configured. Set OPENAI_API_KEY to enable it.");
} else {
try {
const { bytes, filePath } = await deps.download(audio.file_id);
const raw = path.basename(filePath) || "voice.ogg";
// Telegram voice messages arrive as .oga (audio-only OGG), which OpenAI's
// transcription API rejects even though the bytes are a valid OGG container.
const filename = raw.toLowerCase().endsWith(".oga") ? `${raw.slice(0, -4)}.ogg` : raw;
transcript = await deps.transcriber.transcribe(bytes, filename, audio.mime_type ?? "audio/ogg");
if (transcript) parts.push(transcript);
else notes.push("The voice message came back empty from transcription.");
} catch (err) {
notes.push(`Could not transcribe voice message: ${errorText(err)}`);
}
}
}
if (msg.document) {
try {
const { bytes } = await deps.download(msg.document.file_id);
const saved = await saveToInbox(deps.workspaceDir, msg.document.file_name ?? "file", bytes, deps.now?.() ?? new Date());
parts.push(`Attached file saved at ${saved}`);
} catch (err) {
notes.push(`Could not save document: ${errorText(err)}`);
}
}
if (parts.length === 0 && images.length > 0) parts.push("(photo attached)");
let text = parts.join("\n\n");
if (msg.forward_origin && (text || images.length > 0)) {
const label = describeForwardOrigin(msg.forward_origin);
text = text ? `[Forwarded from ${label}]\n${text}` : `[Forwarded from ${label}]`;
}
const replyHeader = buildReplyHeader(msg.reply_to_message, msg.quote, deps.botUserId);
if (replyHeader && (text || images.length > 0)) {
text = text ? `${replyHeader}\n${text}` : replyHeader;
}
return {
input: text ? { text, images: images.length > 0 ? images : undefined } : null,
transcript,
notes,
};
}
export function describeForwardOrigin(origin: ForwardOrigin): string {
switch (origin.type) {
case "user": {
const u = origin.sender_user;
if (u.username) return `@${u.username}`;
const name = [u.first_name, u.last_name].filter(Boolean).join(" ");
return name || "unknown user";
}
case "hidden_user":
return origin.sender_user_name || "hidden user";
case "chat": {
const c = origin.sender_chat;
return c.title || (c.username ? `@${c.username}` : "chat");
}
case "channel": {
const c = origin.chat;
return c.title || (c.username ? `@${c.username}` : "channel");
}
}
}
const MAX_STEM = 80;
const MAX_EXT = 16;
/** Writes bytes to <workspace>/inbox/<yyyy-mm-dd>-<safe name>, adding -N on collision. Returns the workspace-relative path. */
export async function saveToInbox(workspaceDir: string, originalName: string, bytes: Buffer, now: Date): Promise<string> {
const inbox = path.join(workspaceDir, "inbox");
await mkdir(inbox, { recursive: true });
const safe = originalName.replace(/[^\w.-]+/g, "_").replace(/^_+|_+$/g, "") || "file";
const rawExt = path.extname(safe);
const ext = rawExt.slice(0, MAX_EXT);
const stem = safe.slice(0, safe.length - rawExt.length).slice(0, MAX_STEM) || "file";
const date = now.toISOString().slice(0, 10);
for (let i = 0; ; i++) {
const name = `${date}-${stem}${i ? `-${i}` : ""}${ext}`;
const full = path.join(inbox, name);
try {
await writeFile(full, bytes, { flag: "wx" });
return `inbox/${name}`;
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
}
}
}
function errorText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/** Builds the `[Reply ...]` header line for the agent, or returns undefined when there is nothing to reply to. */
export function buildReplyHeader(
reply: RepliedMessage | undefined,
quote: Quote | undefined,
botUserId: number | undefined,
): string | undefined {
if (!reply) return undefined;
const isFromBot =
reply.from !== undefined &&
(botUserId !== undefined ? reply.from.id === botUserId : reply.from.is_bot === true);
const isFromOwner = reply.from !== undefined && !isFromBot;
const who = isFromBot
? "your message"
: isFromOwner
? "your own earlier message"
: "the earlier message";
const quoted = quote?.text ?? "";
if (quoted.length > 0) {
return `[Reply quoting ${who}: "${truncateReplyContext(quoted)}"]`;
}
const content = truncateReplyContext(replyContent(reply));
return `[Reply to ${who}: "${content}"]`;
}
function replyContent(reply: RepliedMessage): string {
if (reply.text && reply.text.length > 0) return reply.text;
if (reply.caption && reply.caption.length > 0) return reply.caption;
if (reply.photo && reply.photo.length > 0) return "(photo)";
if (reply.voice) return "(voice message)";
if (reply.audio) return reply.audio.file_name ? `(audio: ${reply.audio.file_name})` : "(audio)";
if (reply.document) return reply.document.file_name ? `(document: ${reply.document.file_name})` : "(document)";
return "(message)";
}
function truncateReplyContext(text: string): string {
const chars = Array.from(text);
if (chars.length <= MAX_REPLY_CONTEXT_CHARS) return text;
return `${chars.slice(0, MAX_REPLY_CONTEXT_CHARS - 1).join("")}…`;
}