@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/commands.js
ADDED
|
@@ -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
|
+
})
|
package/src/constants.js
ADDED
|
@@ -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
|