@devchitchat/chat 3.0.61 → 4.0.1
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 +66 -2
- package/index.js +132 -50
- package/package.json +2 -2
- package/pages/admin/bots/[userId].js +32 -10
- package/pages/admin/bots/[userId].phtml +79 -6
- package/pages/public/themes/base.css +58 -0
- package/src/adapters/SqliteChannelRepository.js +12 -0
- package/src/services/BotService.js +15 -2
- package/src/ws/ChatServer.js +6 -3
package/README.md
CHANGED
|
@@ -30,11 +30,74 @@ The system should just create a default hub and channel. That way, on bootstrap,
|
|
|
30
30
|
|
|
31
31
|
# Getting Started
|
|
32
32
|
|
|
33
|
-
##
|
|
33
|
+
## Install as a package
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bun add @devchitchat/chat
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Then start it from your own server file:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
// server.mjs
|
|
43
|
+
import { start } from '@devchitchat/chat'
|
|
44
|
+
|
|
45
|
+
const server = await start({
|
|
46
|
+
port: 3000,
|
|
47
|
+
basePath: '/chat', // mount at example.com/chat — omit for root
|
|
48
|
+
dbPath: './data/chat.db',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
console.log(`chat listening on port ${server.port}`)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
All config options and their defaults:
|
|
55
|
+
|
|
56
|
+
| Option | Env var fallback | Default |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `port` | `PORT` | `3000` |
|
|
59
|
+
| `dbPath` | `DB_PATH` | `./data/chat.db` |
|
|
60
|
+
| `basePath` | `BASE_PATH` | `""` (root) |
|
|
61
|
+
| `dev` | `NODE_ENV !== 'production'` | `true` |
|
|
62
|
+
| `tlsCert` | `TLS_CERT` | `./certs/dev-cert.pem` |
|
|
63
|
+
| `tlsKey` | `TLS_KEY` | `./certs/dev-key.pem` |
|
|
64
|
+
|
|
65
|
+
Every option falls back to its environment variable, so you can configure via env instead of passing a config object:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
BASE_PATH=/chat PORT=3000 DB_PATH=./data/chat.db bun server.mjs
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`start()` returns the [Bun server instance](https://bun.sh/docs/api/http#bun-serve) — you can inspect `server.port`, stop it with `server.stop()`, etc.
|
|
72
|
+
|
|
73
|
+
Migrations run automatically on every call to `start()` — no separate migration step needed.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Run standalone
|
|
78
|
+
|
|
79
|
+
To run the chat app directly without writing a wrapper:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bunx devchitchat
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Or install globally:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
bun install -g @devchitchat/chat
|
|
89
|
+
devchitchat
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Run from source
|
|
95
|
+
|
|
96
|
+
### Prerequisites
|
|
34
97
|
|
|
35
98
|
- [Bun](https://bun.sh) v1.0 or later
|
|
36
99
|
|
|
37
|
-
|
|
100
|
+
### Install
|
|
38
101
|
|
|
39
102
|
```bash
|
|
40
103
|
bun install
|
|
@@ -121,6 +184,7 @@ Copy that URL and open it in a browser to create the first account, which become
|
|
|
121
184
|
|---|---|---|
|
|
122
185
|
| `PORT` | `3000` | Port the server listens on |
|
|
123
186
|
| `DB_PATH` | `data/chat.db` | Path to the SQLite database file |
|
|
187
|
+
| `BASE_PATH` | `""` | URL subpath to mount the app at (e.g. `/chat`) |
|
|
124
188
|
| `NODE_ENV` | `development` | Set to `production` in production |
|
|
125
189
|
| `TLS_CERT` | `certs/dev-cert.pem` | Path to the TLS certificate |
|
|
126
190
|
| `TLS_KEY` | `certs/dev-key.pem` | Path to the TLS private key |
|
package/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { createServer } from '@devchitchat/index97'
|
|
2
|
+
import { createServer, createRoutes } from '@devchitchat/index97'
|
|
3
3
|
import { openDatabase } from './src/db/openDb.js'
|
|
4
4
|
import { initDb } from './src/db/initDb.js'
|
|
5
5
|
import { runMigrations } from './src/db/runMigrations.js'
|
|
@@ -12,26 +12,16 @@ import { UploadService } from './src/services/UploadService.js'
|
|
|
12
12
|
import { LocalFileStore } from './src/adapters/LocalFileStore.js'
|
|
13
13
|
import { SqliteUploadRepository } from './src/adapters/SqliteUploadRepository.js'
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
* @param {string} [config.basePath] - URL subpath to mount the app at, e.g. '/chat'. Default: process.env.BASE_PATH ?? ''
|
|
22
|
-
* @param {boolean} [config.dev] - Enable dev mode. Default: process.env.NODE_ENV !== 'production'
|
|
23
|
-
* @param {string} [config.tlsCert] - Path to TLS certificate. Default: process.env.TLS_CERT ?? './certs/dev-cert.pem'
|
|
24
|
-
* @param {string} [config.tlsKey] - Path to TLS private key. Default: process.env.TLS_KEY ?? './certs/dev-key.pem'
|
|
25
|
-
* @returns {Promise<import('bun').Server>}
|
|
26
|
-
*/
|
|
27
|
-
export async function start(config = {}) {
|
|
15
|
+
const CHAT_PAGES_DIR = import.meta.dir + '/pages'
|
|
16
|
+
const CHAT_PUBLIC_DIR = CHAT_PAGES_DIR + '/public'
|
|
17
|
+
const CHAT_CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:"
|
|
18
|
+
const CHAT_PERMISSIONS_POLICY = 'camera=(self), microphone=(self), display-capture=(self)'
|
|
19
|
+
|
|
20
|
+
async function _setupServices(config = {}) {
|
|
28
21
|
const {
|
|
29
|
-
port = Number(process.env.PORT ?? 3000),
|
|
30
22
|
dbPath = process.env.DB_PATH ?? './data/chat.db',
|
|
31
23
|
basePath = (process.env.BASE_PATH ?? '').replace(/\/$/, ''),
|
|
32
24
|
dev = process.env.NODE_ENV !== 'production',
|
|
33
|
-
tlsCert = process.env.TLS_CERT ?? './certs/dev-cert.pem',
|
|
34
|
-
tlsKey = process.env.TLS_KEY ?? './certs/dev-key.pem',
|
|
35
25
|
} = config
|
|
36
26
|
|
|
37
27
|
const p = path => `${basePath}${path}`
|
|
@@ -50,7 +40,6 @@ export async function start(config = {}) {
|
|
|
50
40
|
})
|
|
51
41
|
chat.messageService.setUploadService(uploadService)
|
|
52
42
|
|
|
53
|
-
// Wire service context so page handlers (pages/**/*.js) can access services
|
|
54
43
|
initContext({
|
|
55
44
|
auth: chat.auth,
|
|
56
45
|
hubService: chat.hubService,
|
|
@@ -67,26 +56,133 @@ export async function start(config = {}) {
|
|
|
67
56
|
logger,
|
|
68
57
|
})
|
|
69
58
|
|
|
59
|
+
return { chat, db, logger, p, basePath, dev }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Return chat routes and websocket handler for embedding in an existing server.
|
|
64
|
+
*
|
|
65
|
+
* Use this when you want to run the chat app on the same port as another app.
|
|
66
|
+
* Pass the returned routes and websocket to your own createServer() call, then
|
|
67
|
+
* call onServerReady() with the server instance once it is created.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* import { createServer } from '@devchitchat/index97'
|
|
71
|
+
* import { setup as setupChat } from '@devchitchat/chat'
|
|
72
|
+
*
|
|
73
|
+
* const chat = await setupChat({ basePath: '/chat', dbPath: './data/chat.db' })
|
|
74
|
+
*
|
|
75
|
+
* const server = await createServer({
|
|
76
|
+
* pagesDir: new URL('./pages', import.meta.url).pathname,
|
|
77
|
+
* port: 3000,
|
|
78
|
+
* routes: chat.routes,
|
|
79
|
+
* websocket: chat.websocket,
|
|
80
|
+
* })
|
|
81
|
+
*
|
|
82
|
+
* chat.onServerReady(server)
|
|
83
|
+
*
|
|
84
|
+
* @param {object} config
|
|
85
|
+
* @param {string} [config.basePath] - URL prefix for all chat routes (e.g. '/chat')
|
|
86
|
+
* @param {string} [config.dbPath] - Path to the SQLite database file
|
|
87
|
+
* @param {boolean} [config.dev] - Enable dev mode
|
|
88
|
+
* @returns {Promise<{ routes: object, websocket: object, onServerReady: Function }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function setup(config = {}) {
|
|
91
|
+
const { chat, logger, p, basePath, dev } = await _setupServices(config)
|
|
92
|
+
|
|
93
|
+
// Discover and prefix all file-based chat page routes
|
|
94
|
+
const routes = await createRoutes({
|
|
95
|
+
pagesDir: CHAT_PAGES_DIR,
|
|
96
|
+
prefix: basePath,
|
|
97
|
+
dev,
|
|
98
|
+
csp: CHAT_CSP,
|
|
99
|
+
permissionsPolicy: CHAT_PERMISSIONS_POLICY,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
// Explicit protocol routes
|
|
103
|
+
routes[p('/ws')] = (req, server) => {
|
|
104
|
+
const session = sessionFromRequest(req)
|
|
105
|
+
if (server.upgrade(req, {
|
|
106
|
+
data: session ? { userId: session.user.user_id, sessionId: session.session_id, displayName: session.user.display_name } : {}
|
|
107
|
+
})) return
|
|
108
|
+
return new Response('WebSocket upgrade required', { status: 426 })
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
routes[p('/vendor/rdbl.js')] = () =>
|
|
112
|
+
new Response(Bun.file(new URL(import.meta.resolve('@devchitchat/rdbljs/src/rdbl.js'))), {
|
|
113
|
+
headers: { 'Content-Type': 'text/javascript' },
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
routes[p('/sw.js')] = () => new Response(
|
|
117
|
+
Bun.file(CHAT_PAGES_DIR + '/public/sw.js'),
|
|
118
|
+
{ headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Service-Worker-Allowed': `${basePath}/`, 'Cache-Control': 'no-cache, no-store' } }
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
routes[p('/manifest.json')] = () => new Response(
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
name: 'devchitchat', short_name: 'devchitchat',
|
|
124
|
+
start_url: `${basePath}/`, scope: `${basePath}/`,
|
|
125
|
+
display: 'standalone', background_color: '#1a1b1e', theme_color: '#141517',
|
|
126
|
+
icons: [{ src: `${basePath}/icon.png`, sizes: '300x300', type: 'image/png', purpose: 'any maskable' }]
|
|
127
|
+
}),
|
|
128
|
+
{ headers: { 'Content-Type': 'application/manifest+json', 'Cache-Control': 'no-cache' } }
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// Static files from pages/public/ — register each as an explicit route so the
|
|
132
|
+
// host server's fetch handler (which only knows its own public dir) can serve them
|
|
133
|
+
const glob = new Bun.Glob('**/*')
|
|
134
|
+
for await (const file of glob.scan({ cwd: CHAT_PUBLIC_DIR, onlyFiles: true })) {
|
|
135
|
+
if (file === '.DS_Store') continue
|
|
136
|
+
const pattern = basePath + '/' + file
|
|
137
|
+
const filePath = CHAT_PUBLIC_DIR + '/' + file
|
|
138
|
+
routes[pattern] = () => new Response(Bun.file(filePath))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
routes,
|
|
143
|
+
websocket: chat.websocket,
|
|
144
|
+
onServerReady(server) {
|
|
145
|
+
chat.attachServer(server)
|
|
146
|
+
logger.info('server.ready', { basePath })
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Start the devchitchat server as a standalone process.
|
|
153
|
+
*
|
|
154
|
+
* @param {object} config
|
|
155
|
+
* @param {number} [config.port] - Port to listen on. Default: process.env.PORT ?? 3000
|
|
156
|
+
* @param {string} [config.dbPath] - Path to the SQLite database file. Default: process.env.DB_PATH ?? './data/chat.db'
|
|
157
|
+
* @param {string} [config.basePath] - URL subpath to mount the app at, e.g. '/chat'. Default: process.env.BASE_PATH ?? ''
|
|
158
|
+
* @param {boolean} [config.dev] - Enable dev mode. Default: process.env.NODE_ENV !== 'production'
|
|
159
|
+
* @param {string} [config.tlsCert] - Path to TLS certificate. Default: process.env.TLS_CERT ?? './certs/dev-cert.pem'
|
|
160
|
+
* @param {string} [config.tlsKey] - Path to TLS private key. Default: process.env.TLS_KEY ?? './certs/dev-key.pem'
|
|
161
|
+
* @returns {Promise<import('bun').Server>}
|
|
162
|
+
*/
|
|
163
|
+
export async function start(config = {}) {
|
|
164
|
+
const {
|
|
165
|
+
port = Number(process.env.PORT ?? 3000),
|
|
166
|
+
tlsCert = process.env.TLS_CERT ?? './certs/dev-cert.pem',
|
|
167
|
+
tlsKey = process.env.TLS_KEY ?? './certs/dev-key.pem',
|
|
168
|
+
} = config
|
|
169
|
+
|
|
170
|
+
const { chat, db, logger, p, basePath, dev } = await _setupServices(config)
|
|
171
|
+
|
|
70
172
|
async function getTlsIfAvailable() {
|
|
71
173
|
const cert = Bun.file(tlsCert)
|
|
72
|
-
if (await cert.exists()) {
|
|
73
|
-
return { cert, key: Bun.file(tlsKey) }
|
|
74
|
-
}
|
|
174
|
+
if (await cert.exists()) return { cert, key: Bun.file(tlsKey) }
|
|
75
175
|
return null
|
|
76
176
|
}
|
|
77
177
|
|
|
78
178
|
const server = await createServer({
|
|
79
|
-
pagesDir:
|
|
179
|
+
pagesDir: CHAT_PAGES_DIR,
|
|
80
180
|
port,
|
|
81
181
|
dev,
|
|
82
182
|
basePath,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
// CSP: allow WebSocket connections to self
|
|
86
|
-
csp: "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:",
|
|
87
|
-
// So the SSE doesn't timeout.
|
|
183
|
+
permissionsPolicy: CHAT_PERMISSIONS_POLICY,
|
|
184
|
+
csp: CHAT_CSP,
|
|
88
185
|
idleTimeout: 0,
|
|
89
|
-
// WebSocket upgrade route — authenticate via session cookie before the first message
|
|
90
186
|
routes: {
|
|
91
187
|
[p('/ws')]: (req, server) => {
|
|
92
188
|
const session = sessionFromRequest(req)
|
|
@@ -95,35 +191,24 @@ export async function start(config = {}) {
|
|
|
95
191
|
})) return
|
|
96
192
|
return new Response('WebSocket upgrade required', { status: 426 })
|
|
97
193
|
},
|
|
98
|
-
[p('/vendor/rdbl.js')]: () =>
|
|
99
|
-
|
|
194
|
+
[p('/vendor/rdbl.js')]: () =>
|
|
195
|
+
new Response(Bun.file(new URL(import.meta.resolve('@devchitchat/rdbljs/src/rdbl.js'))), {
|
|
100
196
|
headers: { 'Content-Type': 'text/javascript' },
|
|
101
|
-
})
|
|
102
|
-
},
|
|
103
|
-
// Service worker at basePath scope so it can receive push events for all app pages
|
|
197
|
+
}),
|
|
104
198
|
[p('/sw.js')]: () => new Response(
|
|
105
|
-
Bun.file(
|
|
199
|
+
Bun.file(CHAT_PAGES_DIR + '/public/sw.js'),
|
|
106
200
|
{ headers: { 'Content-Type': 'application/javascript; charset=utf-8', 'Service-Worker-Allowed': `${basePath}/`, 'Cache-Control': 'no-cache, no-store' } }
|
|
107
201
|
),
|
|
108
|
-
// Dynamic PWA manifest — start_url and icon.src must reflect basePath
|
|
109
202
|
[p('/manifest.json')]: () => new Response(
|
|
110
203
|
JSON.stringify({
|
|
111
|
-
name: 'devchitchat',
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
display: 'standalone',
|
|
116
|
-
background_color: '#1a1b1e',
|
|
117
|
-
theme_color: '#141517',
|
|
118
|
-
icons: [
|
|
119
|
-
{ src: `${basePath}/icon.png`, sizes: '300x300', type: 'image/png', purpose: 'any maskable' }
|
|
120
|
-
]
|
|
204
|
+
name: 'devchitchat', short_name: 'devchitchat',
|
|
205
|
+
start_url: `${basePath}/`, scope: `${basePath}/`,
|
|
206
|
+
display: 'standalone', background_color: '#1a1b1e', theme_color: '#141517',
|
|
207
|
+
icons: [{ src: `${basePath}/icon.png`, sizes: '300x300', type: 'image/png', purpose: 'any maskable' }]
|
|
121
208
|
}),
|
|
122
209
|
{ headers: { 'Content-Type': 'application/manifest+json', 'Cache-Control': 'no-cache' } }
|
|
123
210
|
),
|
|
124
211
|
},
|
|
125
|
-
|
|
126
|
-
// Bun native WebSocket handler (new index97 passthrough)
|
|
127
212
|
websocket: chat.websocket,
|
|
128
213
|
tls: await getTlsIfAvailable(),
|
|
129
214
|
onShutdown: (server) => {
|
|
@@ -134,11 +219,8 @@ export async function start(config = {}) {
|
|
|
134
219
|
},
|
|
135
220
|
})
|
|
136
221
|
|
|
137
|
-
// Give ChatServer a reference to the Bun server so it can publish to topics
|
|
138
222
|
chat.attachServer(server)
|
|
139
|
-
|
|
140
223
|
logger.info('server.ready', { port: server.port, dev, basePath })
|
|
141
|
-
|
|
142
224
|
return server
|
|
143
225
|
}
|
|
144
226
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devchitchat/chat",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "A small chat app. p2p video and screenshare.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"dev": "bun --watch index.js",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"styles.css"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@devchitchat/index97": "^
|
|
36
|
+
"@devchitchat/index97": "^3.0.0",
|
|
37
37
|
"@devchitchat/rdbljs": "^2.0.1"
|
|
38
38
|
},
|
|
39
39
|
"main": "index.js",
|
|
@@ -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
|
|
|
@@ -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>
|
|
@@ -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;
|
|
@@ -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`,
|