import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; export type LinkTokenPayloadV1 = { v: 1; teamId: string; userId: string; nonce: string; exp: number; }; const TOKEN_TTL_SEC = Number(process.env.TELEGRAM_LINK_TOKEN_TTL_SEC || 10 * 60); function base64UrlEncode(input: Buffer | string) { return Buffer.from(input) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); } function base64UrlDecode(input: string) { const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); const pad = normalized.length % 4; const padded = pad === 0 ? normalized : normalized + "=".repeat(4 - pad); return Buffer.from(padded, "base64"); } export function requireLinkSecret() { const secret = String(process.env.TELEGRAM_LINK_SECRET || process.env.TELEGRAM_BOT_TOKEN || "").trim(); if (!secret) { throw createError({ statusCode: 500, statusMessage: "TELEGRAM_LINK_SECRET or TELEGRAM_BOT_TOKEN is required" }); } return secret; } export function requireBotUsername() { const botUsername = String(process.env.TELEGRAM_BOT_USERNAME || "").trim().replace(/^@/, ""); if (!botUsername) { throw createError({ statusCode: 500, statusMessage: "TELEGRAM_BOT_USERNAME is required" }); } return botUsername; } function sign(input: string, secret: string) { return createHmac("sha256", secret).update(input).digest(); } export function issueLinkToken(input: { teamId: string; userId: string }) { const secret = requireLinkSecret(); const payload: LinkTokenPayloadV1 = { v: 1, teamId: input.teamId, userId: input.userId, nonce: randomBytes(12).toString("hex"), exp: Math.floor(Date.now() / 1000) + Math.max(60, TOKEN_TTL_SEC), }; const payloadRaw = JSON.stringify(payload); const payloadEncoded = base64UrlEncode(payloadRaw); const sig = base64UrlEncode(sign(payloadEncoded, secret)); const token = `${payloadEncoded}.${sig}`; return { token, payload }; } export function verifyLinkToken(token: string): LinkTokenPayloadV1 | null { const raw = String(token || "").trim(); if (!raw) return null; const dotIdx = raw.indexOf("."); if (dotIdx <= 0 || dotIdx >= raw.length - 1) return null; const payloadEncoded = raw.slice(0, dotIdx); const sigEncoded = raw.slice(dotIdx + 1); try { const secret = requireLinkSecret(); const expected = sign(payloadEncoded, secret); const actual = base64UrlDecode(sigEncoded); if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { return null; } const payload = JSON.parse(base64UrlDecode(payloadEncoded).toString("utf8")) as LinkTokenPayloadV1; if (!payload || payload.v !== 1) return null; if (!payload.teamId || !payload.userId || !payload.nonce || !payload.exp) return null; if (Math.floor(Date.now() / 1000) > payload.exp) return null; return payload; } catch { return null; } } export function extractLinkTokenFromStartText(text: string) { const trimmed = String(text || "").trim(); if (!trimmed.startsWith("/start")) return null; const parts = trimmed.split(/\s+/).filter(Boolean); if (parts.length < 2) return null; const arg = parts[1] || ""; if (!arg.startsWith("link_")) return null; return arg.slice("link_".length); } export function buildTelegramStartUrl(token: string) { const botUsername = requireBotUsername(); return `https://t.me/${botUsername}?start=link_${token}`; } export function getTelegramChatIdFromUpdate(update: any): string | null { const candidates = [ update?.message?.chat?.id, update?.business_message?.chat?.id, update?.edited_business_message?.chat?.id, update?.business_connection?.user_chat_id, ]; for (const c of candidates) { if (c == null) continue; const v = String(c).trim(); if (v) return v; } return null; } export function getBusinessConnectionFromUpdate(update: any): { id: string; userChatId: string | null; isEnabled: boolean | null; canReply: boolean | null; raw: any; } | null { const bc = (update?.business_connection ?? null) as any; if (!bc || typeof bc !== "object") return null; const id = String(bc.id ?? "").trim(); if (!id) return null; const userChatId = bc.user_chat_id != null ? String(bc.user_chat_id) : null; const isEnabled = typeof bc.is_enabled === "boolean" ? bc.is_enabled : null; const canReply = typeof bc.can_reply === "boolean" ? bc.can_reply : typeof bc.rights?.can_reply === "boolean" ? bc.rights.can_reply : null; return { id, userChatId, isEnabled, canReply, raw: bc, }; }