@devchitchat/chat 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +313 -0
- package/index.js +148 -0
- package/migrate/001-drop-channel-invites.js +3 -0
- package/migrate/002-invite-initial-roles.js +5 -0
- package/migrate/003-dm-channels.js +35 -0
- package/migrate/004-notifications.js +15 -0
- package/migrate/005-uploads.js +21 -0
- package/migrate/006-mention-priority.js +3 -0
- package/migrate/007-push-subscriptions.js +14 -0
- package/migrate/008-messages-channel-seq-index.js +3 -0
- package/migrate/009-message-reactions.js +15 -0
- package/migrate/010-edit-messages.js +7 -0
- package/package.json +51 -0
- package/pages/_error.html +12 -0
- package/pages/_layout.html +31 -0
- package/pages/_layout.js +13 -0
- package/pages/admin/_layout.html +52 -0
- package/pages/admin/_layout.js +8 -0
- package/pages/admin/bots/[userId].js +88 -0
- package/pages/admin/bots/[userId].phtml +89 -0
- package/pages/admin/bots/index.js +41 -0
- package/pages/admin/bots/index.phtml +58 -0
- package/pages/admin/index.js +8 -0
- package/pages/admin/invites/index.js +72 -0
- package/pages/admin/invites/index.phtml +88 -0
- package/pages/admin/users/[userId].js +60 -0
- package/pages/admin/users/[userId].phtml +57 -0
- package/pages/admin/users/index.js +20 -0
- package/pages/admin/users/index.phtml +37 -0
- package/pages/api/uploads/index.js +66 -0
- package/pages/api/user/settings.js +26 -0
- package/pages/auth/signout.js +14 -0
- package/pages/channels/[channelId].js +99 -0
- package/pages/channels/[channelId].phtml +173 -0
- package/pages/index.js +33 -0
- package/pages/invite/[token].js +10 -0
- package/pages/login/index.js +57 -0
- package/pages/login/index.phtml +29 -0
- package/pages/public/client/action-sheet.js +77 -0
- package/pages/public/client/app.js +38 -0
- package/pages/public/client/auth-tabs.js +13 -0
- package/pages/public/client/emoji-data.js +197 -0
- package/pages/public/client/islands/call.js +1770 -0
- package/pages/public/client/islands/sidebar.js +1197 -0
- package/pages/public/client/long-press.js +59 -0
- package/pages/public/client/modal.js +50 -0
- package/pages/public/client/router.js +87 -0
- package/pages/public/client/rtc-peer-manager.js +344 -0
- package/pages/public/client/settings-sync.js +76 -0
- package/pages/public/client/shared/messages.js +147 -0
- package/pages/public/client/swipe-nav.js +98 -0
- package/pages/public/client/theme.js +27 -0
- package/pages/public/client/ws.js +71 -0
- package/pages/public/favicon.ico +0 -0
- package/pages/public/favicon.png +0 -0
- package/pages/public/icon.png +0 -0
- package/pages/public/manifest.json +11 -0
- package/pages/public/sw.js +38 -0
- package/pages/public/themes/base.css +1786 -0
- package/pages/public/themes/dark.css +22 -0
- package/pages/public/themes/forest.css +22 -0
- package/pages/public/themes/light.css +23 -0
- package/pages/public/themes/ocean.css +22 -0
- package/pages/public/themes/rose.css +22 -0
- package/pages/registration/index.js +35 -0
- package/pages/registration/index.phtml +38 -0
- package/pages/uploads/[uploadId]/[filename].js +45 -0
- package/src/adapters/InMemoryAuthRepository.js +74 -0
- package/src/adapters/InMemoryChannelRepository.js +138 -0
- package/src/adapters/InMemoryDeliveryRepository.js +52 -0
- package/src/adapters/InMemoryFileStore.js +53 -0
- package/src/adapters/InMemoryHubRepository.js +85 -0
- package/src/adapters/InMemoryMessageRepository.js +35 -0
- package/src/adapters/InMemoryReactionRepository.js +45 -0
- package/src/adapters/InMemorySearchRepository.js +37 -0
- package/src/adapters/InMemorySignalingRepository.js +35 -0
- package/src/adapters/InMemoryUploadRepository.js +36 -0
- package/src/adapters/InMemoryUserSettingsRepository.js +15 -0
- package/src/adapters/LocalFileStore.js +40 -0
- package/src/adapters/SqliteAuthRepository.js +184 -0
- package/src/adapters/SqliteChannelRepository.js +149 -0
- package/src/adapters/SqliteDeliveryRepository.js +53 -0
- package/src/adapters/SqliteHubRepository.js +99 -0
- package/src/adapters/SqliteMessageRepository.js +90 -0
- package/src/adapters/SqlitePushRepository.js +39 -0
- package/src/adapters/SqliteReactionRepository.js +50 -0
- package/src/adapters/SqliteSearchRepository.js +42 -0
- package/src/adapters/SqliteSignalingRepository.js +50 -0
- package/src/adapters/SqliteUploadRepository.js +34 -0
- package/src/adapters/SqliteUserSettingsRepository.js +23 -0
- package/src/adminAuth.js +25 -0
- package/src/config.js +11 -0
- package/src/context.js +77 -0
- package/src/core/dm.js +10 -0
- package/src/core/mentions.js +27 -0
- package/src/core/messages.js +21 -0
- package/src/core/reactions.js +6 -0
- package/src/core/roles.js +5 -0
- package/src/core/uploads.js +107 -0
- package/src/db/initDb.js +225 -0
- package/src/db/openDb.js +18 -0
- package/src/db/runMigrations.js +45 -0
- package/src/db/transaction.js +11 -0
- package/src/ports/IFileStore.js +34 -0
- package/src/services/AuthService.js +208 -0
- package/src/services/BotService.js +148 -0
- package/src/services/ChannelService.js +176 -0
- package/src/services/DeliveryService.js +28 -0
- package/src/services/HubService.js +133 -0
- package/src/services/MessageService.js +122 -0
- package/src/services/NotificationService.js +45 -0
- package/src/services/PresenceService.js +55 -0
- package/src/services/ReactionService.js +57 -0
- package/src/services/SearchService.js +21 -0
- package/src/services/SignalingService.js +177 -0
- package/src/services/UploadService.js +111 -0
- package/src/services/UserSettingsService.js +30 -0
- package/src/services/WebPushService.js +217 -0
- package/src/util/crypto.js +21 -0
- package/src/util/errors.js +14 -0
- package/src/util/ids.js +3 -0
- package/src/util/logger.js +21 -0
- package/src/ws/ChatServer.js +478 -0
- package/src/ws/handlers/authHandlers.js +152 -0
- package/src/ws/handlers/channelHandlers.js +166 -0
- package/src/ws/handlers/hubHandlers.js +82 -0
- package/src/ws/handlers/messageHandlers.js +88 -0
- package/src/ws/handlers/pushHandlers.js +25 -0
- package/src/ws/handlers/reactionHandlers.js +27 -0
- package/src/ws/handlers/rtcHandlers.js +126 -0
- package/styles.css +22 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BotService — manages bot user accounts and API tokens.
|
|
3
|
+
*
|
|
4
|
+
* Bots are regular users with the "bot" role. They authenticate to the
|
|
5
|
+
* WebSocket using a long-lived bot token instead of a session cookie.
|
|
6
|
+
* The token is hashed before storage; the plain token is returned once
|
|
7
|
+
* at creation time and never retrievable again.
|
|
8
|
+
*/
|
|
9
|
+
import { newId } from '../util/ids.js'
|
|
10
|
+
import { randomToken, hashToken } from '../util/crypto.js'
|
|
11
|
+
import { ServiceError } from '../util/errors.js'
|
|
12
|
+
|
|
13
|
+
export class BotService {
|
|
14
|
+
constructor({ authService, authRepo, channelRepo, nowFn = () => Date.now() }) {
|
|
15
|
+
this.authService = authService
|
|
16
|
+
this.authRepo = authRepo
|
|
17
|
+
this.channelRepo = channelRepo
|
|
18
|
+
this.nowFn = nowFn
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Bot accounts ───────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
createBot({ handle, displayName, tokenLabel, requestingUserId }) {
|
|
24
|
+
this._requireAdmin(requestingUserId)
|
|
25
|
+
const h = handle?.trim()
|
|
26
|
+
const name = displayName?.trim() || h
|
|
27
|
+
if (!h) throw new ServiceError('BAD_REQUEST', 'Handle is required')
|
|
28
|
+
if (this.authRepo.isHandleTaken({ handle: h })) throw new ServiceError('CONFLICT', 'Handle already taken')
|
|
29
|
+
|
|
30
|
+
const userId = newId('u')
|
|
31
|
+
const now = this.nowFn()
|
|
32
|
+
this.authRepo.insertBotUser({ userId, handle: h, displayName: name, now })
|
|
33
|
+
|
|
34
|
+
const { tokenId, token } = this._insertToken({ userId, label: tokenLabel, now })
|
|
35
|
+
return { userId, handle: h, displayName: name, roles: ['bot'], tokenId, token }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
listBots({ requestingUserId }) {
|
|
39
|
+
this._requireAdmin(requestingUserId)
|
|
40
|
+
return this.authRepo.listUsers()
|
|
41
|
+
.filter(row => {
|
|
42
|
+
try { return JSON.parse(row.roles_json).includes('bot') } catch { return false }
|
|
43
|
+
})
|
|
44
|
+
.map(row => ({
|
|
45
|
+
user_id: row.user_id,
|
|
46
|
+
handle: row.handle,
|
|
47
|
+
display_name: row.display_name,
|
|
48
|
+
roles: JSON.parse(row.roles_json),
|
|
49
|
+
created_at: row.created_at,
|
|
50
|
+
tokens: this.authRepo.listBotTokens({ userId: row.user_id }),
|
|
51
|
+
}))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
getBot({ userId, requestingUserId }) {
|
|
55
|
+
this._requireAdmin(requestingUserId)
|
|
56
|
+
const row = this.authRepo.findUserById({ userId })
|
|
57
|
+
if (!row) throw new ServiceError('NOT_FOUND', 'Bot not found')
|
|
58
|
+
const roles = JSON.parse(row.roles_json)
|
|
59
|
+
if (!roles.includes('bot')) throw new ServiceError('NOT_FOUND', 'Bot not found')
|
|
60
|
+
return {
|
|
61
|
+
user_id: row.user_id,
|
|
62
|
+
handle: row.handle,
|
|
63
|
+
display_name: row.display_name,
|
|
64
|
+
roles,
|
|
65
|
+
tokens: this.authRepo.listBotTokens({ userId }),
|
|
66
|
+
channels: this._getBotChannels(userId),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Bot tokens ─────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
createToken({ userId, label, ttlMs = null, requestingUserId }) {
|
|
73
|
+
this._requireAdmin(requestingUserId)
|
|
74
|
+
const now = this.nowFn()
|
|
75
|
+
const expiresAt = ttlMs != null ? now + ttlMs : null
|
|
76
|
+
return this._insertToken({ userId, label, now, expiresAt })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
revokeToken({ tokenId, requestingUserId }) {
|
|
80
|
+
this._requireAdmin(requestingUserId)
|
|
81
|
+
this.authRepo.revokeBotToken({ tokenId, now: this.nowFn() })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
revokeAllTokens({ userId, requestingUserId }) {
|
|
85
|
+
this._requireAdmin(requestingUserId)
|
|
86
|
+
this.authRepo.revokeAllBotTokens({ userId, now: this.nowFn() })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Called by ChatServer hello handler to authenticate a bot WS connection. */
|
|
90
|
+
authenticateToken(plainToken) {
|
|
91
|
+
const tokenHash = hashToken(plainToken)
|
|
92
|
+
const row = this.authRepo.findBotTokenByHash({ tokenHash, now: this.nowFn() })
|
|
93
|
+
if (!row) throw new ServiceError('UNAUTHORIZED', 'Invalid bot token')
|
|
94
|
+
this.authRepo.touchBotToken({ tokenId: row.token_id, now: this.nowFn() })
|
|
95
|
+
return {
|
|
96
|
+
userId: row.user_id,
|
|
97
|
+
handle: row.handle,
|
|
98
|
+
displayName: row.display_name,
|
|
99
|
+
roles: JSON.parse(row.roles_json),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Bot channel membership ─────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
setBotChannels({ userId, channelIds, requestingUserId }) {
|
|
106
|
+
this._requireAdmin(requestingUserId)
|
|
107
|
+
const now = this.nowFn()
|
|
108
|
+
|
|
109
|
+
// Current active memberships
|
|
110
|
+
const current = this._getBotChannels(userId).map(c => c.channel_id)
|
|
111
|
+
const next = Array.isArray(channelIds) ? channelIds : []
|
|
112
|
+
|
|
113
|
+
const toJoin = next.filter(id => !current.includes(id))
|
|
114
|
+
const toLeave = current.filter(id => !next.includes(id))
|
|
115
|
+
|
|
116
|
+
for (const channelId of toJoin) {
|
|
117
|
+
this.channelRepo.upsertMembership({ channelId, userId, role: 'member', now })
|
|
118
|
+
}
|
|
119
|
+
for (const channelId of toLeave) {
|
|
120
|
+
this.channelRepo.setMemberLeft({ channelId, userId, now })
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_getBotChannels(userId) {
|
|
125
|
+
return this.channelRepo.listAccessible({ userId })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Internals ──────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
_insertToken({ userId, label, now, expiresAt = null }) {
|
|
131
|
+
const token = randomToken()
|
|
132
|
+
const tokenId = newId('bt')
|
|
133
|
+
this.authRepo.insertBotToken({
|
|
134
|
+
tokenId, userId,
|
|
135
|
+
tokenHash: hashToken(token),
|
|
136
|
+
label: label?.trim() || null,
|
|
137
|
+
now,
|
|
138
|
+
expiresAt,
|
|
139
|
+
})
|
|
140
|
+
return { tokenId, token, expiresAt }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_requireAdmin(userId) {
|
|
144
|
+
const user = this.authService.getUser(userId)
|
|
145
|
+
const roles = user?.roles ?? []
|
|
146
|
+
if (!roles.includes('admin')) throw new ServiceError('FORBIDDEN', 'Admin role required')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { newId } from '../util/ids.js'
|
|
2
|
+
import { ServiceError } from '../util/errors.js'
|
|
3
|
+
import { buildDmChannelName } from '../core/dm.js'
|
|
4
|
+
|
|
5
|
+
export class ChannelService {
|
|
6
|
+
constructor({ channelRepo, hubService, nowFn = () => Date.now() }) {
|
|
7
|
+
this.channelRepo = channelRepo
|
|
8
|
+
this.hubService = hubService
|
|
9
|
+
this.nowFn = nowFn
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
createChannel({ hubId, kind, name, topic = null, visibility = 'public', createdByUserId, userRoles = [] }) {
|
|
13
|
+
if (!['text', 'voice'].includes(kind)) throw new ServiceError('BAD_REQUEST', 'Invalid channel kind')
|
|
14
|
+
if (!name?.trim()) throw new ServiceError('BAD_REQUEST', 'Channel name required')
|
|
15
|
+
if (!this.hubService.canAccessHub(hubId, createdByUserId, userRoles)) throw new ServiceError('FORBIDDEN', 'Cannot access hub')
|
|
16
|
+
|
|
17
|
+
const channelId = newId('c')
|
|
18
|
+
const now = this.nowFn()
|
|
19
|
+
|
|
20
|
+
this.channelRepo.insertChannelWithOwner({ channelId, hubId, kind, name: name.trim(), topic, visibility, createdByUserId, now })
|
|
21
|
+
|
|
22
|
+
return { channel_id: channelId, hub_id: hubId, kind, name: name.trim(), topic, visibility }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
listChannels(userId, userRoles = [], hubId = null) {
|
|
26
|
+
const isAdmin = userRoles.includes('admin')
|
|
27
|
+
const isGuest = userRoles.includes('guest')
|
|
28
|
+
if (hubId) {
|
|
29
|
+
return isAdmin
|
|
30
|
+
? this.channelRepo.listInHub({ hubId })
|
|
31
|
+
: this.channelRepo.listAccessibleInHub({ hubId, userId, isGuest })
|
|
32
|
+
}
|
|
33
|
+
return isAdmin
|
|
34
|
+
? this.channelRepo.listAll()
|
|
35
|
+
: this.channelRepo.listAccessible({ userId, isGuest })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
joinChannel({ channelId, userId, userRoles = [] }) {
|
|
39
|
+
const channel = this.getChannel(channelId)
|
|
40
|
+
if (!channel || channel.deleted_at) throw new ServiceError('NOT_FOUND', 'Channel not found')
|
|
41
|
+
if (channel.hub_id !== null && !this.hubService.canAccessHub(channel.hub_id, userId, userRoles)) throw new ServiceError('FORBIDDEN', 'Cannot access hub')
|
|
42
|
+
|
|
43
|
+
if (channel.visibility === 'private') {
|
|
44
|
+
const member = this.getMembership(channelId, userId)
|
|
45
|
+
if (!member || member.left_at || member.banned_at) throw new ServiceError('FORBIDDEN', 'Not a member of this channel')
|
|
46
|
+
return { channel_id: channelId, kind: channel.kind }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
this.channelRepo.upsertMembership({ channelId, userId, role: 'member', now: this.nowFn() })
|
|
50
|
+
return { channel_id: channelId, kind: channel.kind }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
leaveChannel({ channelId, userId }) {
|
|
54
|
+
const channel = this.getChannel(channelId)
|
|
55
|
+
// DM and private channels have permanent membership — the WS topic unsubscribes
|
|
56
|
+
// but the DB row is preserved. Private channel members cannot re-add themselves,
|
|
57
|
+
// so clearing left_at here would lock them out until an owner re-adds them.
|
|
58
|
+
const isPermanent = channel?.kind === 'dm' || channel?.visibility === 'private'
|
|
59
|
+
if (!isPermanent) {
|
|
60
|
+
this.channelRepo.setMemberLeft({ channelId, userId, now: this.nowFn() })
|
|
61
|
+
}
|
|
62
|
+
return { channel_id: channelId }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
isMember(channelId, userId) {
|
|
66
|
+
const member = this.getMembership(channelId, userId)
|
|
67
|
+
return !!member && !member.left_at && !member.banned_at
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
canAccessChannel(channelId, userId, roles = []) {
|
|
71
|
+
if (roles.includes('admin')) return true
|
|
72
|
+
const channel = this.getChannel(channelId)
|
|
73
|
+
if (!channel || channel.deleted_at) return false
|
|
74
|
+
// DM channels (hub_id = null) skip the hub access check — membership is the sole gate
|
|
75
|
+
if (channel.hub_id !== null && !this.hubService.canAccessHub(channel.hub_id, userId, roles)) return false
|
|
76
|
+
if (channel.visibility === 'public' && !roles.includes('guest')) return true
|
|
77
|
+
return this.isMember(channelId, userId)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
listChannelMembers(channelId) {
|
|
81
|
+
return this.channelRepo.listActiveMembers({ channelId })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
addMember({ channelId, createdByUserId, targetUserId }) {
|
|
85
|
+
const adder = this.getMembership(channelId, createdByUserId)
|
|
86
|
+
if (!adder || !['owner', 'mod'].includes(adder.role)) throw new ServiceError('FORBIDDEN', 'Only owner or mod can add members')
|
|
87
|
+
const channel = this.getChannel(channelId)
|
|
88
|
+
if (!channel) throw new ServiceError('NOT_FOUND', 'Channel not found')
|
|
89
|
+
const existing = this.getMembership(channelId, targetUserId)
|
|
90
|
+
if (existing && !existing.left_at && !existing.banned_at) throw new ServiceError('BAD_REQUEST', 'User is already a member')
|
|
91
|
+
|
|
92
|
+
this.channelRepo.upsertMembership({ channelId, userId: targetUserId, role: 'member', now: this.nowFn() })
|
|
93
|
+
|
|
94
|
+
return { channel_id: channelId, user_id: targetUserId }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
removeMember({ channelId, removedByUserId, targetUserId }) {
|
|
98
|
+
const remover = this.getMembership(channelId, removedByUserId)
|
|
99
|
+
if (!remover || !['owner', 'mod'].includes(remover.role)) throw new ServiceError('FORBIDDEN', 'Only owner or mod can remove members')
|
|
100
|
+
if (removedByUserId === targetUserId) throw new ServiceError('BAD_REQUEST', 'Use leaveChannel to leave a channel')
|
|
101
|
+
const target = this.getMembership(channelId, targetUserId)
|
|
102
|
+
if (!target || target.left_at || target.banned_at) throw new ServiceError('BAD_REQUEST', 'User is not a member')
|
|
103
|
+
|
|
104
|
+
this.channelRepo.setMemberLeft({ channelId, userId: targetUserId, now: this.nowFn() })
|
|
105
|
+
|
|
106
|
+
return { channel_id: channelId, user_id: targetUserId }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
getChannel(channelId) {
|
|
110
|
+
return this.channelRepo.findById({ channelId })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
getMembership(channelId, userId) {
|
|
114
|
+
return this.channelRepo.findMembership({ channelId, userId })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
findOrCreateDm({ userId, targetUserId }) {
|
|
118
|
+
if (userId === targetUserId) throw new ServiceError('BAD_REQUEST', 'Cannot DM yourself')
|
|
119
|
+
const name = buildDmChannelName(userId, targetUserId)
|
|
120
|
+
const existing = this.channelRepo.findDmByName({ name })
|
|
121
|
+
if (existing) return { channel_id: existing.channel_id, is_new: false }
|
|
122
|
+
const channelId = newId('c')
|
|
123
|
+
this.channelRepo.insertDmChannel({ channelId, name, userIdA: userId, userIdB: targetUserId, now: this.nowFn() })
|
|
124
|
+
return { channel_id: channelId, is_new: true }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
listDms({ userId }) {
|
|
128
|
+
return this.channelRepo.listDmsByUser({ userId })
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
ensureDefaultChannel(hubId, createdByUserId) {
|
|
132
|
+
const existing = this.channelRepo.findByHubAndName({ hubId, name: 'general' })
|
|
133
|
+
if (existing) return existing
|
|
134
|
+
return this.createChannel({ hubId, kind: 'text', name: 'general', topic: 'General discussions', visibility: 'public', createdByUserId, userRoles: ['admin'] })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
updateChannel({ channelId, userId, roles = [], name = null, topic = null, visibility = null }) {
|
|
138
|
+
const channel = this.getChannel(channelId)
|
|
139
|
+
if (!channel || channel.deleted_at) throw new ServiceError('NOT_FOUND', 'Channel not found')
|
|
140
|
+
const membership = this.getMembership(channelId, userId)
|
|
141
|
+
const isOwner = membership && membership.role === 'owner' && !membership.left_at && !membership.banned_at
|
|
142
|
+
if (!roles.includes('admin') && channel.created_by_user_id !== userId && !isOwner) throw new ServiceError('FORBIDDEN', 'Cannot update channel')
|
|
143
|
+
if (name === null && topic === null && visibility === null) throw new ServiceError('BAD_REQUEST', 'No fields to update')
|
|
144
|
+
|
|
145
|
+
const patch = {}
|
|
146
|
+
if (name !== null) {
|
|
147
|
+
if (!name.trim()) throw new ServiceError('BAD_REQUEST', 'Channel name cannot be empty')
|
|
148
|
+
patch.name = name.trim()
|
|
149
|
+
}
|
|
150
|
+
if (topic !== null) patch.topic = topic
|
|
151
|
+
if (visibility !== null) {
|
|
152
|
+
if (!['public', 'private'].includes(visibility)) throw new ServiceError('BAD_REQUEST', 'Channel visibility must be public or private')
|
|
153
|
+
patch.visibility = visibility
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.channelRepo.patchChannel({ channelId, ...patch })
|
|
157
|
+
return this.getChannel(channelId)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
deleteChannel({ channelId, userId, roles = [] }) {
|
|
161
|
+
const channel = this.getChannel(channelId)
|
|
162
|
+
if (!channel || channel.deleted_at) throw new ServiceError('NOT_FOUND', 'Channel not found')
|
|
163
|
+
const membership = this.getMembership(channelId, userId)
|
|
164
|
+
const isOwner = membership && membership.role === 'owner' && !membership.left_at && !membership.banned_at
|
|
165
|
+
if (!roles.includes('admin') && channel.created_by_user_id !== userId && !isOwner) throw new ServiceError('FORBIDDEN', 'Cannot delete channel')
|
|
166
|
+
|
|
167
|
+
this.channelRepo.softDeleteChannel({ channelId, now: this.nowFn() })
|
|
168
|
+
return { channel_id: channel.channel_id, hub_id: channel.hub_id }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
reorderChannels({ hubId, channelIds, userId, userRoles = [] }) {
|
|
172
|
+
if (!this.hubService.canAccessHub(hubId, userId, userRoles)) throw new ServiceError('FORBIDDEN', 'Cannot access hub')
|
|
173
|
+
if (!Array.isArray(channelIds) || channelIds.length === 0) throw new ServiceError('BAD_REQUEST', 'channelIds must be a non-empty array')
|
|
174
|
+
return this.channelRepo.reorderChannels({ hubId, channelIds })
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class DeliveryService {
|
|
2
|
+
constructor({ deliveryRepo, nowFn = () => Date.now() }) {
|
|
3
|
+
this.deliveryRepo = deliveryRepo
|
|
4
|
+
this.nowFn = nowFn
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
getOrCreate({ channelId, userId }) {
|
|
8
|
+
const existing = this.deliveryRepo.findByChannelAndUser({ channelId, userId })
|
|
9
|
+
if (existing) return existing
|
|
10
|
+
return this.deliveryRepo.create({ channelId, userId, now: this.nowFn() })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
advance({ channelId, userId, afterSeq }) {
|
|
14
|
+
this.deliveryRepo.advance({ channelId, userId, afterSeq, now: this.nowFn() })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
advanceMention({ channelId, userId, mentionSeq, priority = 'normal' }) {
|
|
18
|
+
// Ensure a delivery row exists before updating mention_seq.
|
|
19
|
+
// If Linda has never navigated to this channel, there is no row yet and
|
|
20
|
+
// the UPDATE in advanceMention would silently affect 0 rows, losing the mention.
|
|
21
|
+
this.getOrCreate({ channelId, userId })
|
|
22
|
+
this.deliveryRepo.advanceMention({ channelId, userId, mentionSeq, priority })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
buildDigestData({ userId }) {
|
|
26
|
+
return this.deliveryRepo.buildDigestData({ userId })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { newId } from '../util/ids.js'
|
|
2
|
+
import { ServiceError } from '../util/errors.js'
|
|
3
|
+
|
|
4
|
+
export class HubService {
|
|
5
|
+
constructor({ hubRepo, nowFn = () => Date.now() }) {
|
|
6
|
+
this.hubRepo = hubRepo
|
|
7
|
+
this.nowFn = nowFn
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
createHub({ name, description = null, visibility = 'public', createdByUserId }) {
|
|
11
|
+
if (!['public', 'restricted'].includes(visibility)) throw new ServiceError('BAD_REQUEST', 'Invalid hub visibility')
|
|
12
|
+
if (!name?.trim()) throw new ServiceError('BAD_REQUEST', 'Hub name required')
|
|
13
|
+
const hubId = newId('h')
|
|
14
|
+
const now = this.nowFn()
|
|
15
|
+
|
|
16
|
+
this.hubRepo.insertHubWithOwner({ hubId, name: name.trim(), description, visibility, createdByUserId, now })
|
|
17
|
+
|
|
18
|
+
return { hub_id: hubId, name: name.trim(), description, visibility }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
listHubs(userId, roles = []) {
|
|
22
|
+
if (roles.includes('admin')) return this.hubRepo.listAllHubs()
|
|
23
|
+
return this.hubRepo.listAccessibleHubs({ userId })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
canAccessHub(hubId, userId, roles = []) {
|
|
27
|
+
if (roles.includes('admin')) return true
|
|
28
|
+
const hub = this.getHub(hubId)
|
|
29
|
+
if (!hub || hub.deleted_at) return false
|
|
30
|
+
if (hub.visibility === 'public' && !roles.includes('guest')) return true
|
|
31
|
+
const member = this.getHubMembership(hubId, userId)
|
|
32
|
+
return !!member && !member.left_at
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
getHub(hubId) {
|
|
36
|
+
return this.hubRepo.findById({ hubId })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
getHubMembership(hubId, userId) {
|
|
40
|
+
return this.hubRepo.findMembership({ hubId, userId })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
joinHub(hubId, userId) {
|
|
44
|
+
const hub = this.getHub(hubId)
|
|
45
|
+
if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
|
|
46
|
+
this.hubRepo.upsertMembership({ hubId, userId, now: this.nowFn() })
|
|
47
|
+
return { hub_id: hubId }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
leaveHub(hubId, userId) {
|
|
51
|
+
this.hubRepo.setMemberLeft({ hubId, userId, now: this.nowFn() })
|
|
52
|
+
return { hub_id: hubId }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
ensureDefaultHub(createdByUserId) {
|
|
56
|
+
const existing = this.hubRepo.findByName({ name: 'Lobby' })
|
|
57
|
+
if (existing) return existing
|
|
58
|
+
return this.createHub({ name: 'Lobby', description: 'Main hub for general discussions', visibility: 'public', createdByUserId })
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
updateHub({ hubId, userId, roles = [], name = null, description = null, visibility = null }) {
|
|
62
|
+
const hub = this.getHub(hubId)
|
|
63
|
+
if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
|
|
64
|
+
if (!roles.includes('admin') && hub.created_by_user_id !== userId) throw new ServiceError('FORBIDDEN', 'Cannot update hub')
|
|
65
|
+
if (name === null && description === null && visibility === null) throw new ServiceError('BAD_REQUEST', 'No fields to update')
|
|
66
|
+
|
|
67
|
+
const patch = {}
|
|
68
|
+
if (name !== null) {
|
|
69
|
+
if (!name.trim()) throw new ServiceError('BAD_REQUEST', 'Hub name cannot be empty')
|
|
70
|
+
patch.name = name.trim()
|
|
71
|
+
}
|
|
72
|
+
if (description !== null) patch.description = description
|
|
73
|
+
if (visibility !== null) {
|
|
74
|
+
if (!['public', 'restricted'].includes(visibility)) throw new ServiceError('BAD_REQUEST', 'Hub visibility must be public or restricted')
|
|
75
|
+
patch.visibility = visibility
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this.hubRepo.patchHub({ hubId, ...patch })
|
|
79
|
+
return this.getHub(hubId)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
addHubMember({ hubId, targetUserId, requestingUserId, requestingRoles = [] }) {
|
|
83
|
+
const hub = this.getHub(hubId)
|
|
84
|
+
if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
|
|
85
|
+
const isAdmin = requestingRoles.includes('admin')
|
|
86
|
+
const isCreator = hub.created_by_user_id === requestingUserId
|
|
87
|
+
if (!isAdmin && !isCreator) throw new ServiceError('FORBIDDEN', 'Admin or hub creator required')
|
|
88
|
+
this.hubRepo.upsertMembership({ hubId, userId: targetUserId, now: this.nowFn() })
|
|
89
|
+
return { hub_id: hubId, user_id: targetUserId }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
removeHubMember({ hubId, targetUserId, requestingUserId, requestingRoles = [] }) {
|
|
93
|
+
const hub = this.getHub(hubId)
|
|
94
|
+
if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
|
|
95
|
+
const isAdmin = requestingRoles.includes('admin')
|
|
96
|
+
const isCreator = hub.created_by_user_id === requestingUserId
|
|
97
|
+
if (!isAdmin && !isCreator) throw new ServiceError('FORBIDDEN', 'Admin or hub creator required')
|
|
98
|
+
this.hubRepo.setMemberLeft({ hubId, userId: targetUserId, now: this.nowFn() })
|
|
99
|
+
return { hub_id: hubId, user_id: targetUserId }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
listHubMembers({ hubId, requestingUserId, requestingRoles = [] }) {
|
|
103
|
+
const hub = this.getHub(hubId)
|
|
104
|
+
if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
|
|
105
|
+
const isAdmin = requestingRoles.includes('admin')
|
|
106
|
+
const membership = this.getHubMembership(hubId, requestingUserId)
|
|
107
|
+
const isMember = membership && !membership.left_at
|
|
108
|
+
if (!isAdmin && !isMember) throw new ServiceError('FORBIDDEN', 'Hub membership required')
|
|
109
|
+
return this.hubRepo.listMembers({ hubId })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
reorderHubs({ hubIds, userId, userRoles = [] }) {
|
|
113
|
+
if (!Array.isArray(hubIds) || hubIds.length === 0) throw new ServiceError('BAD_REQUEST', 'hub_ids required')
|
|
114
|
+
if (!userRoles.includes('admin')) {
|
|
115
|
+
// Non-admins can only reorder hubs they own or are a member of — the list query
|
|
116
|
+
// already scopes to accessible hubs, so any hub_id not in that set is silently ignored.
|
|
117
|
+
}
|
|
118
|
+
this.hubRepo.reorderHubs({ hubIds })
|
|
119
|
+
return this.listHubs(userId, userRoles)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
deleteHub({ hubId, userId, roles = [] }) {
|
|
123
|
+
const hub = this.getHub(hubId)
|
|
124
|
+
if (!hub || hub.deleted_at) throw new ServiceError('NOT_FOUND', 'Hub not found')
|
|
125
|
+
if (!roles.includes('admin') && hub.created_by_user_id !== userId) throw new ServiceError('FORBIDDEN', 'Cannot delete hub')
|
|
126
|
+
|
|
127
|
+
const channelIds = this.hubRepo.listActiveChannelIds({ hubId })
|
|
128
|
+
const now = this.nowFn()
|
|
129
|
+
this.hubRepo.softDeleteHub({ hubId, now })
|
|
130
|
+
|
|
131
|
+
return { hub_id: hubId, channel_ids: channelIds }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { newId } from '../util/ids.js'
|
|
2
|
+
import { ServiceError } from '../util/errors.js'
|
|
3
|
+
import { validateEditPermission, validateEditText, assertMessageEditable, validateDeletePermission } from '../core/messages.js'
|
|
4
|
+
|
|
5
|
+
export class MessageService {
|
|
6
|
+
constructor({ messageRepo, nowFn = () => Date.now(), channelService, searchService, uploadService = null, reactionService = null }) {
|
|
7
|
+
this.messageRepo = messageRepo
|
|
8
|
+
this.nowFn = nowFn
|
|
9
|
+
this.channelService = channelService
|
|
10
|
+
this.searchService = searchService
|
|
11
|
+
this.uploadService = uploadService
|
|
12
|
+
this.reactionService = reactionService
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
setUploadService(uploadService) {
|
|
16
|
+
this.uploadService = uploadService
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
sendMessage({ channelId, userId, text, clientMsgId = null, priority = 'normal', attachments = [] }) {
|
|
20
|
+
if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
|
|
21
|
+
if (!text?.trim() && attachments.length === 0) throw new ServiceError('BAD_REQUEST', 'Message text or attachment required')
|
|
22
|
+
if (!['normal', 'async', 'now'].includes(priority)) throw new ServiceError('BAD_REQUEST', 'Invalid priority')
|
|
23
|
+
|
|
24
|
+
const msgId = newId('m')
|
|
25
|
+
const now = this.nowFn()
|
|
26
|
+
const trimmed = text?.trim() ?? ''
|
|
27
|
+
|
|
28
|
+
const attachmentsJson = attachments.length > 0 ? JSON.stringify(attachments) : null
|
|
29
|
+
|
|
30
|
+
const { seq } = this.messageRepo.insertMessage({
|
|
31
|
+
msgId, channelId, userId, now, text: trimmed, clientMsgId, priority, attachmentsJson
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
if (trimmed) {
|
|
35
|
+
this.searchService.indexMessage({ msg_id: msgId, channel_id: channelId, seq, user_id: userId, ts: now, text: trimmed })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let enrichedAttachments = attachments
|
|
39
|
+
if (this.uploadService && attachments.length > 0) {
|
|
40
|
+
const uploadIds = attachments.map(a => a.upload_id).filter(Boolean)
|
|
41
|
+
if (uploadIds.length > 0) {
|
|
42
|
+
this.uploadService.linkToMessage({ uploadIds, msgId, userId, channelId })
|
|
43
|
+
// Enrich with full upload details so consumers get url, mime_type, original_name.
|
|
44
|
+
enrichedAttachments = attachments.map(a => {
|
|
45
|
+
if (!a.upload_id) return a
|
|
46
|
+
const row = this.uploadService.getUpload({ uploadId: a.upload_id })
|
|
47
|
+
if (!row) return a
|
|
48
|
+
return {
|
|
49
|
+
upload_id: row.upload_id,
|
|
50
|
+
url: `/uploads/${row.upload_id}/${encodeURIComponent(row.original_name)}`,
|
|
51
|
+
original_name: row.original_name,
|
|
52
|
+
mime_type: row.mime_type,
|
|
53
|
+
size_bytes: row.size_bytes,
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { msg_id: msgId, seq, ts: now, priority, attachments: enrichedAttachments }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
editMessage({ msgId, channelId, userId, newText }) {
|
|
63
|
+
const msg = this.messageRepo.getById(msgId)
|
|
64
|
+
if (!msg) throw new ServiceError('NOT_FOUND', 'Message not found')
|
|
65
|
+
if (msg.channel_id !== channelId) throw new ServiceError('BAD_REQUEST', 'Message does not belong to this channel')
|
|
66
|
+
|
|
67
|
+
assertMessageEditable(msg.deleted_at)
|
|
68
|
+
validateEditPermission(userId, msg.user_id)
|
|
69
|
+
const trimmed = validateEditText(newText)
|
|
70
|
+
|
|
71
|
+
const editedAt = this.nowFn()
|
|
72
|
+
this.messageRepo.updateMessage({ msgId, text: trimmed, editedAt })
|
|
73
|
+
|
|
74
|
+
this.searchService.indexMessage({ msg_id: msgId, channel_id: channelId, seq: msg.seq, user_id: msg.user_id, ts: msg.ts, text: trimmed })
|
|
75
|
+
|
|
76
|
+
return { msgId, channelId, text: trimmed, editedAt }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
deleteMessage({ msgId, channelId, userId }) {
|
|
80
|
+
const msg = this.messageRepo.getById(msgId)
|
|
81
|
+
if (!msg) throw new ServiceError('NOT_FOUND', 'Message not found')
|
|
82
|
+
if (msg.channel_id !== channelId) throw new ServiceError('BAD_REQUEST', 'Message does not belong to this channel')
|
|
83
|
+
if (msg.deleted_at != null) throw new ServiceError('BAD_REQUEST', 'Message already deleted')
|
|
84
|
+
|
|
85
|
+
validateDeletePermission(userId, msg.user_id)
|
|
86
|
+
|
|
87
|
+
this.messageRepo.deleteMessage({ msgId, deletedAt: this.nowFn() })
|
|
88
|
+
this.searchService.removeMessage({ msgId })
|
|
89
|
+
|
|
90
|
+
return { msgId, channelId, seq: msg.seq }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
listMessages({ channelId, userId, afterSeq = 0, limit = 50 }) {
|
|
94
|
+
if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
|
|
95
|
+
|
|
96
|
+
const rows = this.messageRepo.listMessages({ channelId, afterSeq, limit })
|
|
97
|
+
const lastSeq = rows.length ? rows[rows.length - 1].seq : afterSeq
|
|
98
|
+
const messages = this.reactionService
|
|
99
|
+
? this.reactionService.enrichWithReactions({ messages: rows, requestingUserId: userId })
|
|
100
|
+
: rows
|
|
101
|
+
return { messages, next_after_seq: lastSeq }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
listLatestMessages({ channelId, userId, limit = 50 }) {
|
|
105
|
+
if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
|
|
106
|
+
const rows = this.messageRepo.listLatestMessages({ channelId, limit })
|
|
107
|
+
const messages = this.reactionService
|
|
108
|
+
? this.reactionService.enrichWithReactions({ messages: rows, requestingUserId: userId })
|
|
109
|
+
: rows
|
|
110
|
+
return { messages }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
listMessagesBefore({ channelId, userId, beforeSeq, limit = 50 }) {
|
|
114
|
+
if (!this.channelService.isMember(channelId, userId)) throw new ServiceError('FORBIDDEN', 'Not a member of channel')
|
|
115
|
+
const rows = this.messageRepo.listMessagesBefore({ channelId, beforeSeq, limit })
|
|
116
|
+
const hasMore = rows.length === limit
|
|
117
|
+
const messages = this.reactionService
|
|
118
|
+
? this.reactionService.enrichWithReactions({ messages: rows, requestingUserId: userId })
|
|
119
|
+
: rows
|
|
120
|
+
return { messages, has_more: hasMore }
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export class NotificationService {
|
|
2
|
+
constructor({ deliveryService, authService }) {
|
|
3
|
+
this.deliveryService = deliveryService
|
|
4
|
+
this.authService = authService
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Build the reconnect digest for a user.
|
|
9
|
+
* Returns { channels, dms } — only entries with unread > 0 or a pending mention.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} userId
|
|
12
|
+
* @param {number} [lastSeenAt] — epoch ms of last session activity; used for away_duration_ms
|
|
13
|
+
*/
|
|
14
|
+
buildDigest(userId, lastSeenAt = null) {
|
|
15
|
+
const rows = this.deliveryService.buildDigestData({ userId })
|
|
16
|
+
const channels = []
|
|
17
|
+
const dms = []
|
|
18
|
+
|
|
19
|
+
for (const row of rows) {
|
|
20
|
+
const unread = Math.max(0, (row.max_seq ?? 0) - (row.after_seq ?? 0))
|
|
21
|
+
const hasMention = (row.mention_seq ?? 0) > (row.after_seq ?? 0)
|
|
22
|
+
if (unread === 0 && !hasMention) continue
|
|
23
|
+
|
|
24
|
+
if (row.kind === 'dm') {
|
|
25
|
+
const other = row.other_user_id ? this.authService.getUser(row.other_user_id) : null
|
|
26
|
+
dms.push({
|
|
27
|
+
channel_id: row.channel_id,
|
|
28
|
+
with_user: { user_id: row.other_user_id, display_name: other?.display_name ?? row.other_user_id },
|
|
29
|
+
unread,
|
|
30
|
+
})
|
|
31
|
+
} else {
|
|
32
|
+
channels.push({
|
|
33
|
+
channel_id: row.channel_id,
|
|
34
|
+
name: row.name,
|
|
35
|
+
unread,
|
|
36
|
+
mentions: hasMention ? 1 : 0,
|
|
37
|
+
urgent: hasMention && row.mention_priority === 'now',
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const away_duration_ms = lastSeenAt ? Math.max(0, Date.now() - lastSeenAt) : null
|
|
43
|
+
return { channels, dms, away_duration_ms }
|
|
44
|
+
}
|
|
45
|
+
}
|