@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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * The DSH side of a chat: creating, resuming and prompting sessions through
3
+ * the same Host services the Web UI uses (`sessionController`), with the raw
4
+ * agent registry as the headless fallback.
5
+ */
6
+ import { randomUUID } from 'node:crypto'
7
+ import { basename } from 'node:path'
8
+ import { REQUEST_PREFIX } from './constants.js'
9
+
10
+ /** @returns the Web session controller, when this deployment mounts it. */
11
+ export function controllerOf(ctx) {
12
+ const controller = ctx.get('sessionController')
13
+ return typeof controller?.create === 'function' && typeof controller?.resolveAgent === 'function' ? controller : undefined
14
+ }
15
+
16
+ /**
17
+ * The live agent of a session, resuming it when needed.
18
+ * @param ctx - Host context.
19
+ * @param sessionId - session id.
20
+ * @returns the agent; throws when the session cannot be resumed.
21
+ */
22
+ export async function resolveAgent(ctx, sessionId) {
23
+ const agents = ctx.get('agents')
24
+ const live = agents?.get(sessionId)
25
+ if (live !== undefined) return live
26
+ const controller = controllerOf(ctx)
27
+ if (controller !== undefined) {
28
+ const found = await controller.resolveAgent(sessionId)
29
+ if (found?.error !== undefined) throw found.error
30
+ const agent = found?.agent ?? agents?.get(sessionId)
31
+ if (agent === undefined) throw new Error(`session ${sessionId} has no live agent`)
32
+ return agent
33
+ }
34
+ if (agents === undefined) throw new Error('dsh-agent is not mounted in this profile')
35
+ const resumed = await agents.resume({ resumeSessionId: sessionId })
36
+ if (resumed?.agent === undefined) throw new Error(`session ${sessionId} could not be resumed`)
37
+ return resumed.agent
38
+ }
39
+
40
+ /**
41
+ * Attach a session to the DSH workspace of its directory, creating the
42
+ * workspace when needed, so it shows up in the Web UI sidebar.
43
+ * @param ctx - Host context.
44
+ * @param sessionId - session id.
45
+ * @param cwd - session directory.
46
+ * @param warn - logger.
47
+ */
48
+ async function attachToWorkspace(ctx, sessionId, cwd, warn) {
49
+ const registry = ctx.get('workspaceRegistry')
50
+ if (registry === undefined) return
51
+ try {
52
+ let workspace = registry.list().find((entry) => entry.path === cwd)
53
+ if (workspace === undefined) workspace = await registry.create(cwd)
54
+ await workspace.attachSession(sessionId)
55
+ } catch (error) {
56
+ warn(`workspace attach failed for ${sessionId}: ${error?.message ?? error}`)
57
+ }
58
+ }
59
+
60
+ /**
61
+ * End a new session's blank state with an empty turn that never reaches a
62
+ * model. DSH hands a blank session (one that never started a turn) to the
63
+ * next New conversation in the Web UI; a chat's session must not be taken
64
+ * that way. The marker message is rejected at `agent/pre-step`, the same gate
65
+ * DSH uses for archived sessions, so the turn ends as blocked and the marker
66
+ * is never recorded.
67
+ * @param agent - the new session's agent.
68
+ * @param chatId - Delta Chat chat id, for the marker text.
69
+ */
70
+ async function claimSession(agent, chatId) {
71
+ if (typeof agent?.followup !== 'function' || typeof agent?.ctx?.on !== 'function') return
72
+ const id = randomUUID()
73
+ const dispose = agent.ctx.on('agent/pre-step', (payload, next) => {
74
+ const messages = Array.isArray(payload?.messages) ? payload.messages : []
75
+ if (messages.length > 0 && messages.every((message) => message?.id === id)) return Promise.resolve({ kind: 'reject' })
76
+ return next()
77
+ })
78
+ agent.followup({
79
+ id,
80
+ role: 'user',
81
+ content: [{ type: 'text', text: `[This session belongs to Delta Chat chat ${chatId}]` }],
82
+ source: { kind: 'user', rpcId: `deltachat-claim:${id}` },
83
+ })
84
+ const idle = typeof agent.whenIdle === 'function' ? agent.whenIdle() : Promise.resolve()
85
+ void idle.catch(() => undefined).finally(() => { if (typeof dispose === 'function') dispose() })
86
+ let timer
87
+ await Promise.race([
88
+ idle.catch(() => undefined),
89
+ new Promise((resolveWait) => { timer = setTimeout(resolveWait, 10_000) }),
90
+ ])
91
+ clearTimeout(timer)
92
+ }
93
+
94
+ /**
95
+ * Create a session for a chat, composed the way the Web UI composes one.
96
+ * @param ctx - Host context.
97
+ * @param options - `{ cwd, preset, model, chatId, claim, warn }`; `claim` ends
98
+ * the blank state at once (for sessions that will not get a prompt now).
99
+ * @returns the new session's agent.
100
+ */
101
+ export async function createSession(ctx, { cwd, preset, model, chatId, claim, warn }) {
102
+ const controller = controllerOf(ctx)
103
+ let agent
104
+ if (controller !== undefined) {
105
+ try {
106
+ const created = await controller.create({ cwd, ...(preset ? { agentPreset: preset } : {}) })
107
+ agent = await resolveAgent(ctx, created.sessionId)
108
+ } catch (error) {
109
+ warn(`sessionController create failed, using the agent registry: ${error?.message ?? error}`)
110
+ }
111
+ }
112
+ if (agent === undefined) {
113
+ const agents = ctx.get('agents')
114
+ if (agents === undefined) throw new Error('dsh-agent is not mounted in this profile')
115
+ const meta = { cwd, ...(preset && ctx.get('agentPresets') !== undefined ? { agentPreset: preset } : {}) }
116
+ const presets = ctx.get('agentPresets')
117
+ const setup = preset && presets !== undefined
118
+ ? async (agentCtx) => {
119
+ try {
120
+ await presets.mount(agentCtx, preset)
121
+ } catch (error) {
122
+ warn(`preset ${preset} not mounted: ${error?.message ?? error}`)
123
+ }
124
+ }
125
+ : undefined
126
+ agent = (await agents.create({ sessionId: `session-${randomUUID()}`, meta, ...(setup ? { setup } : {}) })).agent
127
+ }
128
+ const sessionId = agent.session?.id ?? agent.id
129
+ if (model !== undefined) {
130
+ try {
131
+ agent.model.select(model)
132
+ } catch (error) {
133
+ warn(`could not start ${sessionId} on ${model.provider}/${model.model}: ${error?.message ?? error}`)
134
+ }
135
+ }
136
+ if (claim) await claimSession(agent, chatId)
137
+ await attachToWorkspace(ctx, sessionId, cwd, warn)
138
+ return agent
139
+ }
140
+
141
+ /**
142
+ * Bring an archived session back before a chat message starts a turn in it:
143
+ * DSH admits prompts into archived sessions but rejects every model step.
144
+ * @param ctx - Host context.
145
+ * @param sessionId - session id.
146
+ * @returns true when the session was unarchived.
147
+ */
148
+ export async function unarchive(ctx, sessionId) {
149
+ const registry = ctx.get('workspaceRegistry')
150
+ if (registry?.archivedSessionIds?.includes(sessionId) !== true || typeof registry.unarchiveSession !== 'function') return false
151
+ await registry.unarchiveSession(sessionId)
152
+ return true
153
+ }
154
+
155
+ /** @returns whether a pending or recorded message carries this request id. */
156
+ function hasRequest(agent, requestId) {
157
+ const matches = (message) => message?.source?.kind === 'user' && message.source.rpcId === requestId
158
+ const inbox = agent?.inbox
159
+ if (inbox?.nextTurn?.some?.(matches) || inbox?.nextStep?.some?.(matches)) return true
160
+ const events = typeof agent?.session?.snapshotEvents === 'function' ? agent.session.snapshotEvents() : []
161
+ return events.some((event) => event?.type === 'user/message' && matches(event.data))
162
+ }
163
+
164
+ /**
165
+ * Hand one prompt to a session. Text goes through the Session controller's
166
+ * `prompt`, the Web UI's own path; prompts with stored attachments go to the
167
+ * agent's `followup` (queue) or `steer` (into the running turn), the methods
168
+ * that path calls. A request id already admitted is skipped, so a message
169
+ * handled twice (a restart between admission and marking it seen) reaches the
170
+ * agent once.
171
+ * @param ctx - Host context.
172
+ * @param agent - the session's agent.
173
+ * @param prompt - `{ requestId, text, attachments, mode }`; `mode` is `queue` or `steer`.
174
+ * @param signal - request lifetime.
175
+ */
176
+ export async function admitPrompt(ctx, agent, { requestId, text, attachments = [], mode }, signal) {
177
+ if (hasRequest(agent, requestId)) return
178
+ const content = [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments]
179
+ if (content.length === 0) return
180
+ const controller = ctx.get('sessionController')
181
+ const sessionId = agent.session?.id ?? agent.id
182
+ if (attachments.length === 0 && typeof controller?.prompt === 'function') {
183
+ await controller.prompt({ sessionId, requestId, mode, content }, signal)
184
+ return
185
+ }
186
+ const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId } }
187
+ if (mode === 'steer' && typeof agent.steer === 'function') agent.steer(message)
188
+ else if (typeof agent.followup === 'function') agent.followup(message)
189
+ else agent.send(message, mode === 'steer' ? 'next-step' : 'next-turn', true)
190
+ }
191
+
192
+ /**
193
+ * Remove prompts from one chat that the agent has not taken yet.
194
+ * @param agent - the session's agent.
195
+ * @param chatId - Delta Chat chat id.
196
+ * @returns how many were removed.
197
+ */
198
+ export function clearChatInbox(agent, chatId) {
199
+ const inbox = agent?.inbox
200
+ if (typeof inbox?.remove !== 'function') return 0
201
+ const prefix = `${REQUEST_PREFIX}${chatId}:`
202
+ const ours = (message) => message?.source?.kind === 'user' && typeof message.source.rpcId === 'string' && message.source.rpcId.startsWith(prefix)
203
+ const pending = [...(inbox.nextStep ?? []), ...(inbox.nextTurn ?? [])].filter(ours)
204
+ for (const message of pending) inbox.remove(message.id)
205
+ return pending.length
206
+ }
207
+
208
+ /** @returns whether the agent still holds input it has not taken. */
209
+ export function hasPendingInput(agent) {
210
+ const inbox = agent?.inbox
211
+ if (inbox === undefined) return false
212
+ if (typeof inbox.hasPending === 'boolean') return inbox.hasPending
213
+ return (inbox.nextStep?.length ?? 0) > 0 || (inbox.nextTurn?.length ?? 0) > 0
214
+ }
215
+
216
+ /**
217
+ * Whether a session's first surface node is its system message, the only
218
+ * state in which a message may be appended outside a step.
219
+ * @param session - DSH session.
220
+ */
221
+ function hasSystemHead(session) {
222
+ try {
223
+ const nodes = session.surface?.nodes
224
+ if (nodes !== undefined) {
225
+ if (nodes.length === 0) return false
226
+ const event = typeof session.eventAt === 'function'
227
+ ? session.eventAt(nodes[0])
228
+ : session.snapshotEvents?.().find((item) => item.seq === nodes[0])
229
+ return event?.type === 'system/message'
230
+ }
231
+ const events = typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : session.events
232
+ if (!Array.isArray(events)) return false
233
+ const surfaceTypes = new Set(['system/message', 'user/message', 'developer/message', 'assistant/message', 'tool/result'])
234
+ return events.find((event) => surfaceTypes.has(event.type))?.type === 'system/message'
235
+ } catch {
236
+ return false
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Put a message into a session without starting a turn: appended now to an
242
+ * idle session, otherwise queued for its next step. The Web UI shows it and
243
+ * the model reads it at its next step.
244
+ * @param agent - the session's agent.
245
+ * @param message - a user-role message (`{ id, role, content, source }`).
246
+ */
247
+ export function recordMessage(agent, message) {
248
+ if (agent.status === 'idle' && typeof agent.session?.append === 'function' && hasSystemHead(agent.session)) {
249
+ agent.session.append('user/message', message, { surfaceOp: 'append' })
250
+ } else if (typeof agent.inject === 'function') {
251
+ agent.inject(message)
252
+ } else if (typeof agent.send === 'function') {
253
+ agent.send(message, 'next-step', false)
254
+ } else {
255
+ throw new Error('The session cannot record messages.')
256
+ }
257
+ }
258
+
259
+ /**
260
+ * A session's title, or a short fallback.
261
+ * @param ctx - Host context.
262
+ * @param sessionId - session id.
263
+ * @param header - session header when known.
264
+ */
265
+ export async function sessionTitle(ctx, sessionId, header) {
266
+ try {
267
+ const snapshot = await ctx.get('sessionQuery')?.readTitleSnapshot(sessionId)
268
+ const title = snapshot?.title?.title
269
+ if (typeof title === 'string' && title.trim().length > 0) return title.trim()
270
+ } catch {
271
+ // No title yet.
272
+ }
273
+ const base = typeof header?.cwd === 'string' && header.cwd.length > 0 ? basename(header.cwd) : 'Session'
274
+ return `${base} · ${String(sessionId).slice(-6)}`
275
+ }
276
+
277
+ /**
278
+ * The latest assistant texts of a session, newest first.
279
+ * @param ctx - Host context.
280
+ * @param sessionId - session id.
281
+ * @param limit - most texts.
282
+ */
283
+ export async function recentAssistantTexts(ctx, sessionId, limit) {
284
+ const texts = []
285
+ try {
286
+ const surface = await ctx.get('sessionQuery')?.readSurface(sessionId)
287
+ const events = surface?.events ?? []
288
+ for (let index = events.length - 1; index >= 0 && texts.length < limit; index -= 1) {
289
+ const event = events[index]
290
+ if (event?.type !== 'assistant/message') continue
291
+ const message = event.data?.message ?? event.data
292
+ const content = Array.isArray(message?.content) ? message.content : []
293
+ const text = content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n').trim()
294
+ if (text.length > 0) texts.push(text)
295
+ }
296
+ } catch {
297
+ // An empty result is reported by the caller.
298
+ }
299
+ return texts
300
+ }
301
+
302
+ /** @returns the registered workspaces, or an empty list. */
303
+ export function listWorkspaces(ctx) {
304
+ try {
305
+ return ctx.get('workspaceRegistry')?.list() ?? []
306
+ } catch {
307
+ return []
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Recent ordinary sessions of a workspace, newest first.
313
+ * @param ctx - Host context.
314
+ * @param workspace - workspace record.
315
+ * @param limit - most sessions.
316
+ * @param signal - request lifetime.
317
+ */
318
+ export async function sessionsInWorkspace(ctx, workspace, limit, signal) {
319
+ const query = ctx.get('sessionQuery')
320
+ if (query === undefined) return []
321
+ const archived = new Set(ctx.get('workspaceRegistry')?.archivedSessionIds ?? [])
322
+ const records = await query.listSessions(signal)
323
+ return records
324
+ .filter((record) => !archived.has(record.header.id))
325
+ .filter((record) => record.header?.origin !== 'subagent')
326
+ .filter((record) => (workspace.sessionIds ?? []).includes(record.header.id) || record.header.cwd === workspace.path)
327
+ .sort((left, right) => (right.header.createdAt ?? 0) - (left.header.createdAt ?? 0))
328
+ .slice(0, limit)
329
+ }
330
+
331
+ /**
332
+ * The access mode (permission preset) of a session.
333
+ * @param ctx - Host context.
334
+ * @param agent - live agent, or undefined.
335
+ * @returns the preset value, or undefined when unknown.
336
+ */
337
+ export function currentPermission(ctx, agent) {
338
+ const service = ctx.get('permissionPresets')
339
+ try {
340
+ if (agent !== undefined && typeof service?.current === 'function') return service.current(agent.session)
341
+ } catch {
342
+ // Unknown.
343
+ }
344
+ return service?.defaultPreset
345
+ }
346
+
347
+ /**
348
+ * Access modes offered by this deployment.
349
+ * @param ctx - Host context.
350
+ * @returns `{ value, name }` entries.
351
+ */
352
+ export function permissionOptions(ctx) {
353
+ try {
354
+ const options = ctx.get('permissionPresets')?.catalog()?.options
355
+ if (Array.isArray(options) && options.length > 0) return options.map((option) => ({ value: option.value, name: option.name ?? option.value }))
356
+ } catch {
357
+ // Fall through.
358
+ }
359
+ return [{ value: 'workspace-write', name: 'workspace-write' }, { value: 'danger-full-access', name: 'danger-full-access' }]
360
+ }
361
+
362
+ /**
363
+ * Set a session's access mode.
364
+ * @param ctx - Host context.
365
+ * @param agent - live agent.
366
+ * @param value - preset value.
367
+ */
368
+ export function setPermission(ctx, agent, value) {
369
+ const service = ctx.get('permissionPresets')
370
+ if (typeof service?.set === 'function') {
371
+ service.set(agent.session, value)
372
+ return
373
+ }
374
+ const specs = { 'workspace-write': ['workspace-write', 'ask'], 'danger-full-access': ['danger-full-access', 'never'], 'read-only': ['read-only', 'ask'] }
375
+ const spec = specs[value]
376
+ if (spec === undefined) throw new Error(`unknown access mode ${value}`)
377
+ agent.session.append('permission/preset', { preset: value })
378
+ agent.session.append('sandbox/mode', { mode: spec[0] })
379
+ agent.session.append('approval/policy', { policy: spec[1] })
380
+ }
package/src/spool.js ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * File spool for requests from outside the Host process (the `dsh-deltachat`
3
+ * command): a sender writes one request file, the running plugin handles it
4
+ * and writes a result file next to it. Senders need no network access to the
5
+ * Host, only write access to the DSH home, and requests wait while the bot is
6
+ * stopped.
7
+ */
8
+ import { randomUUID } from 'node:crypto'
9
+ import { watch } from 'node:fs'
10
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+ import { spoolDir } from './constants.js'
13
+
14
+ /** Result files older than this are removed by the plugin. */
15
+ const RESULT_TTL_MS = 24 * 60 * 60 * 1000
16
+
17
+ /**
18
+ * Write one request atomically.
19
+ * @param request - `{ op, ... }`.
20
+ * @param dir - spool directory.
21
+ * @returns the request id.
22
+ */
23
+ export async function enqueue(request, dir = spoolDir()) {
24
+ await mkdir(dir, { recursive: true, mode: 0o700 })
25
+ const id = `${Date.now()}-${randomUUID().slice(0, 8)}`
26
+ const temporary = join(dir, `${id}.tmp`)
27
+ await writeFile(temporary, JSON.stringify({ ...request, id, createdAt: Date.now() }) + '\n', { mode: 0o600 })
28
+ await rename(temporary, join(dir, `${id}.json`))
29
+ return id
30
+ }
31
+
32
+ /**
33
+ * Wait for the plugin's result for one request.
34
+ * @param id - request id from {@link enqueue}.
35
+ * @param timeoutMs - how long to wait.
36
+ * @param dir - spool directory.
37
+ * @returns the result document, or undefined after the timeout.
38
+ */
39
+ export async function waitForResult(id, timeoutMs, dir = spoolDir()) {
40
+ const path = join(dir, `${id}.result.json`)
41
+ const deadline = Date.now() + timeoutMs
42
+ for (;;) {
43
+ try {
44
+ const result = JSON.parse(await readFile(path, 'utf8'))
45
+ await rm(path, { force: true })
46
+ return result
47
+ } catch {
48
+ // Not written yet.
49
+ }
50
+ if (Date.now() >= deadline) return undefined
51
+ await new Promise((resolve) => setTimeout(resolve, 200))
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Remove a request that was never picked up.
57
+ * @param id - request id.
58
+ * @param dir - spool directory.
59
+ * @returns true when it was still waiting and is gone now.
60
+ */
61
+ export async function withdraw(id, dir = spoolDir()) {
62
+ try {
63
+ await rm(join(dir, `${id}.json`))
64
+ return true
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Plugin side: handle spooled requests one at a time.
72
+ * @param options - `{ handle, ready, onError, dir, intervalMs }`: `handle`
73
+ * resolves one request to its result; `ready` says whether requests can be
74
+ * handled now.
75
+ * @returns `{ start, stop, drain }`.
76
+ */
77
+ export function createSpool({ handle, ready, onError, dir = spoolDir(), intervalMs = 1000 }) {
78
+ let timer
79
+ let watcher
80
+ let draining
81
+ let again = false
82
+ let running = false
83
+
84
+ async function recover() {
85
+ for (const name of await readdir(dir).catch(() => [])) {
86
+ if (name.endsWith('.processing')) {
87
+ await rename(join(dir, name), join(dir, name.replace(/\.processing$/u, '.json'))).catch(() => {})
88
+ }
89
+ }
90
+ }
91
+
92
+ async function sweep(names) {
93
+ for (const name of names) {
94
+ if (!name.endsWith('.result.json')) continue
95
+ const path = join(dir, name)
96
+ const info = await stat(path).catch(() => undefined)
97
+ if (info !== undefined && Date.now() - info.mtimeMs > RESULT_TTL_MS) await rm(path, { force: true })
98
+ }
99
+ }
100
+
101
+ async function drainOnce() {
102
+ if (!ready()) return
103
+ const names = (await readdir(dir).catch(() => [])).sort()
104
+ await sweep(names)
105
+ for (const name of names) {
106
+ if (!name.endsWith('.json') || name.endsWith('.result.json')) continue
107
+ if (!ready()) return
108
+ const id = name.slice(0, -'.json'.length)
109
+ const claimed = join(dir, `${id}.processing`)
110
+ try {
111
+ await rename(join(dir, name), claimed)
112
+ } catch {
113
+ continue
114
+ }
115
+ let outcome
116
+ try {
117
+ const request = JSON.parse(await readFile(claimed, 'utf8'))
118
+ outcome = { ok: true, result: await handle(request) }
119
+ } catch (error) {
120
+ outcome = { ok: false, error: typeof error?.message === 'string' ? error.message : String(error) }
121
+ }
122
+ try {
123
+ await writeFile(join(dir, `${id}.result.json`), JSON.stringify(outcome) + '\n', { mode: 0o600 })
124
+ await rm(claimed, { force: true })
125
+ } catch (error) {
126
+ onError(error)
127
+ }
128
+ }
129
+ }
130
+
131
+ function drain() {
132
+ if (draining !== undefined) {
133
+ again = true
134
+ return draining
135
+ }
136
+ draining = (async () => {
137
+ do {
138
+ again = false
139
+ await drainOnce()
140
+ } while (again)
141
+ })().catch(onError).finally(() => { draining = undefined })
142
+ return draining
143
+ }
144
+
145
+ async function start() {
146
+ if (running) return
147
+ running = true
148
+ await mkdir(dir, { recursive: true, mode: 0o700 })
149
+ await recover()
150
+ // Stopped while getting ready.
151
+ if (!running) return
152
+ try {
153
+ watcher = watch(dir, () => { void drain() })
154
+ watcher.on('error', () => {})
155
+ } catch {
156
+ // The interval still picks requests up.
157
+ }
158
+ timer = setInterval(() => { void drain() }, intervalMs)
159
+ timer.unref?.()
160
+ void drain()
161
+ }
162
+
163
+ function stop() {
164
+ running = false
165
+ if (timer !== undefined) clearInterval(timer)
166
+ timer = undefined
167
+ watcher?.close()
168
+ watcher = undefined
169
+ }
170
+
171
+ return { start, stop, drain }
172
+ }
package/src/state.js ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Durable bot state: which DSH session each Delta Chat chat is bound to,
3
+ * contacts that wrote without being allowed, and what was sent on behalf of
4
+ * jobs and sessions (so a quoted reply can say where a message came from).
5
+ */
6
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
7
+ import { dirname, join } from 'node:path'
8
+ import { dataDir } from './constants.js'
9
+
10
+ /** Sent-message records kept for quoted replies. */
11
+ const SENT_LIMIT = 500
12
+ /** Unknown contacts remembered for the pending list. */
13
+ const PENDING_LIMIT = 50
14
+
15
+ /** @returns an empty state document. */
16
+ function emptyState() {
17
+ return { version: 1, chats: {}, pending: {}, sent: [], picks: {} }
18
+ }
19
+
20
+ /**
21
+ * Read a JSON object, or undefined when the file is missing or broken.
22
+ * @param path - file path.
23
+ */
24
+ async function readJson(path) {
25
+ try {
26
+ const parsed = JSON.parse(await readFile(path, 'utf8'))
27
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined
28
+ } catch {
29
+ return undefined
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Open the state file.
35
+ * @param path - defaults to `<data dir>/state.json`.
36
+ * @returns the store.
37
+ */
38
+ export async function openState(path = join(dataDir(), 'state.json')) {
39
+ const loaded = await readJson(path)
40
+ const state = { ...emptyState(), ...(loaded ?? {}) }
41
+ for (const key of ['chats', 'pending', 'picks']) {
42
+ if (state[key] === null || typeof state[key] !== 'object' || Array.isArray(state[key])) state[key] = {}
43
+ }
44
+ if (!Array.isArray(state.sent)) state.sent = []
45
+ let writing = Promise.resolve()
46
+
47
+ const store = {
48
+ path,
49
+ /** The raw document; mutate through the methods below. */
50
+ get data() {
51
+ return state
52
+ },
53
+ /** Write the document atomically; writes are serialized. */
54
+ save() {
55
+ writing = writing.then(async () => {
56
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
57
+ const temporary = `${path}.${process.pid}.tmp`
58
+ await writeFile(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
59
+ await rename(temporary, path)
60
+ })
61
+ return writing
62
+ },
63
+ /** @returns the binding of a chat, or undefined. */
64
+ chat(chatId) {
65
+ return state.chats[String(chatId)]
66
+ },
67
+ /** @returns the chat id bound to a session, or undefined. */
68
+ chatOfSession(sessionId) {
69
+ for (const [chatId, record] of Object.entries(state.chats)) {
70
+ if (record?.sessionId === sessionId) return Number(chatId)
71
+ }
72
+ return undefined
73
+ },
74
+ /**
75
+ * Bind a chat to a session; a session is shown in one chat at a time, so
76
+ * another chat bound to it loses the binding.
77
+ */
78
+ async bind(chatId, record) {
79
+ for (const [other, entry] of Object.entries(state.chats)) {
80
+ if (other !== String(chatId) && entry?.sessionId === record.sessionId) delete entry.sessionId
81
+ }
82
+ state.chats[String(chatId)] = { ...(state.chats[String(chatId)] ?? {}), ...record, updatedAt: Date.now() }
83
+ await store.save()
84
+ },
85
+ /**
86
+ * Merge fields into a chat's record, saving only when something changed.
87
+ * @returns true when the record changed.
88
+ */
89
+ async describeChat(chatId, fields) {
90
+ const record = state.chats[String(chatId)] ?? {}
91
+ if (Object.entries(fields).every(([key, value]) => record[key] === value)) return false
92
+ state.chats[String(chatId)] = { ...record, ...fields }
93
+ await store.save()
94
+ return true
95
+ },
96
+ /** Forget a chat's session (the chat keeps its other settings). */
97
+ async release(chatId) {
98
+ const record = state.chats[String(chatId)]
99
+ if (record === undefined) return undefined
100
+ const previous = record.sessionId
101
+ delete record.sessionId
102
+ await store.save()
103
+ return previous
104
+ },
105
+ /** Forget a chat entirely (it was deleted or left). */
106
+ async forget(chatId) {
107
+ delete state.chats[String(chatId)]
108
+ await store.save()
109
+ },
110
+ /** Remember a contact that wrote without being allowed. */
111
+ async notePending(entry) {
112
+ const known = state.pending[entry.fingerprint]
113
+ state.pending[entry.fingerprint] = { ...entry, firstSeen: known?.firstSeen ?? Date.now(), lastSeen: Date.now(), count: (known?.count ?? 0) + 1 }
114
+ const keys = Object.keys(state.pending)
115
+ if (keys.length > PENDING_LIMIT) {
116
+ keys.sort((left, right) => state.pending[left].lastSeen - state.pending[right].lastSeen)
117
+ for (const key of keys.slice(0, keys.length - PENDING_LIMIT)) delete state.pending[key]
118
+ }
119
+ await store.save()
120
+ return known === undefined
121
+ },
122
+ /** Drop pending entries that are allowed now. */
123
+ async clearPending(fingerprints) {
124
+ let changed = false
125
+ for (const fingerprint of fingerprints) {
126
+ if (state.pending[fingerprint] === undefined) continue
127
+ delete state.pending[fingerprint]
128
+ changed = true
129
+ }
130
+ if (changed) await store.save()
131
+ },
132
+ /** Record messages sent on someone's behalf. */
133
+ async noteSent(record) {
134
+ state.sent.push({ ...record, at: Date.now() })
135
+ if (state.sent.length > SENT_LIMIT) state.sent.splice(0, state.sent.length - SENT_LIMIT)
136
+ await store.save()
137
+ },
138
+ /** @returns the send record holding a Delta Chat message id, or undefined. */
139
+ sentRecord(messageId) {
140
+ for (let index = state.sent.length - 1; index >= 0; index -= 1) {
141
+ if (state.sent[index].messageIds?.includes(messageId)) return state.sent[index]
142
+ }
143
+ return undefined
144
+ },
145
+ }
146
+ return store
147
+ }