项目文件夹

文件
wehub-resource-sync 98e40dac97
CLI Smoke Test / smoke-test-linux (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-linux (24) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (20) (push) Has been cancelled
CLI Smoke Test / smoke-test-windows (24) (push) Has been cancelled
Expo App TypeScript typecheck / typecheck (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:40:49 +08:00

111 行
3.8 KiB
TypeScript

/**
* Push notification dispatch.
*
* Single entry point: dispatchSessionEventPush — rich session-event
* ("It's ready!", permission, question) called by CLI/daemon clients.
*
* Generic per-message pushes were removed: the CLI streams every assistant
* chunk, tool_use, and tool_result as a session message, so notifying on each
* insert produced one buzz every 10s during a turn with no useful title.
* Connected clients still receive the realtime message update over socket;
* only the Expo push for "new message" went away.
*
* Suppression: if the user has ANY non-machine client that is active
* (connected + not backgrounded), suppress the push — they can see in-app
* indicators (unread dots, tab title counter) instead.
*
* "Active" is determined by socket.data.appState:
* - Clients send `app-state: { state: 'active' | 'background' }` via socket.
* - Old clients that never send it are treated as active (connected = present).
* - On disconnect the socket (and its state) disappears automatically.
*/
import { db } from "@/storage/db";
import { isUserActive } from "@/app/push/focusTracker";
import { sendPushNotifications } from "@/app/push/pushSend";
import { log } from "@/utils/log";
async function fetchTokensAndSend(params: {
userId: string;
sessionId: string;
title: string;
body: string;
data: Record<string, unknown>;
channelId: string;
}): Promise<void> {
// All push tokens are mobile — web/CLI never register Expo tokens.
const tokens = await db.accountPushToken.findMany({
where: { accountId: params.userId }
});
if (tokens.length === 0) {
log({ module: 'push' }, `No push tokens for user ${params.userId} session ${params.sessionId} — skipped`);
return;
}
const tickets = await sendPushNotifications(
tokens.map(t => ({
to: t.token,
title: params.title,
body: params.body,
data: params.data,
sound: 'default' as const,
channelId: params.channelId
}))
);
let okCount = 0;
const errors: string[] = [];
for (let i = 0; i < tickets.length; i++) {
const ticket = tickets[i];
if (ticket.status === 'ok') {
okCount++;
continue;
}
errors.push(ticket.details?.error || ticket.message || 'unknown');
if (ticket.details?.error === 'DeviceNotRegistered') {
void db.accountPushToken.deleteMany({
where: { id: tokens[i].id }
});
}
}
if (errors.length === 0) {
log({ module: 'push' }, `Push sent for user ${params.userId} session ${params.sessionId}: ${okCount} token(s)`);
} else {
log({ module: 'push', level: 'warn' }, `Push partial for user ${params.userId} session ${params.sessionId}: ok=${okCount} errors=${JSON.stringify(errors)}`);
}
}
export async function dispatchSessionEventPush(params: {
userId: string;
sessionId: string;
title: string;
body: string;
data?: Record<string, unknown>;
}): Promise<void> {
const { userId, sessionId, title, body, data } = params;
try {
try {
if (await isUserActive(userId)) {
log({ module: 'push' }, `Suppressed session-event push for user ${userId} session ${sessionId}: user active`);
return;
}
} catch (presenceError) {
log({ module: 'push', level: 'error' }, `Presence check failed, sending push anyway: ${presenceError}`);
}
await fetchTokensAndSend({
userId,
sessionId,
title,
body,
data: { sessionId, ...(data ?? {}) },
channelId: 'messages'
});
} catch (error) {
log({ module: 'push', level: 'error' }, `Session-event push dispatch failed: ${error}`);
}
}