import webpush from "web-push"; import { CouchDBChangesStream } from "@hereconnect/couchdb-changes-stream"; import { qrDb, messagesDbUrl, webPushSubscriptionsDb, userChatSettingsDb, } from "./dbs"; import { setupVapidDetails } from "./setupVapidDetails"; import type { MessageDocument } from "@hereconnect/types"; import { ServiceSeqTracker } from "./serviceSeqTracker"; import { startHealthServer } from "./health-server"; const abortController = new AbortController(); startHealthServer(abortController); function limitStringLengthWithEllipsis(str: string, maxLength: number): string { return str.length > maxLength ? str.substring(0, maxLength) + "…" : str; } const start = async () => { await setupVapidDetails(); const serviceSeqTracker = await ServiceSeqTracker.Init( "_local/service:message-delivery-method-web-push", ); const changesStream = new CouchDBChangesStream( messagesDbUrl, { abortController, since: serviceSeqTracker.lastSeq, feed: "continuous", include_docs: true, heartbeat: 1000, filter: "_selector", selector: { type: "message", }, }, ); // Gracefully handle SIGINT process.on("SIGINT", async () => { changesStream?.stop(); process.stdout.write("\n"); process.exit(2); }); console.info("Service started from sequence", serviceSeqTracker.lastSeq); for await (const change of changesStream) { const { seq, doc: msgDoc } = change; if (!msgDoc) { console.error("Change missing document:", change); continue; } serviceSeqTracker.checkNextSeq(seq); try { if (msgDoc.from === "guest") { console.log("FROM GUEST"); // send message to owner const qrDoc = await qrDb.get(`qr_code:${msgDoc.qr_code_uri}`); if (qrDoc.messageDeliveryMethod !== "webPush") { console.log(`Skip !webPush`); continue; } const [{ rows: pushSubscriptionRows }, { rows: userChatSettingsRows }] = await Promise.all([ webPushSubscriptionsDb.allDocs({ startkey: `web_push_subscription:${qrDoc.user_uuid}:`, endkey: `web_push_subscription:${qrDoc.user_uuid}:\uffff`, include_docs: true, }), userChatSettingsDb.allDocs({ startkey: `user_chat_settings:${msgDoc.qr_code_uri}:${msgDoc.chat}:owner:`, endkey: `user_chat_settings:${msgDoc.qr_code_uri}:${msgDoc.chat}:owner:\uffff`, include_docs: true, }), ]); if ( userChatSettingsRows.some( (row) => row.doc!.is_push_notification_enabled === false, ) ) { console.log("push notifications is disabled"); continue; } if (pushSubscriptionRows.length === 0) { console.log("NO SUBSCRIPTIONS"); } else { console.log(`sending to ${pushSubscriptionRows.length} devices`); } for (const row of pushSubscriptionRows) { try { const subscription = row.doc!.subscription; const topic = `chat-${msgDoc.qr_code_uri}-${msgDoc.chat}`; const timestamp = new Date(msgDoc.created_at).getTime(); const tag = `${topic}-${(timestamp - new Date(qrDoc.created_at).getTime()).toString(36)}`; console.debug("subscription", subscription); const title = limitStringLengthWithEllipsis( `${qrDoc.name}: новое сообщение`, 53, ); const body = limitStringLengthWithEllipsis(msgDoc.body, 470); const options: NotificationOptions & { timestamp: number } = { icon: "/images/qr-code-logo-200x200.jpg", body, tag, requireInteraction: true, timestamp, data: { url: `/manage/qr/${msgDoc.qr_code_uri}/chat/${msgDoc.chat}#message-${msgDoc.created_at}`, payload: { tag }, }, } as const; await webpush.sendNotification( subscription, JSON.stringify({ type: "notification", notification: { title, options, }, }), { urgency: "high", topic, }, ); } catch (error) { console.error( `Error sending notification to owner ${row.id} - ${JSON.stringify((error as Error).message)}`, error, ); } } } else if (msgDoc.from === "owner") { console.log("FROM OWNER"); const qrDoc = await qrDb.get(`qr_code:${msgDoc.qr_code_uri}`); const { rows: userChatSettingsRows } = await userChatSettingsDb.allDocs( { startkey: `user_chat_settings:${msgDoc.qr_code_uri}:${msgDoc.chat}:guest:`, endkey: `user_chat_settings:${msgDoc.qr_code_uri}:${msgDoc.chat}:guest:\uffff`, include_docs: true, }, ); for (const { doc: userChatSettingDoc } of userChatSettingsRows) { if (!userChatSettingDoc) { console.log("push notification has not been configured"); continue; } if (!userChatSettingDoc.is_push_notification_enabled) { console.log("push notification is disabled"); continue; } const { rows: subscriptionRows } = await webPushSubscriptionsDb.allDocs({ startkey: `web_push_subscription:${userChatSettingDoc.user_uuid}:`, endkey: `web_push_subscription:${userChatSettingDoc.user_uuid}:\uffff`, include_docs: true, }); if (subscriptionRows.length === 0) { console.log("no subscriptions"); } else { console.log(`sending to ${subscriptionRows.length} devices`); } for (const row of subscriptionRows) { try { const subscription = row.doc!.subscription; const topic = `chat-${msgDoc.qr_code_uri}-${msgDoc.chat}`; const timestamp = new Date(msgDoc.created_at).getTime(); const tag = `${topic}-${(timestamp - new Date(qrDoc.created_at).getTime()).toString(36)}`; const title = limitStringLengthWithEllipsis( `Новое сообщение`, 53, ); const body = limitStringLengthWithEllipsis(msgDoc.body, 470); const options: NotificationOptions & { timestamp: number } = { icon: "/images/qr-code-logo-200x200.jpg", body, tag, requireInteraction: true, timestamp, data: { url: `/read/qr/${msgDoc.qr_code_uri}/chat/${msgDoc.chat}#msg-${msgDoc.created_at}`, payload: { tag }, }, } as const; await webpush.sendNotification( subscription, JSON.stringify({ type: "notification", notification: { title, options, }, }), { urgency: "high", topic, }, ); } catch (error) { console.error( `Error sending notification to guest ${row.id} - ${JSON.stringify((error as Error).message)}`, error, ); } } } } else { continue; } console.log( `Processed change: ${JSON.stringify({ _id: change.id, ref: msgDoc._rev, seq: change.seq })}`, ); } catch (error) { console.error( `Failed processing: ${JSON.stringify(error)} change: ${JSON.stringify({ id: msgDoc._id, rev: msgDoc._rev, seq: seq })}`, ); } finally { await serviceSeqTracker.saveSeq(seq); } } }; start().catch((error) => { console.error("error", error); process.exit(1); });