@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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +182 -2
- package/bin/dsh-deltachat.js +140 -0
- package/cordis.patch.yml +9 -0
- package/icon.svg +14 -0
- package/index.js +4 -0
- package/locale/en.json +6 -0
- package/package.json +67 -4
- package/src/app.js +628 -0
- package/src/commands.js +429 -0
- package/src/config.js +63 -0
- package/src/constants.js +50 -0
- package/src/controls.js +214 -0
- package/src/core.js +236 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +174 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/sessions.js +380 -0
- package/src/spool.js +172 -0
- package/src/state.js +147 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +161 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
package/src/sessions.js
ADDED
|
@@ -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
|
+
}
|