feat(telegram): ingest and render inbound voice messages

This commit is contained in:
Ruslan Bakiev
2026-02-23 12:21:53 +07:00
parent c94c229a1a
commit acd974766a
4 changed files with 225 additions and 5 deletions

View File

@@ -0,0 +1,76 @@
import { getQuery, setHeader } from "h3";
import { getAuthContext } from "../../../utils/auth";
import { prisma } from "../../../utils/prisma";
import { requireTelegramBotToken, telegramApiBase, telegramBotApi } from "../../../utils/telegram";
const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:";
type TelegramFileMeta = {
file_id: string;
file_path?: string;
};
function parseTelegramFileId(audioUrl: string | null | undefined) {
const raw = String(audioUrl ?? "").trim();
if (!raw.startsWith(TELEGRAM_AUDIO_FILE_MARKER)) return null;
const fileId = raw.slice(TELEGRAM_AUDIO_FILE_MARKER.length).trim();
return fileId || null;
}
export default defineEventHandler(async (event) => {
const auth = await getAuthContext(event);
const query = getQuery(event);
const messageId = String(query.messageId ?? "").trim();
if (!messageId) {
throw createError({ statusCode: 400, statusMessage: "messageId is required" });
}
const message = await prisma.contactMessage.findFirst({
where: {
id: messageId,
channel: "TELEGRAM",
contact: { teamId: auth.teamId },
},
select: {
audioUrl: true,
},
});
if (!message) {
throw createError({ statusCode: 404, statusMessage: "telegram message not found" });
}
const fileId = parseTelegramFileId(message.audioUrl);
if (!fileId) {
throw createError({ statusCode: 404, statusMessage: "telegram audio is missing" });
}
const meta = await telegramBotApi<TelegramFileMeta>("getFile", { file_id: fileId });
const filePath = String(meta?.file_path ?? "").trim();
if (!filePath) {
throw createError({ statusCode: 502, statusMessage: "telegram file path is unavailable" });
}
const apiBase = telegramApiBase().replace(/\/+$/, "");
const token = requireTelegramBotToken();
const remote = await fetch(`${apiBase}/file/bot${token}/${filePath}`);
if (!remote.ok) {
throw createError({ statusCode: 502, statusMessage: `telegram file fetch failed (${remote.status})` });
}
const contentType = remote.headers.get("content-type") || "audio/ogg";
const contentLength = remote.headers.get("content-length");
const body = Buffer.from(await remote.arrayBuffer());
setHeader(event, "content-type", contentType);
setHeader(event, "cache-control", "private, max-age=60");
if (contentLength) {
const parsedLength = Number(contentLength);
if (Number.isFinite(parsedLength) && parsedLength >= 0) {
setHeader(event, "content-length", parsedLength);
}
}
return body;
});

View File

@@ -67,6 +67,7 @@ function extractOmniNormalizedText(rawJson: unknown, fallbackText = "") {
type ClientTimelineContentType = "CALENDAR_EVENT" | "DOCUMENT" | "RECOMMENDATION";
const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
const TELEGRAM_AUDIO_FILE_MARKER = "tg-file:";
function mapTimelineContentType(value: ClientTimelineContentType) {
if (value === "CALENDAR_EVENT") return "calendar_event";
@@ -141,6 +142,19 @@ function visibleMessageWhere(hiddenInboxIds: string[]) {
};
}
function resolveContactMessageAudioUrl(message: {
id: string;
channel: string;
audioUrl: string | null;
}) {
const raw = String(message.audioUrl ?? "").trim();
if (!raw) return "";
if (message.channel === "TELEGRAM" && raw.startsWith(TELEGRAM_AUDIO_FILE_MARKER)) {
return `/api/omni/telegram/media?messageId=${encodeURIComponent(message.id)}`;
}
return raw;
}
async function upsertContactInbox(input: {
teamId: string;
contactId: string;
@@ -662,7 +676,7 @@ async function getDashboard(auth: AuthContext | null) {
kind: m.kind === "CALL" ? "call" : "message",
direction: m.direction === "IN" ? "in" : "out",
text: m.content,
audioUrl: "",
audioUrl: resolveContactMessageAudioUrl(m),
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
deliveryStatus: resolveDeliveryStatus(m),
@@ -918,7 +932,7 @@ async function getClientTimeline(auth: AuthContext | null, contactIdInput: strin
kind: m.kind === "CALL" ? "call" : "message",
direction: m.direction === "IN" ? "in" : "out",
text: m.content,
audioUrl: "",
audioUrl: resolveContactMessageAudioUrl(m),
duration: m.durationSec ? new Date(m.durationSec * 1000).toISOString().slice(14, 19) : "",
transcript: Array.isArray(m.transcriptJson) ? ((m.transcriptJson as any) as string[]) : [],
deliveryStatus: resolveDeliveryStatus(m),