211 lines
7.8 KiB
TypeScript
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("")}…`;
|
|
}
|