diff --git a/deno.json b/deno.json index 5142ec9..4b8d33c 100644 --- a/deno.json +++ b/deno.json @@ -6,7 +6,8 @@ ], "tasks": { "check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", - "start": "deno run --env-file -A --watch=static/,routes/ dev.ts", + "dev": "deno run --env-file -A --watch=static/,routes/ dev.ts", + "start": "deno run --env-file -A main.ts", "db": "deno run --env-file -A npm:drizzle-kit", "build": "deno run -A dev.ts build", "preview": "deno run -A main.ts", diff --git a/lib/env.ts b/lib/env.ts index df897dd..5e21eb0 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -8,6 +8,7 @@ export const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER"); export const TMDB_API_KEY = Deno.env.get("TMDB_API_KEY"); export const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY"); export const YOUTUBE_API_KEY = Deno.env.get("YOUTUBE_API_KEY"); +export const TELEGRAM_API_KEY = Deno.env.get("TELEGRAM_API_KEY")!; export const GITEA_SERVER = Deno.env.get("GITEA_SERVER"); export const GITEA_CLIENT_ID = Deno.env.get("GITEA_CLIENT_ID")!; diff --git a/lib/openai.ts b/lib/openai.ts index c822fce..dcb6052 100644 --- a/lib/openai.ts +++ b/lib/openai.ts @@ -228,3 +228,21 @@ export async function extractRecipe(content: string) { return recipeResponseSchema.parse(completion.choices[0].message.parsed); } + +export async function transcribe( + mp3Data: Uint8Array, +): Promise { + if (!openAI) return; + + const file = new File([mp3Data], "audio.mp3", { + type: "audio/mpeg", + }); + + const result = await openAI.audio.transcriptions.create({ + file, + model: "whisper-1", + response_format: "text", + }); + + return result; +} diff --git a/lib/telegram.ts b/lib/telegram.ts new file mode 100644 index 0000000..2572e59 --- /dev/null +++ b/lib/telegram.ts @@ -0,0 +1,124 @@ +import { Bot } from "https://deno.land/x/grammy@v1.36.1/mod.ts"; +import { TELEGRAM_API_KEY } from "@lib/env.ts"; +import { transcribe } from "@lib/openai.ts"; +import { createDocument } from "@lib/documents.ts"; + +const bot = new Bot(TELEGRAM_API_KEY); + +// In-memory task state +const activeTasks: Record< + string, + { + noteName: string; + entries: Array<{ type: string; content: string | Uint8Array }>; + } +> = {}; + +async function downloadFile(filePath: string): Promise { + const url = `https://api.telegram.org/file/bot${bot.token}/${filePath}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error("Failed to download file: " + response.statusText); + } + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); +} + +bot.command("start", async (ctx) => { + const [_, noteName] = ctx.message?.text?.split(" ") || []; + if (!noteName) { + return ctx.reply("Please provide a note name. Usage: /start NoteName"); + } + activeTasks[ctx.chat.id.toString()] = { noteName, entries: [] }; + await ctx.reply(`Started note: ${noteName}`); +}); + +bot.command("end", async (ctx) => { + const task = activeTasks[ctx.chat.id.toString()]; + if (!task) return ctx.reply("No active note found."); + + let finalNote = `# ${task.noteName}\n\n`; + + const photoTasks: { content: Uint8Array; path: string }[] = []; + + let photoIndex = 0; + for (const entry of task.entries) { + if (entry.type === "text") { + finalNote += entry.content + "\n\n"; + } else if (entry.type === "voice") { + try { + const mp3Data = await convertOggToMp3(entry.content as Uint8Array); + const transcript = await transcribe(mp3Data); + finalNote += `**Voice Transcript:**\n${transcript}\n\n`; + } catch (_) { + finalNote += "**[Voice message could not be transcribed]**\n\n"; + } + } else if (entry.type === "photo") { + photoIndex++; + + const photoUrl = `${ + task.noteName.replace(/\.md$/, "") + }/photo-${photoIndex}.jpg`; + + finalNote += `![image](${photoUrl})\n\n`; + photoTasks.push({ + content: entry.content as Uint8Array, + path: photoUrl, + }); + } + } + + for (const entry of photoTasks) { + await createDocument(entry.path, entry.content, "image/jpeg"); + } + await createDocument(task.noteName, finalNote, "text/markdown"); + + delete activeTasks[ctx.chat.id.toString()]; + await ctx.reply("Note complete. Here is your markdown:"); + await ctx.reply(finalNote); +}); + +bot.on("message:text", (ctx) => { + const task = activeTasks[ctx.chat.id.toString()]; + if (!task) return; + task.entries.push({ type: "text", content: ctx.message.text }); +}); + +bot.on("message:voice", async (ctx) => { + const task = activeTasks[ctx.chat.id.toString()]; + if (!task) return; + const file = await ctx.getFile(); + const buffer = await downloadFile(file.file_path!); + task.entries.push({ type: "voice", content: buffer }); +}); + +bot.on("message:photo", async (ctx) => { + const task = activeTasks[ctx.chat.id.toString()]; + if (!task) return; + const file = await ctx.getFile(); + const buffer = await downloadFile(file.file_path!); + task.entries.push({ type: "photo", content: buffer }); +}); + +export async function convertOggToMp3( + oggData: Uint8Array, +): Promise { + const ffmpeg = new Deno.Command("ffmpeg", { + args: ["-f", "ogg", "-i", "pipe:0", "-f", "mp3", "pipe:1"], + stdin: "piped", + stdout: "piped", + stderr: "null", + }); + + const process = ffmpeg.spawn(); + const writer = process.stdin.getWriter(); + await writer.write(oggData); + await writer.close(); + + const output = await process.output(); + const { code } = await process.status; + if (code !== 0) throw new Error(`FFmpeg exited with code ${code}`); + return output.stdout; +} + +bot.start(); diff --git a/main.ts b/main.ts index fc93592..5ffbe34 100644 --- a/main.ts +++ b/main.ts @@ -7,5 +7,7 @@ import { start } from "$fresh/server.ts"; import manifest from "./fresh.gen.ts"; import config from "./fresh.config.ts"; +import "@lib/telegram.ts"; +console.log("hello"); await start(manifest, config); diff --git a/routes/index.tsx b/routes/index.tsx index 8dd23df..7ac49b8 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -4,6 +4,7 @@ import { PageProps } from "$fresh/server.ts"; import { resources } from "@lib/resources.ts"; import { RedirectSearchHandler } from "@islands/Search.tsx"; import { KMenu } from "@islands/KMenu.tsx"; +import "@lib/telegram.ts"; export default function Home(props: PageProps) { return (