@devchitchat/chat 4.0.0 → 4.0.2
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/package.json +1 -1
- package/pages/admin/bots/[userId].js +35 -16
- package/pages/admin/bots/[userId].phtml +79 -6
- package/pages/admin/bots/index.js +1 -4
- package/pages/admin/index.js +1 -1
- package/pages/admin/invites/index.js +2 -5
- package/pages/admin/users/[userId].js +3 -3
- package/pages/channels/[channelId].js +1 -1
- package/pages/index.js +4 -4
- package/pages/invite/[token].js +1 -1
- package/pages/login/index.js +1 -1
- package/pages/public/themes/base.css +58 -0
- package/pages/registration/index.js +1 -1
- package/src/adapters/SqliteChannelRepository.js +12 -0
- package/src/services/BotService.js +15 -2
- package/src/ws/ChatServer.js +6 -3
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { requireAdminSession } from '../../../src/adminAuth.js'
|
|
2
|
-
import { botService, channelService } from '../../../src/context.js'
|
|
2
|
+
import { botService, channelService, hubService } from '../../../src/context.js'
|
|
3
3
|
import { randomToken } from '../../../src/util/crypto.js'
|
|
4
4
|
import { p } from '../../../src/config.js'
|
|
5
5
|
|
|
@@ -25,10 +25,34 @@ export function GET(req) {
|
|
|
25
25
|
if (flashId) tokenFlashes.delete(flashId)
|
|
26
26
|
const flash = url.searchParams.get('flash') ?? null
|
|
27
27
|
|
|
28
|
-
// All channels for channel assignment checkboxes
|
|
29
28
|
const allChannels = channelService.listChannels(session.user.user_id, session.user.roles)
|
|
29
|
+
const allHubs = hubService.listHubs(session.user.user_id, session.user.roles)
|
|
30
30
|
const botChannelIds = new Set(bot.channels.map(c => c.channel_id))
|
|
31
31
|
|
|
32
|
+
// Group channels by hub, preserving hub order; collect channels with no hub separately
|
|
33
|
+
const hubMap = new Map(allHubs.map(h => [h.hub_id, { ...h, channels: [] }]))
|
|
34
|
+
const noHubChannels = []
|
|
35
|
+
for (const ch of allChannels) {
|
|
36
|
+
const entry = { ...ch, checked: botChannelIds.has(ch.channel_id) }
|
|
37
|
+
if (ch.hub_id && hubMap.has(ch.hub_id)) {
|
|
38
|
+
hubMap.get(ch.hub_id).channels.push(entry)
|
|
39
|
+
} else {
|
|
40
|
+
noHubChannels.push(entry)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Compute hub-level checked/indeterminate state for UI rendering
|
|
45
|
+
const hubGroups = [...hubMap.values()]
|
|
46
|
+
.filter(h => h.channels.length > 0)
|
|
47
|
+
.map(h => {
|
|
48
|
+
const checkedCount = h.channels.filter(c => c.checked).length
|
|
49
|
+
return {
|
|
50
|
+
...h,
|
|
51
|
+
hubChecked: checkedCount === h.channels.length,
|
|
52
|
+
hubIndeterminate: checkedCount > 0 && checkedCount < h.channels.length,
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
32
56
|
return {
|
|
33
57
|
user: session.user,
|
|
34
58
|
pageTitle: `Admin — Bot: ${bot.handle}`,
|
|
@@ -37,16 +61,14 @@ export function GET(req) {
|
|
|
37
61
|
flash,
|
|
38
62
|
tokens: bot.tokens.map(t => ({
|
|
39
63
|
...t,
|
|
40
|
-
created_at_fmt:
|
|
41
|
-
expires_at_fmt:
|
|
64
|
+
created_at_fmt: new Date(t.created_at).toLocaleString(),
|
|
65
|
+
expires_at_fmt: t.expires_at ? new Date(t.expires_at).toLocaleString() : 'Never',
|
|
42
66
|
last_used_at_fmt: t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'Never',
|
|
43
|
-
revoked:
|
|
44
|
-
expired:
|
|
45
|
-
})),
|
|
46
|
-
allChannels: allChannels.map(c => ({
|
|
47
|
-
...c,
|
|
48
|
-
checked: botChannelIds.has(c.channel_id),
|
|
67
|
+
revoked: !!t.revoked_at,
|
|
68
|
+
expired: !t.revoked_at && t.expires_at != null && t.expires_at <= Date.now(),
|
|
49
69
|
})),
|
|
70
|
+
hubGroups,
|
|
71
|
+
noHubChannels,
|
|
50
72
|
}
|
|
51
73
|
}
|
|
52
74
|
|
|
@@ -66,22 +88,19 @@ export async function POST(req) {
|
|
|
66
88
|
// Store the token in the server-side flash map — never put it in the URL
|
|
67
89
|
const flashId = randomToken(8)
|
|
68
90
|
tokenFlashes.set(flashId, result.token)
|
|
69
|
-
return Response.redirect(
|
|
70
|
-
new URL(p(`/admin/bots/${botUserId}?flash_id=${encodeURIComponent(flashId)}`), req.url),
|
|
71
|
-
303
|
|
72
|
-
)
|
|
91
|
+
return Response.redirect(p(`/admin/bots/${botUserId}?flash_id=${encodeURIComponent(flashId)}`), 303)
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
if (action === 'revoke_token') {
|
|
76
95
|
const tokenId = form.get('token_id')
|
|
77
96
|
botService.revokeToken({ tokenId, requestingUserId: session.user.user_id })
|
|
78
|
-
return Response.redirect(
|
|
97
|
+
return Response.redirect(p(`/admin/bots/${botUserId}?flash=token_revoked`), 303)
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
if (action === 'set_channels') {
|
|
82
101
|
const channelIds = form.getAll('channel_ids')
|
|
83
102
|
botService.setBotChannels({ userId: botUserId, channelIds, requestingUserId: session.user.user_id })
|
|
84
|
-
return Response.redirect(
|
|
103
|
+
return Response.redirect(p(`/admin/bots/${botUserId}?flash=channels_updated`), 303)
|
|
85
104
|
}
|
|
86
105
|
|
|
87
106
|
return new Response('Bad request', { status: 400 })
|
|
@@ -75,15 +75,88 @@
|
|
|
75
75
|
|
|
76
76
|
<section class="admin-section">
|
|
77
77
|
<h2>Channel access</h2>
|
|
78
|
-
<form method="POST" class="admin-form">
|
|
78
|
+
<form method="POST" class="admin-form" id="channel-access-form">
|
|
79
79
|
<input type="hidden" name="action" value="set_channels">
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
80
|
+
|
|
81
|
+
{{#each hubGroups}}
|
|
82
|
+
<details class="hub-group" {{#if hubChecked}}open{{/if}}{{#if hubIndeterminate}}open{{/if}}>
|
|
83
|
+
<summary class="hub-group__summary">
|
|
84
|
+
<input type="checkbox"
|
|
85
|
+
class="hub-checkbox"
|
|
86
|
+
data-hub="{{hub_id}}"
|
|
87
|
+
{{#if hubChecked}}checked{{/if}}
|
|
88
|
+
{{#if hubIndeterminate}}data-indeterminate="true"{{/if}}>
|
|
89
|
+
<span class="hub-group__name">{{name}}</span>
|
|
90
|
+
<span class="hub-group__badge">{{visibility}}</span>
|
|
91
|
+
</summary>
|
|
92
|
+
<div class="hub-group__channels">
|
|
93
|
+
{{#each channels}}
|
|
94
|
+
<label class="checkbox-label checkbox-label--indented">
|
|
95
|
+
<input type="checkbox"
|
|
96
|
+
class="channel-checkbox"
|
|
97
|
+
name="channel_ids"
|
|
98
|
+
value="{{channel_id}}"
|
|
99
|
+
data-hub="{{hub_id}}"
|
|
100
|
+
{{#if checked}}checked{{/if}}>
|
|
101
|
+
# {{name}}
|
|
102
|
+
</label>
|
|
103
|
+
{{/each}}
|
|
104
|
+
</div>
|
|
105
|
+
</details>
|
|
85
106
|
{{/each}}
|
|
107
|
+
|
|
108
|
+
{{#if noHubChannels}}
|
|
109
|
+
<details class="hub-group" open>
|
|
110
|
+
<summary class="hub-group__summary">
|
|
111
|
+
<span class="hub-group__name">No hub</span>
|
|
112
|
+
</summary>
|
|
113
|
+
<div class="hub-group__channels">
|
|
114
|
+
{{#each noHubChannels}}
|
|
115
|
+
<label class="checkbox-label checkbox-label--indented">
|
|
116
|
+
<input type="checkbox"
|
|
117
|
+
class="channel-checkbox"
|
|
118
|
+
name="channel_ids"
|
|
119
|
+
value="{{channel_id}}"
|
|
120
|
+
{{#if checked}}checked{{/if}}>
|
|
121
|
+
# {{name}}
|
|
122
|
+
</label>
|
|
123
|
+
{{/each}}
|
|
124
|
+
</div>
|
|
125
|
+
</details>
|
|
126
|
+
{{/if}}
|
|
127
|
+
|
|
86
128
|
<button type="submit" class="btn">Save channel access</button>
|
|
87
129
|
</form>
|
|
88
130
|
</section>
|
|
131
|
+
|
|
132
|
+
<script>
|
|
133
|
+
// Set indeterminate state (can't be done in HTML, only via JS property)
|
|
134
|
+
document.querySelectorAll('.hub-checkbox[data-indeterminate="true"]').forEach(cb => {
|
|
135
|
+
cb.indeterminate = true
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// Hub checkbox toggles all its channel checkboxes
|
|
139
|
+
document.querySelectorAll('.hub-checkbox').forEach(hubCb => {
|
|
140
|
+
hubCb.addEventListener('change', () => {
|
|
141
|
+
const hubId = hubCb.dataset.hub
|
|
142
|
+
document.querySelectorAll(`.channel-checkbox[data-hub="${hubId}"]`).forEach(ch => {
|
|
143
|
+
ch.checked = hubCb.checked
|
|
144
|
+
})
|
|
145
|
+
hubCb.indeterminate = false
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Channel checkbox updates its hub checkbox state
|
|
150
|
+
document.querySelectorAll('.channel-checkbox[data-hub]').forEach(chanCb => {
|
|
151
|
+
chanCb.addEventListener('change', () => {
|
|
152
|
+
const hubId = chanCb.dataset.hub
|
|
153
|
+
const hubCb = document.querySelector(`.hub-checkbox[data-hub="${hubId}"]`)
|
|
154
|
+
if (!hubCb) return
|
|
155
|
+
const siblings = [...document.querySelectorAll(`.channel-checkbox[data-hub="${hubId}"]`)]
|
|
156
|
+
const checkedCount = siblings.filter(c => c.checked).length
|
|
157
|
+
hubCb.checked = checkedCount === siblings.length
|
|
158
|
+
hubCb.indeterminate = checkedCount > 0 && checkedCount < siblings.length
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
</script>
|
|
89
162
|
</div>
|
|
@@ -34,8 +34,5 @@ export async function POST(req) {
|
|
|
34
34
|
requestingUserId: session.user.user_id,
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
return Response.redirect(
|
|
38
|
-
new URL(p(`/admin/bots/${result.userId}?created_token=${encodeURIComponent(result.token)}`), req.url),
|
|
39
|
-
303
|
|
40
|
-
)
|
|
37
|
+
return Response.redirect(p(`/admin/bots/${result.userId}?created_token=${encodeURIComponent(result.token)}`), 303)
|
|
41
38
|
}
|
package/pages/admin/index.js
CHANGED
|
@@ -4,5 +4,5 @@ import { p } from '../../src/config.js'
|
|
|
4
4
|
export function GET(req) {
|
|
5
5
|
const session = requireAdminSession(req)
|
|
6
6
|
if (session instanceof Response) return session
|
|
7
|
-
return Response.redirect(
|
|
7
|
+
return Response.redirect(p('/admin/invites'), 302)
|
|
8
8
|
}
|
|
@@ -56,16 +56,13 @@ export async function POST(req) {
|
|
|
56
56
|
note,
|
|
57
57
|
roles,
|
|
58
58
|
})
|
|
59
|
-
return Response.redirect(
|
|
60
|
-
new URL(p(`/admin/invites?created=${encodeURIComponent(invite.inviteToken)}`), req.url),
|
|
61
|
-
303
|
|
62
|
-
)
|
|
59
|
+
return Response.redirect(p(`/admin/invites?created=${encodeURIComponent(invite.inviteToken)}`), 303)
|
|
63
60
|
}
|
|
64
61
|
|
|
65
62
|
if (action === 'revoke') {
|
|
66
63
|
const inviteId = form.get('invite_id')
|
|
67
64
|
auth.revokeInvite({ inviteId, requestingUserId: session.user.user_id })
|
|
68
|
-
return Response.redirect(
|
|
65
|
+
return Response.redirect(p('/admin/invites'), 303)
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
return new Response('Bad request', { status: 400 })
|
|
@@ -41,19 +41,19 @@ export async function POST(req) {
|
|
|
41
41
|
if (action === 'set_display_name') {
|
|
42
42
|
const displayName = form.get('display_name')
|
|
43
43
|
auth.adminUpdateDisplayName({ targetUserId, displayName, requestingUserId: session.user.user_id })
|
|
44
|
-
return Response.redirect(
|
|
44
|
+
return Response.redirect(p(`/admin/users/${targetUserId}?flash=display_name_updated`), 303)
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
if (action === 'set_roles') {
|
|
48
48
|
const roles = form.getAll('roles')
|
|
49
49
|
auth.setUserRoles({ targetUserId, roles, requestingUserId: session.user.user_id })
|
|
50
|
-
return Response.redirect(
|
|
50
|
+
return Response.redirect(p(`/admin/users/${targetUserId}?flash=roles_updated`), 303)
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
if (action === 'set_password') {
|
|
54
54
|
const newPassword = form.get('new_password')
|
|
55
55
|
await auth.adminSetPassword({ targetUserId, newPassword, requestingUserId: session.user.user_id })
|
|
56
|
-
return Response.redirect(
|
|
56
|
+
return Response.redirect(p(`/admin/users/${targetUserId}?flash=password_updated`), 303)
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
return new Response('Bad request', { status: 400 })
|
|
@@ -14,7 +14,7 @@ function sanitizeForFrontEnd(html) {
|
|
|
14
14
|
|
|
15
15
|
export async function GET(req) {
|
|
16
16
|
const session = sessionFromRequest(req)
|
|
17
|
-
if (!session) return Response.redirect(
|
|
17
|
+
if (!session) return Response.redirect(p('/login'), 302)
|
|
18
18
|
|
|
19
19
|
const url = new URL(req.url)
|
|
20
20
|
const channelId = url.pathname.split('/').pop()
|
package/pages/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { p } from '../src/config.js'
|
|
|
4
4
|
export async function GET(req) {
|
|
5
5
|
const session = sessionFromRequest(req)
|
|
6
6
|
if (!session) {
|
|
7
|
-
return Response.redirect(
|
|
7
|
+
return Response.redirect(p('/login'), 302)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
const user = session.user
|
|
@@ -15,19 +15,19 @@ export async function GET(req) {
|
|
|
15
15
|
const { settings } = userSettingsService.getSettings(user.user_id)
|
|
16
16
|
if (settings.last_channel_id) {
|
|
17
17
|
if (channelService.canAccessChannel(settings.last_channel_id, user.user_id, user.roles)) {
|
|
18
|
-
return Response.redirect(
|
|
18
|
+
return Response.redirect(p(`/channels/${settings.last_channel_id}`), 302)
|
|
19
19
|
}
|
|
20
20
|
userSettingsService.putSettings(user.user_id, { last_channel_id: null }, Date.now())
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const channels = channelService.listChannels(user.user_id, user.roles)
|
|
24
24
|
if (channels.length > 0) {
|
|
25
|
-
return Response.redirect(
|
|
25
|
+
return Response.redirect(p(`/channels/${channels[0].channel_id}`), 302)
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// No channels yet — bootstrap defaults and redirect
|
|
29
29
|
const hub = hubService.ensureDefaultHub(user.user_id)
|
|
30
30
|
const channel = channelService.ensureDefaultChannel(hub.hub_id, user.user_id)
|
|
31
31
|
channelService.joinChannel({ channelId: channel.channel_id, userId: user.user_id, userRoles: user.roles })
|
|
32
|
-
return Response.redirect(
|
|
32
|
+
return Response.redirect(p(`/channels/${channel.channel_id}`), 302)
|
|
33
33
|
}
|
package/pages/invite/[token].js
CHANGED
|
@@ -6,5 +6,5 @@ import { p } from '../../src/config.js'
|
|
|
6
6
|
export async function GET(req) {
|
|
7
7
|
const url = new URL(req.url)
|
|
8
8
|
const token = url.pathname.split('/').pop()
|
|
9
|
-
return Response.redirect(
|
|
9
|
+
return Response.redirect(p(`/login?invite=${encodeURIComponent(token)}`), 302)
|
|
10
10
|
}
|
package/pages/login/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { p } from '../../src/config.js'
|
|
|
3
3
|
|
|
4
4
|
export async function GET(req) {
|
|
5
5
|
const session = sessionFromRequest(req)
|
|
6
|
-
if (session) return Response.redirect(
|
|
6
|
+
if (session) return Response.redirect(p('/'), 302)
|
|
7
7
|
|
|
8
8
|
const url = new URL(req.url)
|
|
9
9
|
const inviteToken = url.searchParams.get('invite') ?? ''
|
|
@@ -1484,6 +1484,64 @@ body:has(.admin-topbar) main {
|
|
|
1484
1484
|
cursor: pointer;
|
|
1485
1485
|
}
|
|
1486
1486
|
|
|
1487
|
+
.checkbox-label--indented {
|
|
1488
|
+
padding-left: 24px;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
.hub-group {
|
|
1492
|
+
border: 1px solid var(--border);
|
|
1493
|
+
border-radius: 6px;
|
|
1494
|
+
margin-bottom: 8px;
|
|
1495
|
+
overflow: hidden;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
.hub-group__summary {
|
|
1499
|
+
display: flex;
|
|
1500
|
+
align-items: center;
|
|
1501
|
+
gap: 8px;
|
|
1502
|
+
padding: 8px 12px;
|
|
1503
|
+
background: var(--bg-secondary);
|
|
1504
|
+
cursor: pointer;
|
|
1505
|
+
font-size: 14px;
|
|
1506
|
+
font-weight: 500;
|
|
1507
|
+
list-style: none;
|
|
1508
|
+
user-select: none;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
.hub-group__summary::-webkit-details-marker { display: none; }
|
|
1512
|
+
|
|
1513
|
+
.hub-group__summary::before {
|
|
1514
|
+
content: '▶';
|
|
1515
|
+
font-size: 10px;
|
|
1516
|
+
color: var(--text-muted);
|
|
1517
|
+
transition: transform 0.15s;
|
|
1518
|
+
flex-shrink: 0;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
details.hub-group[open] > .hub-group__summary::before {
|
|
1522
|
+
transform: rotate(90deg);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
.hub-group__name {
|
|
1526
|
+
flex: 1;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
.hub-group__badge {
|
|
1530
|
+
font-size: 11px;
|
|
1531
|
+
font-weight: normal;
|
|
1532
|
+
color: var(--text-muted);
|
|
1533
|
+
background: var(--bg-input);
|
|
1534
|
+
padding: 2px 6px;
|
|
1535
|
+
border-radius: 10px;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
.hub-group__channels {
|
|
1539
|
+
padding: 8px 12px;
|
|
1540
|
+
display: flex;
|
|
1541
|
+
flex-direction: column;
|
|
1542
|
+
gap: 6px;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1487
1545
|
.token-display {
|
|
1488
1546
|
display: inline-block;
|
|
1489
1547
|
margin-top: 8px;
|
|
@@ -3,7 +3,7 @@ import { p } from '../../src/config.js'
|
|
|
3
3
|
|
|
4
4
|
export async function GET(req) {
|
|
5
5
|
const session = sessionFromRequest(req)
|
|
6
|
-
if (session) return Response.redirect(
|
|
6
|
+
if (session) return Response.redirect(p('/'), 302)
|
|
7
7
|
|
|
8
8
|
const url = new URL(req.url)
|
|
9
9
|
const inviteToken = url.searchParams.get('invite') ?? ''
|
|
@@ -49,6 +49,18 @@ export class SqliteChannelRepository {
|
|
|
49
49
|
).all()
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
listMemberships({ userId }) {
|
|
53
|
+
return this.db.prepare(
|
|
54
|
+
`SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
|
|
55
|
+
FROM channels c
|
|
56
|
+
JOIN hubs h ON c.hub_id = h.hub_id
|
|
57
|
+
JOIN channel_members cm ON cm.channel_id = c.channel_id
|
|
58
|
+
WHERE c.deleted_at IS NULL AND h.deleted_at IS NULL
|
|
59
|
+
AND cm.user_id = ? AND cm.left_at IS NULL AND cm.banned_at IS NULL
|
|
60
|
+
ORDER BY h.name, c.sort_order ASC, c.created_at ASC`
|
|
61
|
+
).all(userId)
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
listAccessible({ userId, isGuest = false }) {
|
|
53
65
|
return this.db.prepare(
|
|
54
66
|
`SELECT c.channel_id, c.hub_id, c.name, c.kind, c.visibility, c.topic, c.sort_order, h.name AS hub_name
|
|
@@ -11,10 +11,11 @@ import { randomToken, hashToken } from '../util/crypto.js'
|
|
|
11
11
|
import { ServiceError } from '../util/errors.js'
|
|
12
12
|
|
|
13
13
|
export class BotService {
|
|
14
|
-
constructor({ authService, authRepo, channelRepo, nowFn = () => Date.now() }) {
|
|
14
|
+
constructor({ authService, authRepo, channelRepo, hubService, nowFn = () => Date.now() }) {
|
|
15
15
|
this.authService = authService
|
|
16
16
|
this.authRepo = authRepo
|
|
17
17
|
this.channelRepo = channelRepo
|
|
18
|
+
this.hubService = hubService
|
|
18
19
|
this.nowFn = nowFn
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -119,10 +120,22 @@ export class BotService {
|
|
|
119
120
|
for (const channelId of toLeave) {
|
|
120
121
|
this.channelRepo.setMemberLeft({ channelId, userId, now })
|
|
121
122
|
}
|
|
123
|
+
|
|
124
|
+
// Ensure hub membership for every channel in the final set.
|
|
125
|
+
// Done after the membership loop so it covers both new and pre-existing
|
|
126
|
+
// channel memberships (upsert is idempotent so re-running is safe).
|
|
127
|
+
const hubIds = new Set()
|
|
128
|
+
for (const channelId of next) {
|
|
129
|
+
const channel = this.channelRepo.findById({ channelId })
|
|
130
|
+
if (channel?.hub_id) hubIds.add(channel.hub_id)
|
|
131
|
+
}
|
|
132
|
+
for (const hubId of hubIds) {
|
|
133
|
+
this.hubService.joinHub(hubId, userId)
|
|
134
|
+
}
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
_getBotChannels(userId) {
|
|
125
|
-
return this.channelRepo.
|
|
138
|
+
return this.channelRepo.listMemberships({ userId })
|
|
126
139
|
}
|
|
127
140
|
|
|
128
141
|
// ── Internals ──────────────────────────────────────────────────────────────
|
package/src/ws/ChatServer.js
CHANGED
|
@@ -77,7 +77,7 @@ export class ChatServer {
|
|
|
77
77
|
this.notificationService = new NotificationService({ deliveryService: this.deliveryService, authService: this.auth })
|
|
78
78
|
this.presenceService = new PresenceService()
|
|
79
79
|
this.signalingService = new SignalingService({ signalingRepo: new SqliteSignalingRepository({ db }) })
|
|
80
|
-
this.botService = new BotService({ authService: this.auth, authRepo, channelRepo })
|
|
80
|
+
this.botService = new BotService({ authService: this.auth, authRepo, channelRepo, hubService: this.hubService })
|
|
81
81
|
this.pushService = new WebPushService({
|
|
82
82
|
vapidPublicKey: process.env.VAPID_PUBLIC_KEY ?? null,
|
|
83
83
|
vapidPrivateKey: process.env.VAPID_PRIVATE_KEY ?? null,
|
|
@@ -323,8 +323,12 @@ export class ChatServer {
|
|
|
323
323
|
})
|
|
324
324
|
.filter(Boolean)
|
|
325
325
|
} else {
|
|
326
|
+
const memberIds = new Set(
|
|
327
|
+
this.channelService.listChannelMembers(channelId).map(m => m.user_id)
|
|
328
|
+
)
|
|
326
329
|
candidates = this.auth.listUsersBasic()
|
|
327
|
-
.filter(u => u.user_id !== senderId
|
|
330
|
+
.filter(u => u.user_id !== senderId)
|
|
331
|
+
.filter(u => !u.roles.includes('bot') || memberIds.has(u.user_id))
|
|
328
332
|
.map(u => ({ user_id: u.user_id, handle: u.handle }))
|
|
329
333
|
}
|
|
330
334
|
|
|
@@ -337,7 +341,6 @@ export class ChatServer {
|
|
|
337
341
|
}))
|
|
338
342
|
if (priority === 'now' && this.pushService.isConfigured()) {
|
|
339
343
|
const sender = this.auth.getUser(senderId)
|
|
340
|
-
const channel = this.channelService.getChannel(channelId)
|
|
341
344
|
this.pushService.sendToUser({
|
|
342
345
|
userId: user_id,
|
|
343
346
|
title: `@${sender?.handle ?? 'someone'} mentioned you`,
|