import type { ChatMessage, Transaction } from "../shared/types"; import type { Session } from "./types"; import { createPrivateKey, sign } from "crypto"; import { existsSync, readFileSync } from "fs"; import { connect } from "node:http2"; type Platform = "ios" | "android"; type PushMessage = { to: string; platform: Platform; title?: string; body: string; data?: Record; }; type PlayerTokens = { ios: Set; android: Set; }; const tokensBySession = new Map>(); const apnsKeyId = process.env.APNS_KEY_ID || ""; const apnsTeamId = process.env.APNS_TEAM_ID || ""; const apnsBundleId = process.env.APNS_BUNDLE_ID || ""; const apnsEnv = process.env.APNS_ENV === "production" ? "production" : "sandbox"; const apnsPrivateKey = process.env.APNS_PRIVATE_KEY || ""; const apnsPrivateKeyPath = process.env.APNS_PRIVATE_KEY_PATH || ""; const fcmProjectId = process.env.FCM_PROJECT_ID || ""; const fcmClientEmail = process.env.FCM_CLIENT_EMAIL || ""; const fcmPrivateKey = process.env.FCM_PRIVATE_KEY || ""; const fcmServerKey = process.env.FCM_SERVER_KEY || ""; const pushDebug = Boolean(process.env.PUSH_DEBUG); let cachedApnsToken: { value: string; issuedAt: number } | null = null; let cachedFcmToken: { value: string; expiresAt: number } | null = null; let cachedApnsPrivateKey: string | null = null; function formatMoney(amount: number): string { const value = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format( Math.abs(amount), ); return `₦${value}`; } function formatReason(note?: string | null): string { const trimmed = note?.trim(); return trimmed && trimmed.length > 0 ? trimmed : "No reason provided"; } function compactText(value: string, maxLength: number): string { const clean = value.replace(/\s+/g, " ").trim(); if (clean.length <= maxLength) return clean; return `${clean.slice(0, maxLength - 1)}…`; } function getSessionTokens( sessionId: string, playerIds: string[], platform: Platform, ): string[] { const sessionTokens = tokensBySession.get(sessionId); if (!sessionTokens) return []; const combined = new Set(); playerIds.forEach((playerId) => { const tokens = sessionTokens.get(playerId); if (!tokens) return; const bucket = platform === "ios" ? tokens.ios : tokens.android; bucket.forEach((token) => combined.add(token)); }); return Array.from(combined); } function debugSessionTokens(sessionId: string): void { const sessionTokens = tokensBySession.get(sessionId); if (!sessionTokens) { console.log(`[push][debug] session=${sessionId} tokens=none`); return; } const dump: Record = {}; sessionTokens.forEach((tokens, playerId) => { dump[playerId] = { ios: Array.from(tokens.ios), android: Array.from(tokens.android), }; }); console.log(`[push][debug] session=${sessionId} tokens=${JSON.stringify(dump)}`); } function base64UrlEncode(value: string | Buffer): string { const buffer = typeof value === "string" ? Buffer.from(value) : value; return buffer .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } function normalizeKey(value: string): string { if (!value) return value; return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value; } function normalizeData(data?: Record): Record | undefined { if (!data) return undefined; const entries = Object.entries(data).map(([key, value]) => [key, `${value ?? ""}`]); return Object.fromEntries(entries); } function resolveApnsPrivateKey(): string { if (cachedApnsPrivateKey !== null) return cachedApnsPrivateKey; const candidates = [apnsPrivateKey, apnsPrivateKeyPath].filter(Boolean); let resolved = ""; for (const candidate of candidates) { if (candidate.includes("BEGIN")) { resolved = candidate; break; } if (existsSync(candidate)) { try { resolved = readFileSync(candidate, "utf8"); break; } catch { // Ignore read failures; fallback to raw string. } } } cachedApnsPrivateKey = resolved || apnsPrivateKey; return cachedApnsPrivateKey; } function createApnsJwt(): string | null { const resolvedKey = resolveApnsPrivateKey(); if (!apnsKeyId || !apnsTeamId || !apnsBundleId || !resolvedKey) { if (pushDebug) { const missing = [ !apnsKeyId && "APNS_KEY_ID", !apnsTeamId && "APNS_TEAM_ID", !apnsBundleId && "APNS_BUNDLE_ID", !resolvedKey && "APNS_PRIVATE_KEY", ] .filter(Boolean) .join(", "); console.log(`[push][apns] skipped: missing ${missing || "credentials"}`); } return null; } const now = Math.floor(Date.now() / 1000); if (cachedApnsToken && now - cachedApnsToken.issuedAt < 50 * 60) { return cachedApnsToken.value; } const header = base64UrlEncode(JSON.stringify({ alg: "ES256", kid: apnsKeyId })); const payload = base64UrlEncode(JSON.stringify({ iss: apnsTeamId, iat: now })); const unsigned = `${header}.${payload}`; const key = createPrivateKey(normalizeKey(resolvedKey)); const signature = sign("SHA256", Buffer.from(unsigned), { key, dsaEncoding: "ieee-p1363", }); const token = `${unsigned}.${base64UrlEncode(signature)}`; cachedApnsToken = { value: token, issuedAt: now }; return token; } async function sendApnsMessage(message: PushMessage): Promise { let jwt: string | null = null; try { jwt = createApnsJwt(); } catch (error) { if (pushDebug) { console.warn("[push][apns] failed to build JWT", error); } return; } if (!jwt) return; if (pushDebug) { const shortToken = message.to.slice(0, 12); console.log(`[push][apns] sending token=${shortToken}… env=${apnsEnv}`); } const payload: Record = { aps: { alert: { title: message.title ?? "Negopoly", body: message.body }, sound: "default", }, ...message.data, }; const host = apnsEnv === "production" ? "api.push.apple.com" : "api.sandbox.push.apple.com"; await new Promise((resolve) => { const client = connect(`https://${host}`); let responseStatus: number | null = null; let responseText = ""; const req = client.request({ ":method": "POST", ":path": `/3/device/${message.to}`, authorization: `bearer ${jwt}`, "apns-topic": apnsBundleId, "apns-push-type": "alert", "apns-priority": "10", }); const timeout = setTimeout(() => { if (pushDebug) { console.warn("[push][apns] request timeout"); } try { req.close(); } catch { // Ignore stream close errors. } try { client.close(); } catch { // Ignore client close errors. } resolve(); }, 7000); req.setEncoding("utf8"); req.on("response", (headers) => { const status = headers[":status"]; if (typeof status === "number") { responseStatus = status; } else if (typeof status === "string") { const parsed = Number(status); responseStatus = Number.isNaN(parsed) ? null : parsed; } }); req.on("data", (chunk) => { responseText += chunk; }); req.on("end", () => { clearTimeout(timeout); if (pushDebug && responseStatus) { console.log(`[push][apns] ${responseStatus}`); } if (responseStatus && responseStatus >= 400) { console.warn( `[push][apns] ${responseStatus} ${responseText}`.trim(), ); } try { client.close(); } catch { // Ignore client close errors. } resolve(); }); req.on("error", (error) => { clearTimeout(timeout); if (pushDebug) { console.warn("[push][apns] request failed", error); } try { client.close(); } catch { // Ignore client close errors. } resolve(); }); client.on("error", (error) => { clearTimeout(timeout); if (pushDebug) { console.warn("[push][apns] client error", error); } try { client.close(); } catch { // Ignore client close errors. } resolve(); }); req.end(JSON.stringify(payload)); }); } async function getFcmAccessToken(): Promise { if (!fcmProjectId || !fcmClientEmail || !fcmPrivateKey) { return null; } const now = Math.floor(Date.now() / 1000); if (cachedFcmToken && cachedFcmToken.expiresAt - now > 60) { return cachedFcmToken.value; } const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" })); const payload = base64UrlEncode( JSON.stringify({ iss: fcmClientEmail, scope: "https://www.googleapis.com/auth/firebase.messaging", aud: "https://oauth2.googleapis.com/token", iat: now, exp: now + 3600, }), ); const unsigned = `${header}.${payload}`; const key = createPrivateKey(normalizeKey(fcmPrivateKey)); const signature = sign("RSA-SHA256", Buffer.from(unsigned), key); const assertion = `${unsigned}.${base64UrlEncode(signature)}`; const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion, }).toString(), }); if (!response.ok) return null; const data = (await response.json()) as { access_token?: string; expires_in?: number }; if (!data.access_token) return null; cachedFcmToken = { value: data.access_token, expiresAt: now + (data.expires_in ?? 3600), }; return data.access_token; } async function sendFcmMessage(message: PushMessage): Promise { if (fcmServerKey) { const response = await fetch("https://fcm.googleapis.com/fcm/send", { method: "POST", headers: { Authorization: `key=${fcmServerKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: message.to, notification: { title: message.title ?? "Negopoly", body: message.body }, data: message.data ?? {}, }), }); if (pushDebug) { console.log(`[push][fcm-legacy] ${response.status} ${response.statusText}`); } if (!response.ok) { const detail = await response.text().catch(() => ""); console.warn( `[push][fcm-legacy] ${response.status} ${response.statusText} ${detail}`.trim(), ); } return; } const accessToken = await getFcmAccessToken(); if (!accessToken) return; const response = await fetch( `https://fcm.googleapis.com/v1/projects/${fcmProjectId}/messages:send`, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ message: { token: message.to, notification: { title: message.title ?? "Negopoly", body: message.body }, data: message.data ?? {}, }, }), }, ); if (pushDebug) { console.log(`[push][fcm] ${response.status} ${response.statusText}`); } if (!response.ok) { const detail = await response.text().catch(() => ""); console.warn( `[push][fcm] ${response.status} ${response.statusText} ${detail}`.trim(), ); } } async function sendPushMessages(messages: PushMessage[]): Promise { if (messages.length === 0) return; for (const message of messages) { if (message.platform === "ios") { await sendApnsMessage(message); } else { await sendFcmMessage(message); } } } function queuePushMessages(messages: PushMessage[]): void { if (messages.length === 0) return; if (pushDebug) { const counts = messages.reduce( (acc, message) => { acc.total += 1; if (message.platform === "ios") acc.ios += 1; if (message.platform === "android") acc.android += 1; return acc; }, { total: 0, ios: 0, android: 0 }, ); console.log( `[push][debug] queued messages total=${counts.total} ios=${counts.ios} android=${counts.android}`, ); } void sendPushMessages(messages).catch(() => { // Ignore push failures to avoid breaking gameplay flow. }); } export function registerPushToken( sessionId: string, playerId: string, token: string, platform: Platform, ): void { const normalized = token.trim(); if (!normalized) return; let sessionTokens = tokensBySession.get(sessionId); if (!sessionTokens) { sessionTokens = new Map(); tokensBySession.set(sessionId, sessionTokens); } let playerTokens = sessionTokens.get(playerId); if (!playerTokens) { playerTokens = { ios: new Set(), android: new Set() }; sessionTokens.set(playerId, playerTokens); } if (platform === "ios") { playerTokens.ios.add(normalized); } else { playerTokens.android.add(normalized); } if (pushDebug) { console.log(`[push] registered ${platform} token for ${sessionId}:${playerId}`); } } export function clearPushTokensForSession(sessionId: string): void { tokensBySession.delete(sessionId); } export function notifyTransaction(session: Session, transaction: Transaction): void { const reason = formatReason(transaction.note); if (transaction.kind === "banker_adjust") { const targetId = transaction.toId; if (!targetId) return; if (pushDebug) { debugSessionTokens(session.id); console.log(`[push][debug] banker_adjust target=${targetId}`); } const signed = `${transaction.amount >= 0 ? "+" : "-"}${formatMoney(transaction.amount)}`; const body = `Balance adjusted: ${signed} : ${reason}`; const data = normalizeData({ type: "transactions", sessionId: session.id }); const iosTokens = getSessionTokens(session.id, [targetId], "ios"); const androidTokens = getSessionTokens(session.id, [targetId], "android"); if (pushDebug && iosTokens.length + androidTokens.length === 0) { console.log(`[push] no tokens for banker_adjust target=${targetId}`); } const messages = [ ...iosTokens.map((token) => ({ to: token, platform: "ios" as const, title: "Negopoly", body, data, })), ...androidTokens.map((token) => ({ to: token, platform: "android" as const, title: "Negopoly", body, data, })), ]; queuePushMessages(messages); return; } const fromId = transaction.fromId; const toId = transaction.toId; if (!fromId || !toId) return; const fromName = session.players.get(fromId)?.name ?? "Player"; const toName = session.players.get(toId)?.name ?? "Player"; const amount = formatMoney(transaction.amount); const receiveBody = `Received ${amount} from ${fromName}: ${reason}`; const sentBody = `Sent ${amount} to ${toName}: ${reason}`; const data = normalizeData({ type: "transactions", sessionId: session.id }); const outgoingTokensIos = getSessionTokens(session.id, [fromId], "ios"); const incomingTokensIos = getSessionTokens(session.id, [toId], "ios"); const outgoingTokensAndroid = getSessionTokens(session.id, [fromId], "android"); const incomingTokensAndroid = getSessionTokens(session.id, [toId], "android"); if (pushDebug) { debugSessionTokens(session.id); console.log( `[push][debug] transaction kind=${transaction.kind} from=${fromId} to=${toId} outgoing=${outgoingTokensIos.length + outgoingTokensAndroid.length} incoming=${incomingTokensIos.length + incomingTokensAndroid.length}`, ); if (outgoingTokensIos.length + outgoingTokensAndroid.length === 0) { console.log(`[push] no tokens for transfer sender=${fromId}`); } if (incomingTokensIos.length + incomingTokensAndroid.length === 0) { console.log(`[push] no tokens for transfer recipient=${toId}`); } } const messages = [ ...outgoingTokensIos.map((token) => ({ to: token, platform: "ios" as const, title: "Negopoly", body: sentBody, data, })), ...incomingTokensIos.map((token) => ({ to: token, platform: "ios" as const, title: "Negopoly", body: receiveBody, data, })), ...outgoingTokensAndroid.map((token) => ({ to: token, platform: "android" as const, title: "Negopoly", body: sentBody, data, })), ...incomingTokensAndroid.map((token) => ({ to: token, platform: "android" as const, title: "Negopoly", body: receiveBody, data, })), ]; queuePushMessages(messages); } export function notifyChat(session: Session, message: ChatMessage): void { const sender = session.players.get(message.fromId); const senderName = sender?.name ?? "Player"; const chatId = message.groupId ?? "global"; let groupName = "Global"; let recipients: string[] = []; if (message.groupId === null) { recipients = Array.from(session.players.keys()); } else { const group = session.groups.find((item) => item.id === message.groupId); if (!group) return; groupName = group.name; recipients = group.memberIds.slice(); } recipients = recipients.filter((id) => id !== message.fromId); if (recipients.length === 0) return; const preview = compactText(message.body, 90); const body = `${senderName}#${groupName}: ${preview}`; const data = normalizeData({ type: "chat", sessionId: session.id, chatId }); const tokensIos = getSessionTokens(session.id, recipients, "ios"); const tokensAndroid = getSessionTokens(session.id, recipients, "android"); if (pushDebug && tokensIos.length + tokensAndroid.length === 0) { console.log(`[push] no tokens for chat recipients=${recipients.join(",")}`); } const messages = [ ...tokensIos.map((token) => ({ to: token, platform: "ios" as const, title: "Negopoly", body, data, })), ...tokensAndroid.map((token) => ({ to: token, platform: "android" as const, title: "Negopoly", body, data, })), ]; queuePushMessages(messages); }