151 lines
4.5 KiB
TypeScript
151 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|