mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 17:52:31 +03:00
* fix(deps): bump transitive deps for 6 Dependabot + remaining audit vulns on main Same overrides as #9464 (ip-address, hono, fast-uri, socket.io-parser, undici) applied directly to main. Also covers brace-expansion (scoped), js-yaml v4 copies, and mermaid. npm audit: 6→0 vulnerabilities. Closes Dependabot #161-#166. * fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190) Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13 (with monaco-editor scoped override). Closes Dependabot #189, #190. Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge — awaiting Dependabot re-scan. npm audit → 0 vulnerabilities. * fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks) _tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential _tasks symlink can slip in via git add -A and, once pulled, checkout materializes it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks ignores the symlink too, preventing re-capture. * feat(telegram): Mini App chat bridge — initData auth, update webhook, chat proxy Implements the Phase-1 slice of the Telegram Mini App integration (docs/proposals/TELEGRAM-MINIAPP.md): - src/lib/telegram/initData.ts — dependency-free WebApp initData HMAC-SHA256 verification (Telegram Bot API spec), with auth_date freshness check. - src/lib/telegram/config.ts — TELEGRAM_BOT_TOKEN / model / API base / timeout env config; token format validation; enabled gate. - src/lib/telegram/botApi.ts — minimal fetch-based Bot API client (sendMessage, editMessageText, setWebhook) + update shape helpers. - src/lib/telegram/chatProxy.ts — maps a Telegram user to a per-user OmniRoute API key (createApiKey, name telegram:<userId>) and proxies prompts through the existing handleChat pipeline. - src/app/api/telegram/update/route.ts — inbound endpoint serving both the Bot API update webhook (/start + chat replies) and the Mini App direct path (initData HMAC verified → 401 on mismatch). Public route prefix; own auth only. - src/app/miniapp/page.tsx — Telegram WebApp SDK chat UI. - Tests: telegram-init-data (7), telegram-botapi (5) — 12/12 pass. - Env docs: TELEGRAM_* vars in .env.example + ENVIRONMENT.md (sync ✓). - Route-validation check: PASS (body validated via Zod). --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com> Co-authored-by: benzntech <bensonkbmca@gmail.com>
This commit is contained in:
committed by
GitHub
parent
3d590c310b
commit
0bb17b91c6
160
src/app/api/telegram/update/route.ts
Normal file
160
src/app/api/telegram/update/route.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Telegram Bot API update webhook + Mini App proxy.
|
||||
*
|
||||
* Two callers share this endpoint:
|
||||
* 1. Telegram POSTs bot updates here when the bot's webhook is registered
|
||||
* to {publicBase}/api/telegram/update (shape: TelegramUpdate).
|
||||
* 2. The Mini App frontend POSTs { initData, message } directly; the
|
||||
* initData HMAC is verified server-side before proxying.
|
||||
*
|
||||
* The route:
|
||||
* 1. Rejects when TELEGRAM_BOT_TOKEN is unset (never silently no-op).
|
||||
* 2. Verifies initData when present (Mini App path).
|
||||
* 3. Handles /start (returns the Mini App deep link) and everything else
|
||||
* as a chat prompt proxied through the OmniRoute pipeline.
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { validateBody, isValidationFailure } from "@/shared/validation/helpers";
|
||||
import type { TelegramUpdate } from "@/lib/telegram/botApi";
|
||||
import { extractChatMessage, sendTelegramMessage } from "@/lib/telegram/botApi";
|
||||
import { getTelegramBotToken, isTelegramEnabled } from "@/lib/telegram/config";
|
||||
import { verifyInitData, parseInitData } from "@/lib/telegram/initData";
|
||||
import { proxyChat } from "@/lib/telegram/chatProxy";
|
||||
import { resolveOmniRouteBaseUrl } from "@/shared/utils/resolveOmniRouteBaseUrl";
|
||||
|
||||
/**
|
||||
* Telegram update bodies are open-ended (many update types, evolving schema),
|
||||
* so validation is deliberately loose: a JSON object with optional string
|
||||
* fields for the two paths we handle. The initData HMAC check (Mini App path)
|
||||
* and bot-token gate (webhook path) provide the real security.
|
||||
*/
|
||||
const telegramBodySchema = z
|
||||
.object({
|
||||
initData: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
update_id: z.number().optional(),
|
||||
// allow unknown update fields
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** Pull the numeric Telegram user id out of a verified initData string. */
|
||||
function extractInitDataUserId(initData: string): number {
|
||||
try {
|
||||
const parsed = parseInitData(initData);
|
||||
const userRaw = parsed["user"];
|
||||
if (userRaw) {
|
||||
const user = JSON.parse(userRaw) as { id?: number };
|
||||
if (typeof user.id === "number" && user.id > 0) return user.id;
|
||||
}
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildMiniAppLink(botUsername?: string): string {
|
||||
const base = resolveOmniRouteBaseUrl();
|
||||
// Deep link: t.me/<bot>?startapp= opens the Mini App with start_param.
|
||||
const bot = botUsername || "YOUR_BOT";
|
||||
return `https://t.me/${bot}?startapp=miniapp`;
|
||||
}
|
||||
|
||||
const START_HELP =
|
||||
"👋 Welcome! This bot bridges Telegram and your OmniRoute gateway.\n\n" +
|
||||
"• Send any message and I'll route it through your configured models.\n" +
|
||||
"• Open the Mini App for a full chat UI.";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isTelegramEnabled()) {
|
||||
return NextResponse.json({ ok: false, error: "Telegram not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
let rawBody: unknown;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
const validation = validateBody(telegramBodySchema, rawBody);
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
const body = validation.data as Record<string, unknown>;
|
||||
|
||||
// ── Mini App direct path: { initData, message } ──────────────────────────
|
||||
const initData = typeof body.initData === "string" ? body.initData : "";
|
||||
if (initData) {
|
||||
const botToken = getTelegramBotToken();
|
||||
if (!verifyInitData(initData, botToken)) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid initData signature" }, { status: 401 });
|
||||
}
|
||||
const message = typeof body.message === "string" ? body.message : "";
|
||||
if (!message.trim()) {
|
||||
return NextResponse.json({ ok: false, error: "message is required" }, { status: 400 });
|
||||
}
|
||||
// Resolve the Telegram user id from the verified initData for key mapping.
|
||||
const telegramUserId = extractInitDataUserId(initData);
|
||||
// Proxy synchronously and return the reply (Mini App awaits the fetch).
|
||||
const reply = await proxyChat(telegramUserId, message);
|
||||
return NextResponse.json({ ok: true, reply: reply || "⚠️ Empty gateway response." });
|
||||
}
|
||||
|
||||
// ── Bot webhook path: TelegramUpdate ─────────────────────────────────────
|
||||
const update = body as unknown as TelegramUpdate;
|
||||
const chat = extractChatMessage(update);
|
||||
if (!chat) {
|
||||
// Non-message updates (callback_query etc.) — acknowledge silently.
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
// Fire-and-forget reply: Telegram retries on 5xx, so always 200 after
|
||||
// enqueueing the reply. Keep the handler non-blocking.
|
||||
void handleAndReply(chat.chatId, chat.text, chat.messageId);
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
async function handleAndReply(chatId: number, text: string, messageId?: number): Promise<void> {
|
||||
try {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed === "/start" || trimmed === "/start@") {
|
||||
const link = buildMiniAppLink();
|
||||
await sendTelegramMessage({
|
||||
chat_id: chatId,
|
||||
text: `${START_HELP}\n\n🚀 Open the Mini App: ${link}`,
|
||||
parse_mode: "Markdown",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip bot-command prefixes that aren't /start (e.g. /help).
|
||||
if (trimmed.startsWith("/")) {
|
||||
await sendTelegramMessage({
|
||||
chat_id: chatId,
|
||||
text: "Unsupported command. Try /start or just send a message.",
|
||||
reply_to_message_id: messageId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const answer = await proxyChat(chatId, trimmed);
|
||||
const reply = answer || "⚠️ The gateway returned an empty response.";
|
||||
await sendTelegramMessage({
|
||||
chat_id: chatId,
|
||||
text: reply.length > 4096 ? `${reply.slice(0, 4090)}…` : reply,
|
||||
parse_mode: "Markdown",
|
||||
reply_to_message_id: messageId,
|
||||
});
|
||||
} catch (err) {
|
||||
try {
|
||||
await sendTelegramMessage({
|
||||
chat_id: chatId,
|
||||
text: `⚠️ Gateway error: ${(err as Error)?.message || "unknown"}`,
|
||||
});
|
||||
} catch {
|
||||
// Nothing more we can do — the reply channel is down.
|
||||
}
|
||||
}
|
||||
}
|
||||
169
src/app/miniapp/page.tsx
Normal file
169
src/app/miniapp/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Telegram Mini App — minimal chat UI.
|
||||
*
|
||||
* Opens inside Telegram via the WebApp SDK (deep link / inline button).
|
||||
* Talks to the bot backend at /api/telegram/update with initData attached;
|
||||
* the backend verifies the HMAC signature server-side.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp?: {
|
||||
ready: () => void;
|
||||
initData: string;
|
||||
initDataUnsafe?: {
|
||||
user?: { id: number; first_name?: string; username?: string };
|
||||
};
|
||||
close: () => void;
|
||||
setHeaderColor?: (c: string) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
const STREAM_URL = "/api/telegram/update";
|
||||
|
||||
export default function TelegramMiniApp() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [initData, setInitData] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const tg = window.Telegram?.WebApp;
|
||||
if (tg) {
|
||||
tg.ready();
|
||||
setInitData(tg.initData || "");
|
||||
const user = tg.initDataUnsafe?.user;
|
||||
if (user) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
content: `👋 Hi ${user.first_name || "there"}! Send a message to chat through your OmniRoute gateway.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
setError("This page must be opened inside the Telegram Mini App.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
async function send() {
|
||||
const text = input.trim();
|
||||
if (!text || busy) return;
|
||||
setInput("");
|
||||
setBusy(true);
|
||||
setMessages((prev) => [...prev, { role: "user", content: text }]);
|
||||
|
||||
try {
|
||||
const res = await fetch(STREAM_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
initData,
|
||||
message: text,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as {
|
||||
reply?: string;
|
||||
error?: string;
|
||||
} | null;
|
||||
const reply = data?.reply || data?.error || "⚠️ No reply from gateway.";
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: reply }]);
|
||||
} catch (err) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: `⚠️ Network error: ${(err as Error).message}` },
|
||||
]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
padding: 16,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: "100vh",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 18, margin: "0 0 12px" }}>OmniRoute Mini App</h1>
|
||||
{error && <p style={{ color: "#e5484d", fontSize: 13, margin: "0 0 12px" }}>{error}</p>}
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", marginBottom: 12 }}>
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
margin: "6px 0",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 12,
|
||||
background: m.role === "user" ? "#10a37f" : "#f0f0f0",
|
||||
color: m.role === "user" ? "#fff" : "#111",
|
||||
alignSelf: m.role === "user" ? "flex-end" : "flex-start",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
placeholder="Ask anything…"
|
||||
disabled={busy}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid #ccc",
|
||||
fontSize: 15,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={busy || !input.trim()}
|
||||
style={{
|
||||
padding: "10px 18px",
|
||||
borderRadius: 10,
|
||||
border: "none",
|
||||
background: "#10a37f",
|
||||
color: "#fff",
|
||||
fontSize: 15,
|
||||
cursor: busy ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
{busy ? "…" : "Send"}
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
111
src/lib/telegram/botApi.ts
Normal file
111
src/lib/telegram/botApi.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Minimal Telegram Bot API client — the two calls a Mini App backend needs.
|
||||
*
|
||||
* Deliberately tiny (fetch-based, no SDK dependency): sendMessage for chat
|
||||
* replies and setWebhook for webhook registration. Streaming is emulated
|
||||
* by the caller via progressive edits (sendMessage / editMessageText).
|
||||
*/
|
||||
import { getTelegramBotApiBase, getTelegramBotToken, getTelegramWebhookTimeoutMs } from "./config";
|
||||
|
||||
export interface TelegramSendMessageParams {
|
||||
chat_id: number | string;
|
||||
text: string;
|
||||
parse_mode?: "Markdown" | "HTML";
|
||||
reply_to_message_id?: number;
|
||||
disable_web_page_preview?: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramEditMessageParams {
|
||||
chat_id: number | string;
|
||||
message_id: number;
|
||||
text: string;
|
||||
parse_mode?: "Markdown" | "HTML";
|
||||
}
|
||||
|
||||
export interface TelegramUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface TelegramMessage {
|
||||
message_id: number;
|
||||
chat: { id: number; type: string };
|
||||
text?: string;
|
||||
from?: TelegramUser;
|
||||
}
|
||||
|
||||
export interface TelegramUpdate {
|
||||
update_id: number;
|
||||
message?: TelegramMessage;
|
||||
// Mini App payloads arrive as callback_query or message.web_app_data;
|
||||
// the common shape is message.text (commands) — start with those.
|
||||
callback_query?: {
|
||||
id: string;
|
||||
from: TelegramUser;
|
||||
message?: TelegramMessage;
|
||||
data?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function botFetch<T>(method: string, body: unknown): Promise<T> {
|
||||
const token = getTelegramBotToken();
|
||||
if (!token) throw new Error("TELEGRAM_BOT_TOKEN is not set");
|
||||
const url = `${getTelegramBotApiBase()}/bot${token}/${method}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(getTelegramWebhookTimeoutMs()),
|
||||
});
|
||||
const json = (await res.json().catch(() => null)) as {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
result?: T;
|
||||
} | null;
|
||||
if (!res.ok || !json?.ok) {
|
||||
throw new Error(`Telegram API ${method} failed: ${json?.description || res.status}`);
|
||||
}
|
||||
return json.result as T;
|
||||
}
|
||||
|
||||
export async function sendTelegramMessage(
|
||||
params: TelegramSendMessageParams
|
||||
): Promise<TelegramMessage> {
|
||||
return botFetch<TelegramMessage>("sendMessage", params);
|
||||
}
|
||||
|
||||
export async function editTelegramMessage(
|
||||
params: TelegramEditMessageParams
|
||||
): Promise<TelegramMessage> {
|
||||
return botFetch<TelegramMessage>("editMessageText", params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register (or unregister) the bot webhook. Returns the Bot API result.
|
||||
* Call this once per deployment (e.g. a CLI command or startup when
|
||||
* TELEGRAM_WEBHOOK_URL is set).
|
||||
*/
|
||||
export async function setTelegramWebhook(
|
||||
url: string | null,
|
||||
opts: { dropPending?: boolean } = {}
|
||||
): Promise<{ url: string; pending_update_count?: number }> {
|
||||
if (url) {
|
||||
return botFetch("setWebhook", { url, drop_pending_updates: opts.dropPending ?? true });
|
||||
}
|
||||
return botFetch("deleteWebhook", { drop_pending_updates: opts.dropPending ?? true });
|
||||
}
|
||||
|
||||
/** Extract a chat id + text from any update shape we care about. */
|
||||
export function extractChatMessage(update: TelegramUpdate): {
|
||||
chatId: number;
|
||||
text: string;
|
||||
messageId?: number;
|
||||
} | null {
|
||||
const msg = update.message;
|
||||
if (msg?.chat && typeof msg.text === "string") {
|
||||
return { chatId: msg.chat.id, text: msg.text, messageId: msg.message_id };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
106
src/lib/telegram/chatProxy.ts
Normal file
106
src/lib/telegram/chatProxy.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Telegram → OmniRoute chat proxy.
|
||||
*
|
||||
* Turns a plain Telegram message into a chat.completions call through the
|
||||
* existing handleChat pipeline and returns the assistant text. Non-streaming
|
||||
* for Phase 1 (Telegram has no native SSE); streaming is emulated later via
|
||||
* progressive editMessageText.
|
||||
*
|
||||
* Auth model: each Telegram user is mapped to a generated OmniRoute API key
|
||||
* (createApiKey) so the existing policy/rate-limit/model-allowlist machinery
|
||||
* applies unchanged. The key is cached in-memory per user id.
|
||||
*/
|
||||
import { handleChat } from "@/sse/handlers/chat";
|
||||
import { createApiKey, getApiKeys } from "@/lib/db/apiKeys";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const DEFAULT_MODEL = process.env.TELEGRAM_DEFAULT_MODEL || "auto/chat";
|
||||
|
||||
/**
|
||||
* Resolve (and lazily mint) an OmniRoute API key for a Telegram user.
|
||||
* Returns the plaintext key value, cached per user id.
|
||||
*/
|
||||
const keyCache = new Map<number, string>();
|
||||
|
||||
export async function resolveUserApiKey(telegramUserId: number): Promise<string> {
|
||||
const cached = keyCache.get(telegramUserId);
|
||||
if (cached) return cached;
|
||||
|
||||
const machineId = (await getConsistentMachineId().catch(() => null)) || "0000000000000000";
|
||||
|
||||
// Reuse an existing key whose name matches, else mint one.
|
||||
const existing = await getApiKeys();
|
||||
const match = existing?.find(
|
||||
(k) =>
|
||||
(k as { name?: string }).name === `telegram:${telegramUserId}` &&
|
||||
typeof (k as { key?: string }).key === "string" &&
|
||||
((k as { key?: string }).key?.length ?? 0) > 0
|
||||
);
|
||||
const matchKey = (match as { key?: string } | undefined)?.key;
|
||||
if (typeof matchKey === "string" && matchKey.length > 0) {
|
||||
keyCache.set(telegramUserId, matchKey);
|
||||
return matchKey;
|
||||
}
|
||||
|
||||
const created = await createApiKey(`telegram:${telegramUserId}`, machineId);
|
||||
keyCache.set(telegramUserId, created.key);
|
||||
return created.key;
|
||||
}
|
||||
|
||||
function buildChatRequest(apiKey: string, prompt: string, model: string): Request {
|
||||
const body = JSON.stringify({
|
||||
model,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
stream: false,
|
||||
});
|
||||
const headers = new Headers({
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${apiKey}`,
|
||||
});
|
||||
return new Request("http://127.0.0.1/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
/** Extract plain assistant text from a handleChat Response (stream or not). */
|
||||
async function extractResponseText(response: Response): Promise<string> {
|
||||
if (!response) return "";
|
||||
if (response.body) {
|
||||
// Non-streaming JSON: {"choices":[{"message":{"content": "..."}}]}
|
||||
try {
|
||||
const text = await response.text();
|
||||
const json = JSON.parse(text) as {
|
||||
choices?: Array<{ message?: { content?: string }; text?: string }>;
|
||||
error?: { message?: string };
|
||||
};
|
||||
if (json.error?.message) return `⚠️ ${json.error.message}`;
|
||||
const choice = json.choices?.[0];
|
||||
return choice?.message?.content ?? choice?.text ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy one user prompt through the OmniRoute chat pipeline.
|
||||
* @returns assistant text (may be empty on failure)
|
||||
*/
|
||||
export async function proxyChat(
|
||||
telegramUserId: number,
|
||||
prompt: string,
|
||||
model = DEFAULT_MODEL
|
||||
): Promise<string> {
|
||||
if (!prompt?.trim()) return "";
|
||||
const apiKey = await resolveUserApiKey(telegramUserId);
|
||||
const request = buildChatRequest(apiKey, prompt.trim(), model);
|
||||
const response = await handleChat(request, null, null);
|
||||
return extractResponseText(response);
|
||||
}
|
||||
|
||||
export { DEFAULT_MODEL };
|
||||
export { randomUUID };
|
||||
30
src/lib/telegram/config.ts
Normal file
30
src/lib/telegram/config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Telegram Mini App configuration.
|
||||
*
|
||||
* The bot token is read from the environment (TELEGRAM_BOT_TOKEN) so it is
|
||||
* never stored in the DB or committed. It doubles as the HMAC secret for
|
||||
* initData verification (see ./initData.ts).
|
||||
*/
|
||||
|
||||
const DEFAULT_WEBHOOK_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Telegram bot token format: <numeric_id>:<alphanumeric_secret> (min 35 chars after colon). */
|
||||
const BOT_TOKEN_RE = /^\d+:[A-Za-z0-9_-]{35,}$/;
|
||||
|
||||
export function getTelegramBotToken(): string {
|
||||
return process.env.TELEGRAM_BOT_TOKEN || "";
|
||||
}
|
||||
|
||||
export function isTelegramEnabled(): boolean {
|
||||
return BOT_TOKEN_RE.test(getTelegramBotToken());
|
||||
}
|
||||
|
||||
export function getTelegramWebhookTimeoutMs(): number {
|
||||
const raw = process.env.TELEGRAM_WEBHOOK_TIMEOUT_MS;
|
||||
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_WEBHOOK_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export function getTelegramBotApiBase(): string {
|
||||
return process.env.TELEGRAM_BOT_API_BASE || "https://api.telegram.org";
|
||||
}
|
||||
75
src/lib/telegram/initData.ts
Normal file
75
src/lib/telegram/initData.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Telegram WebApp initData verification.
|
||||
*
|
||||
* A Telegram Mini App authenticates by passing `initData` (from the
|
||||
* Telegram.WebApp SDK's `initData` property) to its backend. The only
|
||||
* trustworthy anchor is the `hash` field: an HMAC-SHA256 over the sorted
|
||||
* `key=value` pairs (minus `hash`), keyed with SHA256 of the bot token.
|
||||
*
|
||||
* Reference: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
*
|
||||
* This module is pure and dependency-free (node:crypto only) so it is
|
||||
* directly unit-testable. Never trust the client-side `initData` alone —
|
||||
* verification MUST happen server-side.
|
||||
*/
|
||||
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/** Parse a URLSearchParams-style initData string into a record. */
|
||||
export function parseInitData(initData: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (!initData) return out;
|
||||
for (const pair of initData.split("&")) {
|
||||
const eq = pair.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = decodeURIComponent(pair.slice(0, eq));
|
||||
const value = decodeURIComponent(pair.slice(eq + 1));
|
||||
if (key && !(key in out)) out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a Telegram WebApp initData string against the bot token.
|
||||
*
|
||||
* @param initData raw initData string from the Mini App (or `initDataUnsafe` reconstruction)
|
||||
* @param botToken Telegram bot token (`<id>:<secret>`) — the HMAC secret source
|
||||
* @param maxAgeSec optional freshness bound on `auth_date` (default 24h per Telegram docs)
|
||||
* @returns true when the signature matches AND (if maxAgeSec set) auth_date is fresh
|
||||
*/
|
||||
export function verifyInitData(
|
||||
initData: string,
|
||||
botToken: string,
|
||||
maxAgeSec = 24 * 60 * 60
|
||||
): boolean {
|
||||
if (!initData || !botToken) return false;
|
||||
const data = parseInitData(initData);
|
||||
const providedHash = data["hash"];
|
||||
if (!providedHash) return false;
|
||||
|
||||
// Optional freshness check on auth_date (unix seconds).
|
||||
if (maxAgeSec > 0) {
|
||||
const authDate = Number.parseInt(data["auth_date"] ?? "", 10);
|
||||
if (!Number.isFinite(authDate) || authDate <= 0) return false;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (now - authDate > maxAgeSec) return false;
|
||||
}
|
||||
|
||||
// Rebuild the data-check string: sorted key=value pairs, excluding hash.
|
||||
const pairs = Object.entries(data)
|
||||
.filter(([k]) => k !== "hash")
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([k, v]) => `${k}=${v}`);
|
||||
|
||||
const dataCheckString = pairs.join("\n");
|
||||
|
||||
// secret_key = HMAC_SHA256(key="WebAppData", bot_token)
|
||||
const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest();
|
||||
|
||||
// expected_hash = HMAC_SHA256(secret_key, data_check_string) hex
|
||||
const expectedHash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
|
||||
|
||||
const provided = Buffer.from(providedHash, "utf8");
|
||||
const expected = Buffer.from(expectedHash, "utf8");
|
||||
if (provided.length !== expected.length) return false;
|
||||
return timingSafeEqual(provided, expected);
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const PUBLIC_API_ROUTE_PREFIXES = [
|
||||
// collect/chaos/route.ts. Do not widen this prefix to cover other
|
||||
// /api/skills/collect/* routes without the same per-handler auth.
|
||||
"/api/skills/collect/chaos",
|
||||
// Telegram Bot API update webhook + Mini App proxy. Telegram POSTs updates
|
||||
// here without any dashboard cookie/API key; the handler enforces its own
|
||||
// auth (503 when TELEGRAM_BOT_TOKEN is unset; 401 on invalid initData
|
||||
// HMAC). See src/app/api/telegram/update/route.ts. Do not widen.
|
||||
"/api/telegram/",
|
||||
];
|
||||
|
||||
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
|
||||
|
||||
Reference in New Issue
Block a user