CoompanionApp/server/notifications.ts

583 lines
18 KiB
TypeScript
Raw Permalink Normal View History

2026-02-03 16:35:01 +01:00
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<string, string>;
};
type PlayerTokens = {
ios: Set<string>;
android: Set<string>;
};
const tokensBySession = new Map<string, Map<string, PlayerTokens>>();
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<string>();
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<string, { ios: string[]; android: string[] }> = {};
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<string, unknown>): Record<string, string> | 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<void> {
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<string, unknown> = {
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<void>((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<string | null> {
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<void> {
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<void> {
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);
}