@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,429 @@
1
+ /**
2
+ * Chat commands. They follow the Telegram bot's commands; where Telegram
3
+ * shows buttons, a command lists numbered choices and the same command with
4
+ * the number picks one (`/model` lists providers, `/model 2` its models,
5
+ * `/model 2 5` selects). Delta Chat apps make `/command` words tappable in
6
+ * bot chats.
7
+ */
8
+ import { basename } from 'node:path'
9
+ import { messageData } from './core.js'
10
+ import { allowedEntry, vcardFingerprint } from './identity.js'
11
+ import { CONTROL_HANDLERS, liveAgent, pick, sessionOf } from './controls.js'
12
+ import { clearChatInbox, controllerOf, listWorkspaces, recentAssistantTexts, sessionTitle, sessionsInWorkspace } from './sessions.js'
13
+ import { clip, toPlainText } from './text.js'
14
+ import { chatTitle } from './titles.js'
15
+
16
+ /** Commands and their help lines, in the order /help shows them. */
17
+ export const COMMANDS = [
18
+ ['help', 'List commands'],
19
+ ['new', 'Start a new session in this chat'],
20
+ ['stop', 'Stop the running turn'],
21
+ ['clear', 'Drop messages still waiting for the session'],
22
+ ['status', 'Bot and session status'],
23
+ ['current', "Show this chat's session"],
24
+ ['last', 'Show the latest answers: /last [1-5]'],
25
+ ['sessions', 'Browse workspaces and sessions, continue one here'],
26
+ ['workspace', 'Start a new session in another workspace'],
27
+ ['fork', 'Fork this session into a new group: /fork [name]'],
28
+ ['rename', "Rename this chat's session (and the group)"],
29
+ ['goal', 'Goal: /goal <objective>, edit, pause, resume, clear'],
30
+ ['menu', 'Model, effort, access mode and context'],
31
+ ['model', 'Choose the model'],
32
+ ['effort', 'Choose the reasoning effort'],
33
+ ['mode', 'Choose the access mode'],
34
+ ['context', 'Context use of the session'],
35
+ ['allow', 'Allow the waiting tool request'],
36
+ ['deny', 'Deny the waiting tool request'],
37
+ ['invite', "Show the bot's invite link"],
38
+ ['id', 'Show chat and key ids'],
39
+ ['ping', 'Check that the bot answers'],
40
+ ['restart', 'Restart the Delta Chat connection'],
41
+ ]
42
+
43
+ const NAMES = new Set([...COMMANDS.map(([command]) => command), 'start'])
44
+
45
+ /** Workspaces and sessions listed at most. */
46
+ const LIST_LIMIT = 15
47
+
48
+ /**
49
+ * Read a command from a message.
50
+ * @param text - message text.
51
+ * @returns `{ name, args }`, or undefined when the text is not a known command
52
+ * (a path such as `/home/...` stays an ordinary message).
53
+ */
54
+ export function parseCommand(text) {
55
+ const match = String(text ?? '').trim().match(/^\/([A-Za-z]+)(?:@\S+)?(?:\s+([\s\S]*))?$/u)
56
+ if (match === null) return undefined
57
+ const name = match[1].toLowerCase()
58
+ if (!NAMES.has(name)) return undefined
59
+ return { name: name === 'start' ? 'help' : name, args: (match[2] ?? '').trim() }
60
+ }
61
+
62
+ /** @returns the help text. */
63
+ export function helpText() {
64
+ return ['Commands:', ...COMMANDS.map(([command, line]) => `/${command} — ${line}`), '', 'Anything else you write goes to this chat\'s DSH session.'].join('\n')
65
+ }
66
+
67
+ /** @returns a relative age, such as "5 min ago". */
68
+ function age(timestamp) {
69
+ if (typeof timestamp !== 'number') return ''
70
+ const minutes = Math.round((Date.now() - timestamp) / 60_000)
71
+ if (minutes < 60) return `${Math.max(0, minutes)} min ago`
72
+ const hours = Math.round(minutes / 60)
73
+ if (hours < 48) return `${hours} h ago`
74
+ return `${Math.round(hours / 24)} d ago`
75
+ }
76
+
77
+ /**
78
+ * Run one command.
79
+ * @param bridge - runtime handles from app.js.
80
+ * @param from - `{ chatId, chatInfo, sender, msgId }`.
81
+ * @param command - `{ name, args }`.
82
+ */
83
+ export async function runCommand(bridge, from, command) {
84
+ const handler = HANDLERS[command.name]
85
+ await handler(bridge, from, command.args)
86
+ }
87
+
88
+ function isRunning(agent) {
89
+ return agent !== undefined && agent.status !== undefined && agent.status !== 'idle'
90
+ }
91
+
92
+ /** Refuse a command while the chat's session runs a turn. */
93
+ async function refuseWhileRunning(bridge, chatId, what) {
94
+ if (!isRunning(liveAgent(bridge, chatId))) return false
95
+ await bridge.notice(chatId, `Stop the running turn first (/stop), then ${what}.`)
96
+ return true
97
+ }
98
+
99
+ /** Start the chat's next session in a directory, keeping the old one in DSH. */
100
+ async function startFresh(bridge, chatId, cwd) {
101
+ const store = bridge.store()
102
+ const previous = await store.release(chatId)
103
+ const record = store.data.chats[String(chatId)] ?? {}
104
+ store.data.chats[String(chatId)] = { ...record, cwd }
105
+ await store.save()
106
+ const title = previous === undefined ? undefined : await sessionTitle(bridge.ctx, previous)
107
+ return title
108
+ }
109
+
110
+ /** Text of a goal's state. */
111
+ function goalStatus(view) {
112
+ if (view === undefined) return '🎯 No goal is set.\n\n/goal <objective> sets one.'
113
+ const phase = view.activation === 'armed' || view.phase !== 'active' ? view.phase : `${view.phase}, not armed`
114
+ return `🎯 Goal (${phase}, round ${view.roundsStarted}/${view.maxGoalRounds}):\n${view.objective}\n\n/goal edit <objective> · /goal pause · /goal resume · /goal clear`
115
+ }
116
+
117
+ const HANDLERS = {
118
+ async help(bridge, { chatId }) {
119
+ await bridge.notice(chatId, helpText())
120
+ },
121
+
122
+ async ping(bridge, { chatId }) {
123
+ await bridge.notice(chatId, 'pong')
124
+ },
125
+
126
+ async id(bridge, { chatId, chatInfo, sender }) {
127
+ const { dc, accountId } = bridge.runtime()
128
+ const vcard = await dc.rpc.makeVcard(accountId, [1]).catch(() => '')
129
+ const own = vcardFingerprint(vcard)
130
+ await bridge.notice(chatId, [
131
+ `Chat: ${chatId} (${chatInfo.chatType}, "${chatInfo.name}")`,
132
+ `Your key: ${sender.fingerprint}`,
133
+ ...(own ? [`Bot key: ${own}`] : []),
134
+ `Session: ${sessionOf(bridge, chatId) ?? 'none yet'}`,
135
+ ].join('\n'))
136
+ },
137
+
138
+ async status(bridge, { chatId }) {
139
+ const facts = await bridge.describe()
140
+ const agent = liveAgent(bridge, chatId)
141
+ const sessionId = sessionOf(bridge, chatId)
142
+ const lines = [
143
+ `Delta Chat bot: ${facts.running ? 'running' : facts.state}`,
144
+ ...(facts.running ? [
145
+ `Address: ${facts.primary}`,
146
+ `Relays: ${facts.relays.map((addr) => addr.split('@')[1]).join(', ')}`,
147
+ `Connectivity: ${facts.connectivity >= 4000 ? 'connected' : facts.connectivity >= 3000 ? 'working' : facts.connectivity >= 2000 ? 'connecting' : 'offline'}`,
148
+ `Uptime: ${Math.round(facts.uptimeSeconds / 60)} min`,
149
+ `Allowed people: ${facts.allowedUsers}${facts.pending.length > 0 ? `, ${facts.pending.length} ignored contact(s)` : ''}`,
150
+ ] : []),
151
+ `Session: ${sessionId ?? 'none yet'}`,
152
+ ...(sessionId === undefined ? [] : [`Title: ${await sessionTitle(bridge.ctx, sessionId)}`]),
153
+ `Agent: ${isRunning(agent) ? 'running' : 'idle'}`,
154
+ ]
155
+ const asked = bridge.prompts.pending(chatId)
156
+ if (asked.question) lines.push('A question is waiting for your answer.')
157
+ if (asked.approvals > 0) lines.push(`${asked.approvals} tool request(s) wait for /allow or /deny.`)
158
+ await bridge.notice(chatId, lines.join('\n'))
159
+ },
160
+
161
+ async current(bridge, { chatId }) {
162
+ const sessionId = sessionOf(bridge, chatId)
163
+ if (sessionId === undefined) {
164
+ await bridge.notice(chatId, 'This chat has no session yet; your next message starts one.')
165
+ return
166
+ }
167
+ const record = bridge.store().chat(chatId)
168
+ await bridge.notice(chatId, `${await sessionTitle(bridge.ctx, sessionId)}\nSession: ${sessionId}\nWorkspace: ${record?.cwd ?? '?'}`)
169
+ },
170
+
171
+ async new(bridge, { chatId }) {
172
+ if (await refuseWhileRunning(bridge, chatId, 'start a new session')) return
173
+ const record = bridge.store().chat(chatId)
174
+ const previous = await startFresh(bridge, chatId, record?.cwd ?? bridge.setting('cwd', process.cwd()))
175
+ await bridge.notice(chatId, `🆕 New session: your next message starts it.${previous === undefined ? '' : `\nThe previous session stays in DSH: ${previous}`}`)
176
+ },
177
+
178
+ async stop(bridge, { chatId }) {
179
+ const agent = liveAgent(bridge, chatId)
180
+ bridge.prompts.cancelChat(chatId)
181
+ if (!isRunning(agent)) {
182
+ await bridge.notice(chatId, 'Nothing is running.')
183
+ return
184
+ }
185
+ const turn = bridge.currentTurn(agent.session?.id ?? agent.id)
186
+ if (turn !== undefined) turn.stopped = true
187
+ try {
188
+ agent.inbox?.clear?.()
189
+ } catch {
190
+ // Inbox unavailable.
191
+ }
192
+ try {
193
+ agent.cancel({ kind: 'user' })
194
+ } catch {
195
+ // Already idle.
196
+ }
197
+ await bridge.notice(chatId, '⏹ Stopped.')
198
+ },
199
+
200
+ async clear(bridge, { chatId }) {
201
+ const removed = clearChatInbox(liveAgent(bridge, chatId), chatId)
202
+ await bridge.notice(chatId, removed === 0 ? '🧹 Nothing is waiting.' : `🧹 Dropped ${removed} waiting message(s).`)
203
+ },
204
+
205
+ async last(bridge, { chatId }, args) {
206
+ const sessionId = sessionOf(bridge, chatId)
207
+ if (sessionId === undefined) {
208
+ await bridge.notice(chatId, 'This chat has no session yet.')
209
+ return
210
+ }
211
+ const requested = Number.parseInt(args || '1', 10)
212
+ const limit = Number.isFinite(requested) ? Math.min(5, Math.max(1, requested)) : 1
213
+ const texts = await recentAssistantTexts(bridge.ctx, sessionId, limit)
214
+ if (texts.length === 0) {
215
+ await bridge.notice(chatId, 'This session has no answers yet.')
216
+ return
217
+ }
218
+ for (const [index, text] of [...texts].reverse().entries()) {
219
+ const label = texts.length === 1 ? 'Latest answer' : `Answer ${index + 1}/${texts.length}`
220
+ await bridge.notice(chatId, `${label}:\n\n${toPlainText(text)}`)
221
+ }
222
+ },
223
+
224
+ async sessions(bridge, { chatId }, args) {
225
+ const workspaces = listWorkspaces(bridge.ctx).slice(0, LIST_LIMIT)
226
+ const [first, second] = args.split(/\s+/u).filter(Boolean)
227
+ if (first === undefined) {
228
+ if (workspaces.length === 0) {
229
+ await bridge.notice(chatId, 'No workspaces are registered.')
230
+ return
231
+ }
232
+ const here = bridge.store().chat(chatId)?.cwd
233
+ await bridge.notice(chatId, [
234
+ 'Workspaces:',
235
+ ...workspaces.map((workspace, index) => `${index + 1}. ${workspace.title || basename(workspace.path)} (${workspace.path})${workspace.path === here ? ' ✅' : ''}`),
236
+ '',
237
+ '/sessions <n> lists the sessions of workspace n.',
238
+ ].join('\n'))
239
+ return
240
+ }
241
+ const workspace = pick(workspaces, first)
242
+ if (workspace === undefined) {
243
+ await bridge.notice(chatId, `No workspace ${first}; /sessions lists them.`)
244
+ return
245
+ }
246
+ const records = await sessionsInWorkspace(bridge.ctx, workspace, LIST_LIMIT, bridge.signal)
247
+ if (second === undefined) {
248
+ const store = bridge.store()
249
+ const lines = [`Sessions in ${workspace.title || basename(workspace.path)}:`, '0. New session']
250
+ for (const [index, record] of records.entries()) {
251
+ const id = record.header.id
252
+ const boundTo = store.chatOfSession(id)
253
+ const mark = boundTo === chatId ? ' ✅' : boundTo !== undefined ? ' 🔗' : ''
254
+ lines.push(`${index + 1}. ${clip(await sessionTitle(bridge.ctx, id, record.header), 60)} · ${age(record.header.createdAt)}${mark}`)
255
+ }
256
+ lines.push('', `/sessions ${first} <n> continues session n in this chat (0 starts a new one). 🔗 means another chat shows it now; it moves here.`)
257
+ await bridge.notice(chatId, lines.join('\n'))
258
+ return
259
+ }
260
+ if (await refuseWhileRunning(bridge, chatId, 'switch sessions')) return
261
+ if (second === '0' || second === 'new') {
262
+ await startFresh(bridge, chatId, workspace.path)
263
+ await bridge.notice(chatId, `🆕 Your next message starts a new session in ${workspace.title || basename(workspace.path)}.`)
264
+ return
265
+ }
266
+ const record = pick(records, second)
267
+ if (record === undefined) {
268
+ await bridge.notice(chatId, `No session ${second}; /sessions ${first} lists them.`)
269
+ return
270
+ }
271
+ const sessionId = record.header.id
272
+ await bridge.store().bind(chatId, { sessionId, cwd: record.header.cwd ?? workspace.path })
273
+ await bridge.ensureAgent(chatId)
274
+ await bridge.notice(chatId, `✅ This chat now continues: ${await sessionTitle(bridge.ctx, sessionId, record.header)}`)
275
+ },
276
+
277
+ async workspace(bridge, from, args) {
278
+ if (args.length === 0) {
279
+ await HANDLERS.sessions(bridge, from, '')
280
+ await bridge.notice(from.chatId, '/workspace <n> starts a new session in workspace n.')
281
+ return
282
+ }
283
+ await HANDLERS.sessions(bridge, from, `${args.split(/\s+/u)[0]} new`)
284
+ },
285
+
286
+ async fork(bridge, { chatId }, args) {
287
+ const sessionId = sessionOf(bridge, chatId)
288
+ if (sessionId === undefined) {
289
+ await bridge.notice(chatId, 'This chat has no session to fork yet.')
290
+ return
291
+ }
292
+ if (await refuseWhileRunning(bridge, chatId, 'fork')) return
293
+ const controller = controllerOf(bridge.ctx)
294
+ if (typeof controller?.fork !== 'function') {
295
+ await bridge.notice(chatId, 'Forking is not available in this DSH.')
296
+ return
297
+ }
298
+ const result = await controller.fork({ sessionId })
299
+ const forkId = typeof result === 'string' ? result : result?.sessionId
300
+ if (typeof forkId !== 'string') throw new Error('The fork returned no session id.')
301
+ const title = clip(args.replace(/^["']|["']$/gu, '').trim() || `Fork of ${await sessionTitle(bridge.ctx, sessionId)}`, 100)
302
+ const { dc, accountId, fingerprints } = bridge.runtime()
303
+ const allowed = bridge.setting('allowedUsers', [])
304
+ const groupId = await dc.rpc.createGroupChat(accountId, title, false)
305
+ for (const contactId of await dc.rpc.getChatContacts(accountId, chatId)) {
306
+ if (contactId <= 9) continue
307
+ if (allowedEntry(allowed, await fingerprints.get(contactId)) !== undefined) await dc.rpc.addContactToChat(accountId, groupId, contactId)
308
+ }
309
+ const record = bridge.store().chat(chatId)
310
+ await bridge.store().bind(groupId, { sessionId: forkId, cwd: record?.cwd })
311
+ await bridge.store().describeChat(groupId, { person: title, chatName: title, titlePersons: [record?.person].filter(Boolean) })
312
+ await bridge.ensureAgent(groupId)
313
+ await bridge.applyTitle(forkId)
314
+ await dc.rpc.sendMsg(accountId, groupId, messageData({ text: `🔀 This group continues a fork of "${await sessionTitle(bridge.ctx, sessionId)}". The original stays in the chat it came from.` }))
315
+ await bridge.notice(chatId, `🔀 Forked into the new group "${title}".`)
316
+ },
317
+
318
+ async rename(bridge, { chatId, chatInfo }, args) {
319
+ const sessionId = sessionOf(bridge, chatId)
320
+ const name = args.trim()
321
+ if (name.length === 0 || name.length > 120) {
322
+ await bridge.notice(chatId, 'Usage: /rename <new name> (at most 120 characters)')
323
+ return
324
+ }
325
+ // A group is renamed, and its session's title follows; in a one-to-one
326
+ // chat the session's conversation title is set.
327
+ if (chatInfo.chatType === 'Group') {
328
+ const { dc, accountId } = bridge.runtime()
329
+ await dc.rpc.setChatName(accountId, chatId, name)
330
+ await bridge.store().describeChat(chatId, { person: name, chatName: name })
331
+ if (sessionId !== undefined) await bridge.applyTitle(sessionId)
332
+ await bridge.notice(chatId, `✏️ Group renamed to "${name}".`)
333
+ return
334
+ }
335
+ if (sessionId === undefined) {
336
+ await bridge.notice(chatId, 'This chat has no session to rename yet.')
337
+ return
338
+ }
339
+ const record = bridge.store().chat(chatId)
340
+ const title = chatTitle(record?.person ?? chatInfo.name, name)
341
+ const service = bridge.ctx.get('sessionTitle')
342
+ const session = (await bridge.ensureAgent(chatId)).session
343
+ if (service !== undefined) service.rename(session, title)
344
+ else await controllerOf(bridge.ctx)?.rename?.({ sessionId, title })
345
+ await bridge.notice(chatId, `✏️ Session renamed to "${title}".`)
346
+ },
347
+
348
+ async goal(bridge, { chatId }, args) {
349
+ const goals = bridge.ctx.get('goals')
350
+ if (goals === undefined) {
351
+ await bridge.notice(chatId, 'Goals are not available in this DSH.')
352
+ return
353
+ }
354
+ const agent = await bridge.ensureAgent(chatId, { claim: true })
355
+ const [action = ''] = args.split(/\s+/u)
356
+ const verb = action.toLowerCase()
357
+ let view = goals.get(agent)
358
+ if (args.length === 0) {
359
+ await bridge.notice(chatId, goalStatus(view))
360
+ return
361
+ }
362
+ if (verb === 'clear' || verb === 'delete') {
363
+ if (view !== undefined) goals.clear(agent, { id: view.id, revision: view.revision })
364
+ await bridge.notice(chatId, view === undefined ? '🎯 No goal to clear.' : '🎯 Goal cleared.')
365
+ return
366
+ }
367
+ if (verb === 'pause') {
368
+ if (view === undefined) {
369
+ await bridge.notice(chatId, '🎯 No goal to pause.')
370
+ return
371
+ }
372
+ view = goals.pause(agent, { id: view.id, revision: view.revision })
373
+ await bridge.notice(chatId, goalStatus(view))
374
+ return
375
+ }
376
+ if (verb === 'resume') {
377
+ if (view === undefined || view.phase === 'complete') {
378
+ await bridge.notice(chatId, view === undefined ? '🎯 No goal is set.' : '🎯 The goal is already complete.')
379
+ return
380
+ }
381
+ if (!(view.phase === 'active' && view.activation === 'armed')) view = goals.resume(agent, { id: view.id, revision: view.revision })
382
+ await bridge.notice(chatId, goalStatus(view))
383
+ return
384
+ }
385
+ const objective = verb === 'edit' || verb === 'set' ? args.slice(action.length).trim() : args
386
+ if (objective.length === 0) {
387
+ await bridge.notice(chatId, 'Usage: /goal <objective>')
388
+ return
389
+ }
390
+ if (view !== undefined && view.phase !== 'complete') {
391
+ view = goals.edit(agent, { id: view.id, revision: view.revision }, { objective })
392
+ if (view.phase !== 'active' || view.activation !== 'armed') {
393
+ try {
394
+ view = goals.resume(agent, { id: view.id, revision: view.revision })
395
+ } catch (error) {
396
+ bridge.warn(`goal resume after edit failed: ${error?.message ?? error}`)
397
+ }
398
+ }
399
+ } else {
400
+ if (view !== undefined) goals.clear(agent, { id: view.id, revision: view.revision })
401
+ view = goals.create(agent, { objective })
402
+ }
403
+ bridge.chatState(chatId).goalPhase = view.phase
404
+ await bridge.notice(chatId, goalStatus(view))
405
+ },
406
+
407
+ ...CONTROL_HANDLERS,
408
+
409
+ async allow(bridge, { chatId }) {
410
+ const tool = bridge.prompts.settleApproval(chatId, true)
411
+ await bridge.notice(chatId, tool === undefined ? 'No tool request is waiting.' : `✅ Allowed ${tool}.`)
412
+ },
413
+
414
+ async deny(bridge, { chatId }) {
415
+ const tool = bridge.prompts.settleApproval(chatId, false)
416
+ await bridge.notice(chatId, tool === undefined ? 'No tool request is waiting.' : `❌ Denied ${tool}.`)
417
+ },
418
+
419
+ async invite(bridge, { chatId }) {
420
+ const { link } = await bridge.invite()
421
+ await bridge.notice(chatId, `Invite link of this bot (people still need to be in allowedUsers to talk to it):\n${link}`)
422
+ },
423
+
424
+ async restart(bridge, { chatId }) {
425
+ await bridge.notice(chatId, '♻️ Restarting the Delta Chat connection…')
426
+ bridge.restart('command')
427
+ },
428
+ }
429
+
package/src/config.js ADDED
@@ -0,0 +1,63 @@
1
+ /** Editable plugin configuration. Every field is volatile: it is read live. */
2
+ import z from '@deepseek-ai/schemastery'
3
+ import { DEFAULT_RELAYS } from './constants.js'
4
+
5
+ export const Config = z.object({
6
+ /** Whether the bot runs while this profile runs. */
7
+ enabled: z.boolean().default(true).volatile(),
8
+ /** Name the bot shows in Delta Chat. */
9
+ displayName: z.string().default('DSH').volatile(),
10
+ /** Absolute path of a picture used as the bot's avatar; empty keeps the current one. */
11
+ avatar: z.string().default('').volatile(),
12
+ /**
13
+ * Chatmail relays the bot keeps an address on, by domain. The first one that
14
+ * works creates the profile; the others are added as extra relays, so the bot
15
+ * stays reachable while one relay is down. Relays added here later are added
16
+ * to the profile; removing one here leaves it in the profile.
17
+ */
18
+ relays: z.array(z.string()).default(DEFAULT_RELAYS).volatile(),
19
+ /**
20
+ * People who may talk to the bot, by OpenPGP key fingerprint (40 hex
21
+ * characters, spaces allowed). `name` is how their messages are labelled for
22
+ * the agent. Everyone else is ignored.
23
+ */
24
+ allowedUsers: z.array(z.object({
25
+ fingerprint: z.string(),
26
+ name: z.string().default(''),
27
+ })).default([]).volatile(),
28
+ /**
29
+ * JSON file whose `deltachat_keys` object maps key fingerprints to the names
30
+ * used in session titles (`DeltaChat-<name>-<task>`); empty uses the
31
+ * `allowedUsers` name, then the contact's own name.
32
+ */
33
+ peopleFile: z.string().default('').volatile(),
34
+ /** Working directory of sessions the bot creates. */
35
+ cwd: z.string().default(process.cwd()).volatile(),
36
+ /** Agent preset of sessions the bot creates; empty uses the Host default. */
37
+ agentPreset: z.string().default('').volatile(),
38
+ /**
39
+ * Also show turns started elsewhere (Web UI, scheduled tasks, other plugins)
40
+ * in the chat bound to their session.
41
+ */
42
+ mirrorTurns: z.boolean().default(true).volatile(),
43
+ /**
44
+ * Messages sent while the session's turn runs: `steer` sends them into that
45
+ * turn (as the Web UI does); `queue` holds them until the turn ends.
46
+ */
47
+ busyMessages: z.union(['steer', 'queue']).default('steer').volatile(),
48
+ /**
49
+ * How voice messages and audio files become text: `dsh` uses the DSH
50
+ * speech-to-text service (decoded with ffmpeg), `command` runs
51
+ * `transcribeCommand`, `off` passes only the file.
52
+ */
53
+ transcriber: z.union(['dsh', 'command', 'off']).default('dsh').volatile(),
54
+ /** Language hint for the DSH speech-to-text service; empty uses its setting. */
55
+ transcribeLanguage: z.string().default('').volatile(),
56
+ /**
57
+ * Program and arguments for `transcriber: command`, run without a shell.
58
+ * `{file}` is replaced by the recording's path (appended when absent).
59
+ */
60
+ transcribeCommand: z.array(z.string()).default([]).volatile(),
61
+ /** ffmpeg program used to decode recordings for the DSH service. */
62
+ ffmpegPath: z.string().default('ffmpeg').volatile(),
63
+ })
@@ -0,0 +1,50 @@
1
+ /** Shared constants and on-disk locations. */
2
+ import { homedir } from 'node:os'
3
+ import { join } from 'node:path'
4
+
5
+ /** Loader row id and settings namespace; the npm package is @soffits/dsh-deltachat-bot. */
6
+ export const name = 'deltachat-bot'
7
+
8
+ /** Name of the Host service other plugins send through. */
9
+ export const SERVICE_NAME = 'deltachatChats'
10
+
11
+ /**
12
+ * Directory holding everything the bot keeps: the Delta Chat profile, the
13
+ * chat → session map, the send log, the spool and diagnostics. Read at call
14
+ * time, so tests and throwaway homes can move it with DSH_HOME.
15
+ * @returns absolute path.
16
+ */
17
+ export function dataDir() {
18
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'deltachat')
19
+ }
20
+
21
+ /** @returns the Delta Chat accounts directory (owned by the core's RPC server). */
22
+ export function accountsDir() {
23
+ return join(dataDir(), 'accounts')
24
+ }
25
+
26
+ /** @returns the request spool used by the `dsh-deltachat` command. */
27
+ export function spoolDir() {
28
+ return join(dataDir(), 'spool')
29
+ }
30
+
31
+ /** Relays used when a new profile is created and no relays are configured. */
32
+ export const DEFAULT_RELAYS = ['nine.testrun.org', 'chtml.ca']
33
+
34
+ /** A final answer ending in exactly this text posts nothing more in the chat. */
35
+ export const SILENT_REPLY = '[SILENT]'
36
+
37
+ /** Longest text sent as one Delta Chat message; longer answers are split. */
38
+ export const MAX_MESSAGE_TEXT = 30_000
39
+
40
+ /** Reaction that tells the sender a message reached the running turn. */
41
+ export const STEER_REACTION = '👀'
42
+
43
+ /** Prefix of the request ids of prompts that came from Delta Chat. */
44
+ export const REQUEST_PREFIX = 'deltachat:'
45
+
46
+ /** Source kind of the copy a send leaves in the bound session. */
47
+ export const POST_SOURCE_KIND = 'deltachat-post'
48
+
49
+ /** Largest file sent or attached, in bytes. Chatmail relays reject bigger mails. */
50
+ export const MAX_FILE_BYTES = 30 * 1024 * 1024