292 lines
10 KiB
JavaScript
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
|
|
}
|