Files
pearcord-platform/mixins/domain/notifications-activity.js
T

292 lines
10 KiB
JavaScript

'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
}