582 lines
18 KiB
TypeScript
582 lines
18 KiB
TypeScript
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);
|
|
}
|