'use strict' const { NOTIFICATION_KIND } = require('pearcord-notifications') const { contentHasEveryoneMention, contentHasHereMention, PERMISSION, roleHasPermission, id, now } = require('pearcord-shared') const { buildActivityFeed } = require('pearcord-activity') const NOTIFICATION_FANOUT_WINDOW_MS = 60000 const NOTIFICATION_FANOUT_MAX_PER_GUILD = 120 const notificationsActivityMixin = { _notificationGossipKeys: null, _notificationFanoutBuckets: null, _activityFeedWatermark: null, _notificationGossipKey (payload) { if (!payload?.id) return '' return `notif:${payload.id}:${payload.updatedAt || payload.createdAt || 0}:${payload.read ? 1 : 0}` }, _shouldGossipNotification (payload) { const hash = this._notificationGossipKey(payload) if (!hash) return false if (!this._notificationGossipKeys) this._notificationGossipKeys = new Set() if (this._notificationGossipKeys.has(hash)) return false this._notificationGossipKeys.add(hash) if (this._notificationGossipKeys.size > 4096) { const first = this._notificationGossipKeys.values().next().value if (first) this._notificationGossipKeys.delete(first) } return true }, _checkNotificationFanoutRate (guildId) { const gid = guildId || '*' const ts = Date.now() if (!this._notificationFanoutBuckets) this._notificationFanoutBuckets = new Map() const bucket = this._notificationFanoutBuckets.get(gid) || { count: 0, at: ts } if (ts - bucket.at > NOTIFICATION_FANOUT_WINDOW_MS) { bucket.count = 0 bucket.at = ts } if (bucket.count >= NOTIFICATION_FANOUT_MAX_PER_GUILD) { return { allowed: false, reason: 'notification fanout rate limit for guild' } } bucket.count += 1 this._notificationFanoutBuckets.set(gid, bucket) return { allowed: true } }, _expandMentionUserIds (plain, members, usersById, mentioned) { const userId = this.identity?.user?.id if (!userId || !plain) return mentioned const out = new Set(mentioned || []) const hasEveryone = contentHasEveryoneMention(plain) const hasHere = contentHasHereMention(plain) if (!hasEveryone && !hasHere) return [...out] for (const mem of members || []) { if (mem.userId === userId) continue if (hasEveryone) { out.add(mem.userId) continue } if (hasHere) { const st = this.contacts?.getPeerPresence?.(mem.userId)?.status if (st === 'online' || st === 'idle' || st === 'dnd') out.add(mem.userId) } } return [...out] }, async _resolveRoleMentionUserIds (plain, members = []) { const userId = this.identity?.user?.id if (!userId || !plain || !this.guild?.listRoles) return [] const roles = await this.guild.listRoles().catch(() => []) const names = String(plain).match(/@([\w-]{2,32})/g) || [] const ids = new Set() for (const raw of names) { const name = raw.slice(1).toLowerCase() if (name === 'everyone' || name === 'here' || name === 'channel') continue const role = roles.find((r) => String(r.name || '').toLowerCase() === name) if (!role) continue for (const mem of members) { if (Array.isArray(mem.roleIds) && mem.roleIds.includes(role.id)) { ids.add(mem.userId) } } } return [...ids] }, async pushLocalNotification (record = {}) { if (!this.notifications || !record?.title) return null const guildId = record.guildId || this.guild?.guild?.id || null const rate = this._checkNotificationFanoutRate(guildId) if (!rate.allowed) return null const span = this.log.time('notification.create', { spanKind: 'notification.create', kind: record.kind || 'message', guildId, channelId: record.channelId || null }) try { const row = await this.notifications.push({ id: record.id || id(), userId: this.identity?.user?.id, read: false, createdAt: now(), ...record }) if (row && this._shouldGossipNotification(row)) { /* P2P notification gossip reserved for future RPC */ } span.end({ notificationId: row?.id, kind: row?.kind, guildCount: (this.guilds || []).length }) return row } catch (err) { this.log.error('notification.create error', { error: err?.message || String(err) }) span.fail(err) throw err } }, async notifyPollEndReminder ({ messageId, channelId, question } = {}) { return this.pushLocalNotification({ kind: NOTIFICATION_KIND.POLL, title: 'Poll closing soon', body: question || 'A poll in this channel is about to close.', channelId, messageId, guildId: this.guild?.guild?.id || null, localOnly: true }) }, async notifyScheduledMessageFired ({ scheduleId, channelId, content, sentMessageId } = {}) { return this.pushLocalNotification({ kind: NOTIFICATION_KIND.SCHEDULED, title: 'Scheduled message sent', body: String(content || '').slice(0, 140) || 'Your scheduled message was delivered.', channelId, messageId: sentMessageId || null, scheduleId, guildId: this.guild?.guild?.id || null }) }, async notifyPollVote ({ messageId, channelId, voterName, question, authorId } = {}) { const userId = this.identity?.user?.id if (authorId && authorId !== userId) return null return this.pushLocalNotification({ kind: NOTIFICATION_KIND.POLL, title: 'New poll vote', body: `${voterName || 'Someone'} voted on “${String(question || 'your poll').slice(0, 80)}”`, channelId, messageId, guildId: this.guild?.guild?.id || null, localOnly: true }) }, async notifyVoiceMissed ({ channelId, callerName, guildId, guildName } = {}) { return this.pushLocalNotification({ kind: NOTIFICATION_KIND.VOICE_MISSED, title: 'Missed voice call', body: `${callerName || 'Someone'} tried to reach you in voice`, channelId, guildId: guildId || this.guild?.guild?.id || null, place: guildName ? `${guildName} voice` : 'Voice channel' }) }, async notifyStageSpeakerPromoted ({ channelId, guildId, channelName } = {}) { return this.pushLocalNotification({ kind: NOTIFICATION_KIND.STAGE, title: 'You are now a stage speaker', body: channelName ? `You can speak in ${channelName}` : 'You were promoted to speaker on stage', channelId, guildId: guildId || this.guild?.guild?.id || null }) }, async notifyGuildBoostLevelUp ({ level, guildId, guildName } = {}) { return this.pushLocalNotification({ kind: NOTIFICATION_KIND.BOOST, title: 'Server boost level up', body: `${guildName || 'This server'} reached boost level ${level || 1}`, guildId: guildId || this.guild?.guild?.id || null }) }, async notifyFriendOnline ({ userId, displayName } = {}) { if (!userId || userId === this.identity?.user?.id) return null if (!this._friendOnlineDedupe) this._friendOnlineDedupe = new Map() const key = `friend:${userId}` const nowMs = Date.now() const prev = this._friendOnlineDedupe.get(key) if (prev && nowMs - prev < 120000) return null this._friendOnlineDedupe.set(key, nowMs) return this.pushLocalNotification({ kind: NOTIFICATION_KIND.FRIEND_ONLINE, title: `${displayName || 'A friend'} is online`, body: 'Open Direct Messages to say hi', localOnly: true }) }, async markNotificationsReadBatch (notificationIds = []) { const ids = Array.isArray(notificationIds) ? notificationIds.filter(Boolean) : [] const out = [] for (const nid of ids.slice(0, 64)) { const row = await this.markNotificationRead(nid).catch(() => null) if (row) out.push(row) } return { marked: out.length, rows: out } }, async restoreNotificationReadState (snapshot = []) { if (!this.notifications?.restoreReadStates) return { restored: 0 } return this.notifications.restoreReadStates(snapshot) }, async _healNotificationInboxOnPartition (guildId) { const span = this.log.time('notification.heal', { spanKind: 'notification.heal', guildId: guildId || this.guild?.guild?.id || null }) try { if (!this.notifications) { span.end({ relisted: 0, skipped: true }) return { relisted: 0, skipped: true } } const rows = await this.notifications.listRecent(500) const unread = rows.filter((r) => !r.read).length span.end({ relisted: rows.length, unread, rowCount: rows.length }) return { relisted: rows.length, unread, rowCount: rows.length } } catch (err) { this.log.error('notification.heal error', { guildId, error: err?.message || String(err) }) span.fail(err) return { relisted: 0, error: err?.message || String(err) } } }, async _healActivityFeedWatermarkOnPartition (guildId) { const span = this.log.time('activity.heal', { spanKind: 'activity.heal', guildId: guildId || this.guild?.guild?.id || null }) try { const watermark = Date.now() this._activityFeedWatermark = watermark const contactsForView = this.contacts ? { ...(await this.listContacts()), stats: this.contacts.getStats() } : { accepted: [], friendPresence: {}, stats: { peers: 0 } } const members = await this.guild?.listMembers?.().catch(() => []) const feed = buildActivityFeed({ selfUserId: this.identity?.user?.id, presence: this.presence?.snapshot?.() || null, members: members || [], contacts: contactsForView }) span.end({ rowCount: feed?.rows?.length || 0, watermark }) return { relisted: feed?.rows?.length || 0, watermark } } catch (err) { this.log.error('activity.heal error', { guildId, error: err?.message || String(err) }) span.fail(err) return { relisted: 0, error: err?.message || String(err) } } } } module.exports = { notificationsActivityMixin, NOTIFICATION_FANOUT_WINDOW_MS, NOTIFICATION_FANOUT_MAX_PER_GUILD }