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