@soffits/dsh-deltachat-bot 0.0.0-stage → 0.1.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/src/app.js ADDED
@@ -0,0 +1,628 @@
1
+ /** Host runtime: the Delta Chat profile, incoming messages and the wiring of the parts. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { mkdir, writeFile } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import { parseCommand, runCommand } from './commands.js'
6
+ import { accountsDir, dataDir, name, POST_SOURCE_KIND, REQUEST_PREFIX, SERVICE_NAME, STEER_REACTION } from './constants.js'
7
+ import * as realCore from './core.js'
8
+ import { allowedEntry, normalizeFingerprint, shortFingerprint } from './identity.js'
9
+ import { composePrompt } from './inbound.js'
10
+ import { createModelCatalog } from './models.js'
11
+ import { createOperations, primaryAddress } from './operations.js'
12
+ import { createOutbound } from './outbound.js'
13
+ import { createPrompts } from './prompts.js'
14
+ import { admitPrompt, createSession, recordMessage, resolveAgent, unarchive } from './sessions.js'
15
+ import { createSpool } from './spool.js'
16
+ import { openState } from './state.js'
17
+ import { clip } from './text.js'
18
+ import { createTurns } from './turns.js'
19
+ import { createPeopleFile, showsPlaceholder, syncTitle } from './titles.js'
20
+
21
+ /**
22
+ * Read a Config field that may be a plain value or a volatile reference.
23
+ * @param value - Config field.
24
+ * @param fallback - value used when the field is absent.
25
+ */
26
+ export function readLive(value, fallback) {
27
+ if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
28
+ try {
29
+ const current = value.get()
30
+ return current === undefined ? fallback : current
31
+ } catch {
32
+ return fallback
33
+ }
34
+ }
35
+ return value === undefined || value === null ? fallback : value
36
+ }
37
+
38
+ /** @returns a short message for any thrown value. */
39
+ export function messageOf(error) {
40
+ return error instanceof Error ? error.message : String(error?.message ?? error)
41
+ }
42
+
43
+ /** Delta Chat's id for the bot's own contact. */
44
+ const SELF_CONTACT = 1
45
+ /** Shortest and longest pause before the profile is started again after a failure. */
46
+ const RETRY_MIN_MS = 5_000
47
+ const RETRY_MAX_MS = 5 * 60_000
48
+
49
+ /**
50
+ * Mount the bot.
51
+ * @param ctx - Host plugin context.
52
+ * @param config - live plugin configuration.
53
+ */
54
+ export function apply(ctx, config) {
55
+ createBridge(ctx, config, realCore)
56
+ }
57
+
58
+ /**
59
+ * The bot with its Delta Chat side passed in, so tests can drive it with a
60
+ * fake core.
61
+ * @param ctx - Host plugin context.
62
+ * @param config - live plugin configuration.
63
+ * @param core - `{ startServer, ensureAccount, createFingerprints, startWatchdog, writeInvite }`.
64
+ */
65
+ export function createBridge(ctx, config, core) {
66
+ const { createFingerprints, ensureAccount, startServer, startWatchdog, writeInvite } = core
67
+ // The Host keeps no plugin logger output, so lines also go to the process's
68
+ // own output, which is where an operator reads DSH's log.
69
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('deltachat') : ctx.logger
70
+ const info = (message) => {
71
+ logger?.info?.(message)
72
+ if (process.env.NODE_ENV !== 'test') console.log(`[dsh-deltachat-bot] ${message}`)
73
+ }
74
+ const warn = (message) => {
75
+ logger?.warn?.(message)
76
+ if (process.env.NODE_ENV !== 'test') console.error(`[dsh-deltachat-bot] ${message}`)
77
+ }
78
+ const setting = (field, fallback) => readLive(config[field], fallback)
79
+ const lifetime = new AbortController()
80
+
81
+ /** Running profile: `{ dc, accountId, close, stopWatchdog, fingerprints, startedAt }`. */
82
+ let runtime
83
+ let starting
84
+ let retryTimer
85
+ let retryDelay = RETRY_MIN_MS
86
+ let disposed = false
87
+ let store
88
+ const storeReady = openState().then((opened) => { store = opened })
89
+ /** Live per-chat state by chat id. */
90
+ const chats = new Map()
91
+ /** Message ids being handled, so a message seen twice at start is handled once. */
92
+ const handling = new Set()
93
+ const modelCatalog = createModelCatalog(ctx)
94
+ let status = { state: 'starting', updatedAt: new Date().toISOString() }
95
+
96
+ async function publishStatus(patch) {
97
+ status = { ...status, ...patch, updatedAt: new Date().toISOString() }
98
+ try {
99
+ await mkdir(dataDir(), { recursive: true, mode: 0o700 })
100
+ await writeFile(join(dataDir(), 'status.json'), JSON.stringify(status, null, 2) + '\n', { mode: 0o600 })
101
+ } catch {
102
+ // Diagnostics never take the runtime down.
103
+ }
104
+ }
105
+
106
+ function reportError(scope, error) {
107
+ warn(`${scope}: ${messageOf(error)}`)
108
+ void publishStatus({ lastError: { scope, detail: messageOf(error), at: new Date().toISOString() } })
109
+ }
110
+
111
+ function fireAndForget(scope, task) {
112
+ Promise.resolve().then(task).catch((error) => reportError(scope, error))
113
+ }
114
+
115
+ /** @returns the live state of one chat. */
116
+ function chatState(chatId) {
117
+ let chat = chats.get(chatId)
118
+ if (chat === undefined) {
119
+ chat = { chatId, out: Promise.resolve(), lastInbound: undefined, admission: undefined, stopping: false }
120
+ chats.set(chatId, chat)
121
+ }
122
+ return chat
123
+ }
124
+
125
+ function requireRuntime() {
126
+ if (runtime === undefined) throw new Error('The Delta Chat bot is not running.')
127
+ return runtime
128
+ }
129
+
130
+ const outbound = createOutbound({ requireRuntime, chatState, reportError, messageOf })
131
+ const { notice, sendText } = outbound
132
+ const turns = createTurns({
133
+ ctx,
134
+ getStore: () => store,
135
+ isRunning: () => runtime !== undefined,
136
+ setting,
137
+ outbound,
138
+ chatState,
139
+ publishStatus,
140
+ fireAndForget,
141
+ messageOf,
142
+ })
143
+ const { currentTurn } = turns
144
+ const operations = createOperations({
145
+ ctx,
146
+ getStore: () => store,
147
+ storeReady,
148
+ requireRuntime,
149
+ getRuntime: () => runtime,
150
+ getStatus: () => status,
151
+ setting,
152
+ outbound,
153
+ currentTurn,
154
+ writeInvite,
155
+ info,
156
+ reportError,
157
+ messageOf,
158
+ })
159
+ const { sendToChat, listChats, invite, joinInvite, allowUser, describe } = operations
160
+
161
+ // ---------------------------------------------------------------- sessions
162
+
163
+ /**
164
+ * The live agent of a chat's session, creating the session when the chat
165
+ * has none.
166
+ * @param chatId - chat id.
167
+ * @param options - `{ claim }`: end the blank state of a new session at once.
168
+ */
169
+ async function ensureAgent(chatId, { claim = false } = {}) {
170
+ await storeReady
171
+ const record = store.chat(chatId)
172
+ if (typeof record?.sessionId === 'string') {
173
+ try {
174
+ const agent = await resolveAgent(ctx, record.sessionId)
175
+ prompts.attach(agent)
176
+ return agent
177
+ } catch (error) {
178
+ warn(`session ${record.sessionId} of chat ${chatId} could not be resumed, starting a new one: ${messageOf(error)}`)
179
+ await store.release(chatId)
180
+ }
181
+ }
182
+ const cwd = record?.cwd ?? setting('cwd', process.cwd())
183
+ const preset = String(setting('agentPreset', '')).trim() || undefined
184
+ const model = store.data.picks[String(chatId)]
185
+ const agent = await createSession(ctx, { cwd, preset, model, chatId, claim, warn })
186
+ const sessionId = agent.session?.id ?? agent.id
187
+ await store.bind(chatId, { sessionId, cwd })
188
+ await applyTitle(sessionId)
189
+ prompts.attach(agent)
190
+ info(`chat ${chatId} now uses session ${sessionId}`)
191
+ return agent
192
+ }
193
+
194
+ // ------------------------------------------------------------------ titles
195
+
196
+ const peopleFile = createPeopleFile()
197
+ /** Sessions whose first conversation title is being generated. */
198
+ const titleRequests = new Set()
199
+
200
+ /**
201
+ * Remember who a chat is with, for its session's title: a group's name, or
202
+ * the person's name from `peopleFile`, the allowlist or their own profile.
203
+ * A change moves the title of the chat's session.
204
+ */
205
+ async function noteChatIdentity(chatId, chatInfo, sender) {
206
+ const group = chatInfo.chatType !== 'Single'
207
+ const person = group
208
+ ? chatInfo.name
209
+ : peopleFile.nameOf(setting('peopleFile', ''), sender.fingerprint) || sender.name || sender.displayName || chatInfo.name
210
+ if (await store.describeChat(chatId, { person, chatName: chatInfo.name })) {
211
+ const sessionId = store.chat(chatId)?.sessionId
212
+ if (typeof sessionId === 'string') await applyTitle(sessionId)
213
+ }
214
+ }
215
+
216
+ /** Bring a bound session's title to `DeltaChat-<person>-<task>`. */
217
+ async function applyTitle(sessionId) {
218
+ const chatId = store?.chatOfSession(sessionId)
219
+ const record = chatId === undefined ? undefined : store.chat(chatId)
220
+ const session = ctx.get('agents')?.get(sessionId)?.session
221
+ if (record === undefined || session === undefined) return
222
+ try {
223
+ const person = record.person ?? record.chatName ?? String(chatId)
224
+ const placeholder = syncTitle({
225
+ service: ctx.get('sessionTitle'),
226
+ session,
227
+ person,
228
+ earlier: record.titlePersons ?? [],
229
+ placeholder: record.chatName ?? String(chatId),
230
+ previous: record.placeholderTitle,
231
+ warn,
232
+ })
233
+ const titlePersons = [person, ...(record.titlePersons ?? []).filter((name) => name !== person)].slice(0, 5)
234
+ await store.describeChat(chatId, { placeholderTitle: placeholder, titlePersons })
235
+ } catch (error) {
236
+ warn(`title of ${sessionId} not set: ${messageOf(error)}`)
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Ask DSH for a session's first conversation title once it has a prompt and
242
+ * a model route, while it still shows the bot's placeholder: the placeholder
243
+ * is a user title, which stops automatic titling.
244
+ */
245
+ function startTitle(sessionId) {
246
+ const chatId = store?.chatOfSession(sessionId)
247
+ const service = ctx.get('sessionTitle')
248
+ const session = ctx.get('agents')?.get(sessionId)?.session
249
+ if (chatId === undefined || session === undefined || typeof service?.refresh !== 'function' || titleRequests.has(sessionId)) return
250
+ if (!showsPlaceholder(service, session, store.chat(chatId)?.placeholderTitle)) return
251
+ const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : []
252
+ if (!events.some((event) => event?.type === 'user/message' && event.data?.source?.kind === 'user')) return
253
+ titleRequests.add(sessionId)
254
+ Promise.resolve()
255
+ .then(() => service.refresh(session))
256
+ .catch((error) => warn(`title of ${sessionId} not generated: ${messageOf(error)}`))
257
+ .finally(() => titleRequests.delete(sessionId))
258
+ }
259
+
260
+ const prompts = createPrompts({
261
+ chatOfAgent: (agent) => {
262
+ const sessionId = agent.session?.id ?? agent.id
263
+ const chatId = store?.chatOfSession(sessionId)
264
+ if (chatId === undefined) return undefined
265
+ // Only turns started from Delta Chat ask there; others ask where they started.
266
+ return currentTurn(sessionId)?.ours === true ? chatId : undefined
267
+ },
268
+ send: (chatId, text) => notice(chatId, text),
269
+ warn,
270
+ })
271
+
272
+ // ----------------------------------------------------------------- inbound
273
+
274
+ /**
275
+ * Handle one incoming message: check the sender, run a command or hand the
276
+ * message to the chat's session, then mark it seen.
277
+ * @param msgId - Delta Chat message id.
278
+ */
279
+ async function onIncoming(msgId) {
280
+ if (handling.has(msgId)) return
281
+ handling.add(msgId)
282
+ try {
283
+ await storeReady
284
+ const { dc, accountId, fingerprints } = requireRuntime()
285
+ let msg = await dc.rpc.getMessage(accountId, msgId)
286
+ if (msg.isInfo || msg.fromId === SELF_CONTACT || msg.fromId <= 9) return
287
+ if (msg.downloadState !== 'Done') {
288
+ await dc.rpc.downloadFullMessage(accountId, msgId)
289
+ for (let attempt = 0; attempt < 60 && msg.downloadState !== 'Done'; attempt += 1) {
290
+ await new Promise((resolve) => setTimeout(resolve, 1000))
291
+ msg = await dc.rpc.getMessage(accountId, msgId)
292
+ }
293
+ }
294
+ const chatId = msg.chatId
295
+ const fingerprint = await fingerprints.get(msg.fromId)
296
+ const allowed = allowedEntry(setting('allowedUsers', []), fingerprint)
297
+ if (allowed === undefined) {
298
+ await rejectSender(msg, fingerprint)
299
+ return
300
+ }
301
+ const chatInfo = await dc.rpc.getBasicChatInfo(accountId, chatId)
302
+ if (chatInfo.isContactRequest) await dc.rpc.acceptChat(accountId, chatId)
303
+ const sender = { name: allowed.name, fingerprint, displayName: msg.sender?.displayName ?? '' }
304
+ await noteChatIdentity(chatId, chatInfo, sender)
305
+ await publishStatus({ lastMessageAt: Date.now(), lastChatId: chatId })
306
+ const chat = chatState(chatId)
307
+ chat.lastInbound = msgId
308
+ const text = String(msg.text ?? '').trim()
309
+ if (!msg.file && prompts.consumeReply(chatId, text)) {
310
+ await dc.rpc.markseenMsgs(accountId, [msgId])
311
+ return
312
+ }
313
+ const command = msg.file ? undefined : parseCommand(text)
314
+ if (command !== undefined) {
315
+ try {
316
+ await runCommand(bridge, { chatId, chatInfo, sender, msgId }, command)
317
+ } catch (error) {
318
+ await notice(chatId, `❌ ${messageOf(error)}`)
319
+ }
320
+ await dc.rpc.markseenMsgs(accountId, [msgId])
321
+ return
322
+ }
323
+ if (!text && !msg.file) return
324
+ try {
325
+ await dispatchPrompt(chatId, msg, sender)
326
+ } catch (error) {
327
+ // Left unseen, so the next start hands it over again.
328
+ await notice(chatId, `❌ Your message did not reach the session: ${messageOf(error)}`)
329
+ throw error
330
+ }
331
+ await dc.rpc.markseenMsgs(accountId, [msgId])
332
+ } catch (error) {
333
+ reportError(`message ${msgId}`, error)
334
+ } finally {
335
+ handling.delete(msgId)
336
+ }
337
+ }
338
+
339
+ /** Ignore a sender who is not allowed, and remember them for the pending list. */
340
+ async function rejectSender(msg, fingerprint) {
341
+ const { dc, accountId } = requireRuntime()
342
+ const entry = {
343
+ fingerprint: fingerprint || `contact-${msg.fromId}`,
344
+ displayName: msg.sender?.displayName ?? '',
345
+ address: msg.sender?.address ?? '',
346
+ chatId: msg.chatId,
347
+ }
348
+ const first = await store.notePending(entry)
349
+ if (first) info(`ignored a message from ${entry.displayName || 'an unknown contact'} (${entry.fingerprint}); add the fingerprint to allowedUsers to let them in`)
350
+ // Noticed, not seen: no read receipt goes back to a stranger.
351
+ await dc.rpc.marknoticedChat(accountId, msg.chatId).catch(() => {})
352
+ }
353
+
354
+ /**
355
+ * Hand a message to the chat's session: into the running turn (`steer`) or
356
+ * after it (`queue`), with its sender line, quote and file.
357
+ */
358
+ async function dispatchPrompt(chatId, msg, sender) {
359
+ const { dc, accountId } = requireRuntime()
360
+ const chat = chatState(chatId)
361
+ // Messages reach the session in the order they arrived.
362
+ while (chat.admission !== undefined) await chat.admission
363
+ let release
364
+ chat.admission = new Promise((resolve) => { release = resolve })
365
+ try {
366
+ let quotedOwn = false
367
+ let sentRecord
368
+ if (msg.quote?.kind === 'WithMessage') {
369
+ const quoted = await dc.rpc.getMessage(accountId, msg.quote.messageId).catch(() => undefined)
370
+ quotedOwn = quoted?.fromId === SELF_CONTACT
371
+ if (quotedOwn) sentRecord = store.sentRecord(msg.quote.messageId)
372
+ }
373
+ const prompt = await composePrompt(msg, {
374
+ sender,
375
+ chatId,
376
+ quotedOwn,
377
+ sentRecord,
378
+ attachments: ctx.get('attachments'),
379
+ voice: {
380
+ mode: setting('transcriber', 'dsh'),
381
+ language: setting('transcribeLanguage', ''),
382
+ command: setting('transcribeCommand', []),
383
+ ffmpeg: setting('ffmpegPath', 'ffmpeg'),
384
+ speech: ctx.get('speech'),
385
+ },
386
+ signal: lifetime.signal,
387
+ warn,
388
+ })
389
+ const agent = await ensureAgent(chatId)
390
+ const sessionId = agent.session?.id ?? agent.id
391
+ if (await unarchive(ctx, sessionId)) info(`unarchived session ${sessionId} for a message from chat ${chatId}`)
392
+ const running = agent.status !== undefined && agent.status !== 'idle'
393
+ const mode = running && setting('busyMessages', 'steer') !== 'queue' ? 'steer' : 'queue'
394
+ const requestId = `${REQUEST_PREFIX}${chatId}:${msg.id}`
395
+ await admitPrompt(ctx, agent, { requestId, text: prompt.text, attachments: prompt.attachments, mode }, lifetime.signal)
396
+ if (running) {
397
+ await dc.rpc.sendReaction(accountId, msg.id, [mode === 'steer' ? STEER_REACTION : '⏳']).catch(() => {})
398
+ }
399
+ } finally {
400
+ chat.admission = undefined
401
+ release()
402
+ }
403
+ }
404
+
405
+ /** Record a reaction to one of the bot's messages in the chat's session. */
406
+ async function onReaction(event) {
407
+ await storeReady
408
+ const { dc, accountId, fingerprints } = requireRuntime()
409
+ const allowed = allowedEntry(setting('allowedUsers', []), await fingerprints.get(event.contactId))
410
+ const sessionId = store.chat(event.chatId)?.sessionId
411
+ if (allowed === undefined || typeof sessionId !== 'string') return
412
+ const agent = ctx.get('agents')?.get(sessionId)
413
+ if (agent === undefined) return
414
+ const target = await dc.rpc.getMessage(accountId, event.msgId).catch(() => undefined)
415
+ if (target === undefined) return
416
+ const who = allowed.name || 'Someone'
417
+ const what = event.reaction ? `reacted ${event.reaction}` : 'removed their reaction'
418
+ const owner = target.fromId === SELF_CONTACT ? 'your message' : 'the message'
419
+ const text = `[Delta Chat: ${who} ${what} to ${owner} ${target.id}:]\n${clip(target.text ?? target.fileName ?? '', 500)}`
420
+ recordMessage(agent, {
421
+ id: randomUUID(),
422
+ role: 'user',
423
+ content: [{ type: 'text', text }],
424
+ source: { kind: POST_SOURCE_KIND, form: 'notice', summary: clip(`${who} ${what}`, 120) },
425
+ })
426
+ }
427
+
428
+ // ---------------------------------------------------------- send service
429
+
430
+ const service = {
431
+ send: (request = {}) => sendToChat(request),
432
+ chats: () => listChats(),
433
+ invite: () => invite(),
434
+ }
435
+ if (typeof ctx.provide === 'function') ctx.provide(SERVICE_NAME, service)
436
+
437
+ // -------------------------------------------------------------- lifecycle
438
+
439
+ async function startRuntime() {
440
+ if (disposed || runtime !== undefined) return
441
+ if (setting('enabled', true) === false) {
442
+ await publishStatus({ state: 'disabled', detail: '' })
443
+ return
444
+ }
445
+ await publishStatus({ state: 'starting', detail: '' })
446
+ let server
447
+ try {
448
+ server = await startServer({ dir: accountsDir(), log: (line) => warn(line) })
449
+ const { dc } = server
450
+ // A server that quits during setup leaves its requests unanswered.
451
+ const quit = server.exited.then((error) => { throw error })
452
+ quit.catch(() => {})
453
+ const setup = (task) => Promise.race([task, quit])
454
+ dc.on('Error', (_account, event) => warn(`core error: ${event.msg ?? ''}`))
455
+ dc.on('ErrorSelfNotInGroup', (_account, event) => warn(`core: ${event.msg ?? 'not in group'}`))
456
+ const accountId = await setup(ensureAccount(dc, {
457
+ relays: setting('relays', []),
458
+ displayName: setting('displayName', 'DSH'),
459
+ avatar: setting('avatar', ''),
460
+ log: (line) => info(line),
461
+ }))
462
+ dc.on('IncomingMsg', (account, event) => {
463
+ if (account === accountId) void onIncoming(event.msgId)
464
+ })
465
+ dc.on('IncomingReaction', (account, event) => {
466
+ if (account === accountId) fireAndForget('reaction', () => onReaction(event))
467
+ })
468
+ dc.on('SecurejoinInviterProgress', (account, event) => {
469
+ if (account === accountId && event.progress === 1000) fireAndForget('securejoin', () => noteJoined(event.contactId))
470
+ })
471
+ await setup(dc.rpc.startIo(accountId))
472
+ if (disposed) {
473
+ server.close()
474
+ return
475
+ }
476
+ const current = { dc, accountId, close: server.close, startedAt: Date.now(), fingerprints: createFingerprints(dc, accountId), stopWatchdog: startWatchdog(dc, accountId, warn) }
477
+ runtime = current
478
+ void server.exited.then((error) => {
479
+ if (runtime !== current) return
480
+ reportError('core', error)
481
+ stopRuntime()
482
+ scheduleRetry()
483
+ })
484
+ retryDelay = RETRY_MIN_MS
485
+ const { link } = await setup(invite())
486
+ const primary = primaryAddress(link) ?? ''
487
+ info(`running as ${primary}; invite link written to ${join(dataDir(), 'invite.txt')}`)
488
+ await publishStatus({ state: 'running', detail: '', primary, invite: link })
489
+ spool.start().catch((error) => reportError('spool', error))
490
+ // Messages that arrived while the bot was down.
491
+ for (const msgId of (await setup(dc.rpc.getFreshMsgs(accountId))).sort((left, right) => left - right)) void onIncoming(msgId)
492
+ } catch (error) {
493
+ reportError('start', error)
494
+ await publishStatus({ state: 'error', detail: messageOf(error) })
495
+ if (runtime === undefined) server?.close()
496
+ stopRuntime()
497
+ scheduleRetry()
498
+ }
499
+ }
500
+
501
+ /** Log contacts that complete the bot's invite, with their fingerprint. */
502
+ async function noteJoined(contactId) {
503
+ const { dc, accountId, fingerprints } = requireRuntime()
504
+ const fingerprint = await fingerprints.get(contactId)
505
+ const contact = await dc.rpc.getContact(accountId, contactId).catch(() => undefined)
506
+ const allowed = allowedEntry(setting('allowedUsers', []), fingerprint)
507
+ if (allowed !== undefined) {
508
+ info(`${allowed.name || fingerprint} joined through the invite`)
509
+ return
510
+ }
511
+ await store.notePending({ fingerprint, displayName: contact?.displayName ?? '', address: contact?.address ?? '', chatId: null })
512
+ info(`${contact?.displayName || 'a contact'} (${shortFingerprint(fingerprint)}) joined through the invite but is not in allowedUsers`)
513
+ }
514
+
515
+ function stopRuntime() {
516
+ const current = runtime
517
+ runtime = undefined
518
+ spool.stop()
519
+ if (current === undefined) return
520
+ current.stopWatchdog?.()
521
+ current.close()
522
+ void publishStatus({ state: disposed ? 'stopped' : 'restarting' })
523
+ }
524
+
525
+ function scheduleRetry() {
526
+ if (disposed || retryTimer !== undefined) return
527
+ const delay = retryDelay
528
+ retryDelay = Math.min(RETRY_MAX_MS, retryDelay * 2)
529
+ info(`starting again in ${Math.round(delay / 1000)} s`)
530
+ retryTimer = setTimeout(() => {
531
+ retryTimer = undefined
532
+ void start()
533
+ }, delay)
534
+ retryTimer.unref?.()
535
+ }
536
+
537
+ function start() {
538
+ starting ??= startRuntime().finally(() => { starting = undefined })
539
+ return starting
540
+ }
541
+
542
+ async function restart(reason) {
543
+ info(`restart: ${reason}`)
544
+ await starting
545
+ stopRuntime()
546
+ await start()
547
+ }
548
+
549
+ /** Apply a settings change: start or stop, and bring the profile up to date. */
550
+ async function applySettings() {
551
+ if (setting('enabled', true) === false) {
552
+ stopRuntime()
553
+ await publishStatus({ state: 'disabled' })
554
+ return
555
+ }
556
+ if (runtime === undefined) {
557
+ await start()
558
+ return
559
+ }
560
+ const { dc } = runtime
561
+ await ensureAccount(dc, { relays: setting('relays', []), displayName: setting('displayName', 'DSH'), avatar: setting('avatar', ''), log: (line) => info(line) })
562
+ await store.clearPending(setting('allowedUsers', []).map((entry) => normalizeFingerprint(entry.fingerprint)).filter(Boolean))
563
+ }
564
+
565
+ // Requests from the `dsh-deltachat` command.
566
+ const spool = createSpool({
567
+ ready: () => runtime !== undefined,
568
+ onError: (error) => reportError('spool', error),
569
+ handle: async (request) => {
570
+ if (request.op === 'send') return sendToChat(request)
571
+ if (request.op === 'invite') return invite()
572
+ if (request.op === 'join') return joinInvite(String(request.link ?? ''))
573
+ if (request.op === 'status') return describe()
574
+ if (request.op === 'chats') return listChats()
575
+ if (request.op === 'allow') return allowUser(request.fingerprint, request.name)
576
+ throw new Error(`unknown request ${request.op}`)
577
+ },
578
+ })
579
+
580
+ /** What commands may use. */
581
+ const bridge = {
582
+ ctx,
583
+ setting,
584
+ store: () => store,
585
+ runtime: requireRuntime,
586
+ chatState,
587
+ currentTurn,
588
+ ensureAgent,
589
+ notice,
590
+ sendText,
591
+ modelCatalog,
592
+ prompts,
593
+ describe,
594
+ invite,
595
+ restart: (reason) => fireAndForget('restart', () => restart(reason)),
596
+ applyTitle,
597
+ info,
598
+ warn,
599
+ signal: lifetime.signal,
600
+ }
601
+
602
+ ctx.on('session/event', (session, event) => {
603
+ try {
604
+ turns.onSessionEvent(session, event)
605
+ } catch (error) {
606
+ reportError('session-event', error)
607
+ }
608
+ // Titles change after the title service folded this event.
609
+ if (store === undefined || store.chatOfSession(session?.id) === undefined) return
610
+ if (event?.type === 'session/title' && event.data?.source?.kind !== 'user') fireAndForget('title', () => applyTitle(session.id))
611
+ else if (event?.type === 'request/header') fireAndForget('title', () => startTitle(session.id))
612
+ }, { global: true })
613
+
614
+ ctx.on('settings/document-updated', (namespace) => {
615
+ if (namespace === name) fireAndForget('settings', () => applySettings())
616
+ })
617
+
618
+ ctx.effect(() => {
619
+ void storeReady.then(() => start()).catch((error) => reportError('start', error))
620
+ return () => {
621
+ disposed = true
622
+ lifetime.abort()
623
+ if (retryTimer !== undefined) clearTimeout(retryTimer)
624
+ prompts.cancelAll()
625
+ stopRuntime()
626
+ }
627
+ }, 'dsh-deltachat-bot: runtime')
628
+ }