@soffits/dsh-deltachat-bot 0.0.0-stage → 0.2.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 +12 -0
- package/LICENSE +21 -0
- package/README.md +185 -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 +653 -0
- package/src/commands.js +454 -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/helpers.js +180 -0
- package/src/identity.js +134 -0
- package/src/inbound.js +100 -0
- package/src/models.js +191 -0
- package/src/operations.js +183 -0
- package/src/outbound.js +79 -0
- package/src/prompts.js +179 -0
- package/src/reactions.js +125 -0
- package/src/sessions.js +388 -0
- package/src/spool.js +172 -0
- package/src/state.js +162 -0
- package/src/text.js +175 -0
- package/src/titles.js +146 -0
- package/src/turns.js +197 -0
- package/src/voice.js +126 -0
- package/tools.js +60 -0
package/src/state.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
* and messages that carry the bot's working reaction.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { dataDir } from './constants.js'
|
|
10
|
+
|
|
11
|
+
/** Sent-message records kept for quoted replies. */
|
|
12
|
+
const SENT_LIMIT = 500
|
|
13
|
+
/** Unknown contacts remembered for the pending list. */
|
|
14
|
+
const PENDING_LIMIT = 50
|
|
15
|
+
|
|
16
|
+
/** @returns an empty state document. */
|
|
17
|
+
function emptyState() {
|
|
18
|
+
return { version: 1, chats: {}, pending: {}, sent: [], picks: {}, reactions: {} }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Read a JSON object, or undefined when the file is missing or broken.
|
|
23
|
+
* @param path - file path.
|
|
24
|
+
*/
|
|
25
|
+
async function readJson(path) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(await readFile(path, 'utf8'))
|
|
28
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined
|
|
29
|
+
} catch {
|
|
30
|
+
return undefined
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open the state file.
|
|
36
|
+
* @param path - defaults to `<data dir>/state.json`.
|
|
37
|
+
* @returns the store.
|
|
38
|
+
*/
|
|
39
|
+
export async function openState(path = join(dataDir(), 'state.json')) {
|
|
40
|
+
const loaded = await readJson(path)
|
|
41
|
+
const state = { ...emptyState(), ...(loaded ?? {}) }
|
|
42
|
+
for (const key of ['chats', 'pending', 'picks', 'reactions']) {
|
|
43
|
+
if (state[key] === null || typeof state[key] !== 'object' || Array.isArray(state[key])) state[key] = {}
|
|
44
|
+
}
|
|
45
|
+
if (!Array.isArray(state.sent)) state.sent = []
|
|
46
|
+
let writing = Promise.resolve()
|
|
47
|
+
|
|
48
|
+
const store = {
|
|
49
|
+
path,
|
|
50
|
+
/** The raw document; mutate through the methods below. */
|
|
51
|
+
get data() {
|
|
52
|
+
return state
|
|
53
|
+
},
|
|
54
|
+
/** Write the document atomically; writes are serialized. */
|
|
55
|
+
save() {
|
|
56
|
+
writing = writing.then(async () => {
|
|
57
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
58
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
59
|
+
await writeFile(temporary, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 })
|
|
60
|
+
await rename(temporary, path)
|
|
61
|
+
})
|
|
62
|
+
return writing
|
|
63
|
+
},
|
|
64
|
+
/** @returns the binding of a chat, or undefined. */
|
|
65
|
+
chat(chatId) {
|
|
66
|
+
return state.chats[String(chatId)]
|
|
67
|
+
},
|
|
68
|
+
/** @returns the chat id bound to a session, or undefined. */
|
|
69
|
+
chatOfSession(sessionId) {
|
|
70
|
+
for (const [chatId, record] of Object.entries(state.chats)) {
|
|
71
|
+
if (record?.sessionId === sessionId) return Number(chatId)
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
},
|
|
75
|
+
/**
|
|
76
|
+
* Bind a chat to a session; a session is shown in one chat at a time, so
|
|
77
|
+
* another chat bound to it loses the binding.
|
|
78
|
+
*/
|
|
79
|
+
async bind(chatId, record) {
|
|
80
|
+
for (const [other, entry] of Object.entries(state.chats)) {
|
|
81
|
+
if (other !== String(chatId) && entry?.sessionId === record.sessionId) delete entry.sessionId
|
|
82
|
+
}
|
|
83
|
+
state.chats[String(chatId)] = { ...(state.chats[String(chatId)] ?? {}), ...record, updatedAt: Date.now() }
|
|
84
|
+
await store.save()
|
|
85
|
+
},
|
|
86
|
+
/**
|
|
87
|
+
* Merge fields into a chat's record, saving only when something changed.
|
|
88
|
+
* @returns true when the record changed.
|
|
89
|
+
*/
|
|
90
|
+
async describeChat(chatId, fields) {
|
|
91
|
+
const record = state.chats[String(chatId)] ?? {}
|
|
92
|
+
if (Object.entries(fields).every(([key, value]) => record[key] === value)) return false
|
|
93
|
+
state.chats[String(chatId)] = { ...record, ...fields }
|
|
94
|
+
await store.save()
|
|
95
|
+
return true
|
|
96
|
+
},
|
|
97
|
+
/** Forget a chat's session (the chat keeps its other settings). */
|
|
98
|
+
async release(chatId) {
|
|
99
|
+
const record = state.chats[String(chatId)]
|
|
100
|
+
if (record === undefined) return undefined
|
|
101
|
+
const previous = record.sessionId
|
|
102
|
+
delete record.sessionId
|
|
103
|
+
await store.save()
|
|
104
|
+
return previous
|
|
105
|
+
},
|
|
106
|
+
/** Forget a chat entirely (it was deleted or left). */
|
|
107
|
+
async forget(chatId) {
|
|
108
|
+
delete state.chats[String(chatId)]
|
|
109
|
+
await store.save()
|
|
110
|
+
},
|
|
111
|
+
/** Remember a contact that wrote without being allowed. */
|
|
112
|
+
async notePending(entry) {
|
|
113
|
+
const known = state.pending[entry.fingerprint]
|
|
114
|
+
state.pending[entry.fingerprint] = { ...entry, firstSeen: known?.firstSeen ?? Date.now(), lastSeen: Date.now(), count: (known?.count ?? 0) + 1 }
|
|
115
|
+
const keys = Object.keys(state.pending)
|
|
116
|
+
if (keys.length > PENDING_LIMIT) {
|
|
117
|
+
keys.sort((left, right) => state.pending[left].lastSeen - state.pending[right].lastSeen)
|
|
118
|
+
for (const key of keys.slice(0, keys.length - PENDING_LIMIT)) delete state.pending[key]
|
|
119
|
+
}
|
|
120
|
+
await store.save()
|
|
121
|
+
return known === undefined
|
|
122
|
+
},
|
|
123
|
+
/** Drop pending entries that are allowed now. */
|
|
124
|
+
async clearPending(fingerprints) {
|
|
125
|
+
let changed = false
|
|
126
|
+
for (const fingerprint of fingerprints) {
|
|
127
|
+
if (state.pending[fingerprint] === undefined) continue
|
|
128
|
+
delete state.pending[fingerprint]
|
|
129
|
+
changed = true
|
|
130
|
+
}
|
|
131
|
+
if (changed) await store.save()
|
|
132
|
+
},
|
|
133
|
+
/** Record messages sent on someone's behalf. */
|
|
134
|
+
async noteSent(record) {
|
|
135
|
+
state.sent.push({ ...record, at: Date.now() })
|
|
136
|
+
if (state.sent.length > SENT_LIMIT) state.sent.splice(0, state.sent.length - SENT_LIMIT)
|
|
137
|
+
await store.save()
|
|
138
|
+
},
|
|
139
|
+
/** @returns message id → chat id of messages with the bot's working reaction. */
|
|
140
|
+
reactions() {
|
|
141
|
+
return { ...state.reactions }
|
|
142
|
+
},
|
|
143
|
+
/** Remember a message that got the working reaction. */
|
|
144
|
+
async noteReaction(msgId, chatId) {
|
|
145
|
+
state.reactions[String(msgId)] = chatId
|
|
146
|
+
await store.save()
|
|
147
|
+
},
|
|
148
|
+
/** Forget messages whose working reaction was removed. */
|
|
149
|
+
async dropReactions(msgIds) {
|
|
150
|
+
for (const msgId of msgIds) delete state.reactions[String(msgId)]
|
|
151
|
+
await store.save()
|
|
152
|
+
},
|
|
153
|
+
/** @returns the send record holding a Delta Chat message id, or undefined. */
|
|
154
|
+
sentRecord(messageId) {
|
|
155
|
+
for (let index = state.sent.length - 1; index >= 0; index -= 1) {
|
|
156
|
+
if (state.sent[index].messageIds?.includes(messageId)) return state.sent[index]
|
|
157
|
+
}
|
|
158
|
+
return undefined
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
return store
|
|
162
|
+
}
|
package/src/text.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text going between DSH and Delta Chat: the answer a turn leaves, Markdown
|
|
3
|
+
* turned into readable plain text (Delta Chat apps show messages as plain
|
|
4
|
+
* text), splitting, and the silent marker.
|
|
5
|
+
*/
|
|
6
|
+
import { MAX_MESSAGE_TEXT, SILENT_REPLY } from './constants.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The answer one assistant message holds: the last text block of a message
|
|
10
|
+
* that called no tool. Text written between tool calls is working narration,
|
|
11
|
+
* not part of the answer; a message that called a tool is mid-chain.
|
|
12
|
+
* @param message - an assistant message (`{ content: [...] }`).
|
|
13
|
+
* @returns the answer, or '' when the message holds none.
|
|
14
|
+
*/
|
|
15
|
+
export function messageAnswer(message) {
|
|
16
|
+
const content = Array.isArray(message?.content) ? message.content : []
|
|
17
|
+
if (content.some((block) => block?.type === 'tool-call')) return ''
|
|
18
|
+
const texts = content
|
|
19
|
+
.filter((block) => block?.type === 'text' && typeof block.text === 'string')
|
|
20
|
+
.map((block) => block.text.trim())
|
|
21
|
+
.filter((text) => text.length > 0)
|
|
22
|
+
return texts.at(-1) ?? ''
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Remove the silent marker from the end of an answer.
|
|
27
|
+
* @param text - final answer.
|
|
28
|
+
* @returns `{ text, silent }`: the rest of the text and whether the marker was there.
|
|
29
|
+
*/
|
|
30
|
+
export function stripSilent(text) {
|
|
31
|
+
const trimmed = String(text ?? '').trim()
|
|
32
|
+
if (!trimmed.endsWith(SILENT_REPLY)) return { text: trimmed, silent: false }
|
|
33
|
+
return { text: trimmed.slice(0, -SILENT_REPLY.length).trim(), silent: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Local image paths a Markdown answer shows inline (`` or a
|
|
38
|
+
* `file://` URL), outside code. Delta Chat cannot show them inline, so they
|
|
39
|
+
* are sent as files after the text.
|
|
40
|
+
* @param markdown - answer text.
|
|
41
|
+
* @returns absolute paths, without duplicates.
|
|
42
|
+
*/
|
|
43
|
+
export function localImagePaths(markdown) {
|
|
44
|
+
const found = []
|
|
45
|
+
const pattern = /!\[[^\]]*\]\(\s*(?:<([^>\n]+)>|([^)\s]+))(?:\s+"[^"\n]*")?\s*\)/gu
|
|
46
|
+
let fenced = false
|
|
47
|
+
for (const line of String(markdown ?? '').split('\n')) {
|
|
48
|
+
if (/^\s*```/u.test(line)) {
|
|
49
|
+
fenced = !fenced
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
if (fenced) continue
|
|
53
|
+
for (const match of line.split(/`[^`]*`/gu).join(' ').matchAll(pattern)) {
|
|
54
|
+
let target = (match[1] ?? match[2]).trim()
|
|
55
|
+
if (/^file:\/\//iu.test(target)) {
|
|
56
|
+
try {
|
|
57
|
+
target = decodeURIComponent(new URL(target).pathname)
|
|
58
|
+
} catch {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (target.startsWith('/') && !found.includes(target)) found.push(target)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return found
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Inline Markdown of one line as plain text.
|
|
70
|
+
* @param line - text outside code fences.
|
|
71
|
+
* @returns the line without emphasis markers, with links spelled out.
|
|
72
|
+
*/
|
|
73
|
+
function plainInline(line) {
|
|
74
|
+
// Keep inline code as it is, without its backticks.
|
|
75
|
+
const parts = line.split(/(`[^`\n]+`)/gu)
|
|
76
|
+
return parts.map((part, index) => {
|
|
77
|
+
if (index % 2 === 1) return part.slice(1, -1)
|
|
78
|
+
let text = part
|
|
79
|
+
// Images: local ones are sent as files, remote ones keep their address.
|
|
80
|
+
text = text.replace(/!\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/gu, (_all, alt, target) => {
|
|
81
|
+
if (target.startsWith('/') || /^file:\/\//iu.test(target)) return alt.trim().length > 0 ? `[${alt.trim()}]` : ''
|
|
82
|
+
return alt.trim().length > 0 ? `${alt.trim()} (${target})` : target
|
|
83
|
+
})
|
|
84
|
+
text = text.replace(/\[([^\]]+)\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/gu, (_all, label, target) => {
|
|
85
|
+
const shown = label.trim()
|
|
86
|
+
return shown === target || shown === target.replace(/^mailto:/u, '') ? target : `${shown} (${target})`
|
|
87
|
+
})
|
|
88
|
+
text = text.replace(/<(https?:\/\/[^>\s]+)>/gu, '$1')
|
|
89
|
+
text = text.replace(/(\*\*|__)(?=\S)([^\n]*?\S)\1/gu, '$2')
|
|
90
|
+
text = text.replace(/(^|[\s(])\*(?=[^\s*])([^*\n]*?[^\s*])\*(?=$|[\s).,!?:;])/gu, '$1$2')
|
|
91
|
+
text = text.replace(/(^|[\s(])_(?=[^\s_])([^_\n]*?[^\s_])_(?=$|[\s).,!?:;])/gu, '$1$2')
|
|
92
|
+
text = text.replace(/~~(?=\S)([^\n]*?\S)~~/gu, '$1')
|
|
93
|
+
return text
|
|
94
|
+
}).join('')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Turn a Markdown answer into plain text that reads well in Delta Chat:
|
|
99
|
+
* emphasis markers and fence lines go, headings become plain lines, bullets
|
|
100
|
+
* become dots, links show their address, tables become rows of cells. Text
|
|
101
|
+
* inside code fences is kept exactly.
|
|
102
|
+
* @param markdown - answer text.
|
|
103
|
+
* @returns plain text.
|
|
104
|
+
*/
|
|
105
|
+
export function toPlainText(markdown) {
|
|
106
|
+
const out = []
|
|
107
|
+
let fenced = false
|
|
108
|
+
for (const raw of String(markdown ?? '').replace(/\r\n/gu, '\n').split('\n')) {
|
|
109
|
+
if (/^\s*(```|~~~)/u.test(raw)) {
|
|
110
|
+
fenced = !fenced
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (fenced) {
|
|
114
|
+
out.push(raw)
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
let line = raw
|
|
118
|
+
if (/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/u.test(line) && line.includes('|')) continue
|
|
119
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/u.test(line)) {
|
|
120
|
+
out.push('—')
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
const heading = line.match(/^\s{0,3}#{1,6}\s+(.*?)\s*#*\s*$/u)
|
|
124
|
+
if (heading !== null) {
|
|
125
|
+
out.push(plainInline(heading[1]))
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (/^\s*\|.*\|\s*$/u.test(line)) {
|
|
129
|
+
const cells = line.trim().slice(1, -1).split('|').map((cell) => plainInline(cell.trim()))
|
|
130
|
+
out.push(cells.join(' · '))
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
line = line.replace(/^(\s*)[-*+]\s+\[( |x|X)\]\s+/u, (_all, indent, mark) => `${indent}${mark === ' ' ? '☐' : '☑'} `)
|
|
134
|
+
line = line.replace(/^(\s*)[-*+]\s+/u, '$1• ')
|
|
135
|
+
out.push(plainInline(line))
|
|
136
|
+
}
|
|
137
|
+
return out.join('\n').replace(/\n{3,}/gu, '\n\n').trim()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Split text into messages no longer than the limit, preferring paragraph,
|
|
142
|
+
* then line, then word boundaries.
|
|
143
|
+
* @param text - plain text.
|
|
144
|
+
* @param limit - longest message.
|
|
145
|
+
* @returns non-empty parts.
|
|
146
|
+
*/
|
|
147
|
+
export function splitText(text, limit = MAX_MESSAGE_TEXT) {
|
|
148
|
+
const parts = []
|
|
149
|
+
let rest = String(text ?? '').trim()
|
|
150
|
+
while (rest.length > limit) {
|
|
151
|
+
const window = rest.slice(0, limit)
|
|
152
|
+
let cut = window.lastIndexOf('\n\n')
|
|
153
|
+
if (cut < limit / 2) cut = window.lastIndexOf('\n')
|
|
154
|
+
if (cut < limit / 2) cut = window.lastIndexOf(' ')
|
|
155
|
+
if (cut < limit / 2) cut = limit
|
|
156
|
+
parts.push(rest.slice(0, cut).trim())
|
|
157
|
+
rest = rest.slice(cut).trim()
|
|
158
|
+
}
|
|
159
|
+
if (rest.length > 0) parts.push(rest)
|
|
160
|
+
return parts
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Cut a preview to a length, on a word boundary when one is near.
|
|
165
|
+
* @param text - any text.
|
|
166
|
+
* @param max - longest result.
|
|
167
|
+
* @returns the preview, with an ellipsis when cut.
|
|
168
|
+
*/
|
|
169
|
+
export function clip(text, max) {
|
|
170
|
+
const value = String(text ?? '').trim()
|
|
171
|
+
if (value.length <= max) return value
|
|
172
|
+
const cut = value.slice(0, max - 1)
|
|
173
|
+
const space = cut.lastIndexOf(' ')
|
|
174
|
+
return `${space > max * 0.6 ? cut.slice(0, space) : cut}…`
|
|
175
|
+
}
|
package/src/titles.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session titles of chat sessions: `DeltaChat-<person>-<task>`. `person` is
|
|
3
|
+
* who the chat is with (a group's name for a group), `task` the conversation
|
|
4
|
+
* title DSH generates, or the chat's name until there is one. DSH has no title
|
|
5
|
+
* hook, so a generated title is appended again with the prefix and the same
|
|
6
|
+
* source, which keeps automatic titling going.
|
|
7
|
+
*/
|
|
8
|
+
import { readFileSync, statSync } from 'node:fs'
|
|
9
|
+
import { normalizeFingerprint } from './identity.js'
|
|
10
|
+
|
|
11
|
+
/** Title source marker shared with the other chat bridges' formats. */
|
|
12
|
+
export const TITLE_SOURCE = 'DeltaChat'
|
|
13
|
+
|
|
14
|
+
/** Words taken from the first message when DSH's fallback title is the sender line. */
|
|
15
|
+
const FALLBACK_WORDS = 8
|
|
16
|
+
|
|
17
|
+
/** @returns one line of text, without the separator dashes doubling up. */
|
|
18
|
+
function clean(value) {
|
|
19
|
+
return String(value ?? '').replace(/\s+/gu, ' ').trim()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The full title.
|
|
24
|
+
* @param person - who the chat is with.
|
|
25
|
+
* @param task - conversation title.
|
|
26
|
+
*/
|
|
27
|
+
export function chatTitle(person, task) {
|
|
28
|
+
return `${TITLE_SOURCE}-${clean(person) || 'unknown'}-${clean(task)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The conversation part of a title, without a `DeltaChat-<person>-` prefix.
|
|
33
|
+
* @param title - current title.
|
|
34
|
+
* @param persons - names the prefix may carry (the current one and earlier ones).
|
|
35
|
+
*/
|
|
36
|
+
export function taskOf(title, persons) {
|
|
37
|
+
const value = clean(title)
|
|
38
|
+
for (const person of persons) {
|
|
39
|
+
if (person === undefined || person === null) continue
|
|
40
|
+
const prefix = `${TITLE_SOURCE}-${clean(person) || 'unknown'}-`
|
|
41
|
+
if (value.startsWith(prefix)) return value.slice(prefix.length)
|
|
42
|
+
}
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A conversation title from the first message of a session, used when DSH's
|
|
48
|
+
* fallback title is the bot's sender line.
|
|
49
|
+
* @param events - session events.
|
|
50
|
+
* @returns a few words of the first message's own text, or ''.
|
|
51
|
+
*/
|
|
52
|
+
export function firstMessageTask(events) {
|
|
53
|
+
for (const event of Array.isArray(events) ? events : []) {
|
|
54
|
+
if (event?.type !== 'user/message' || event.data?.source?.kind !== 'user') continue
|
|
55
|
+
const text = (event.data.content ?? []).filter((block) => block?.type === 'text').map((block) => block.text).join('\n')
|
|
56
|
+
const body = text.split('\n').filter((line) => !/^\[[^\]]*\]$/u.test(line.trim())).join(' ')
|
|
57
|
+
const words = clean(body).split(' ').filter(Boolean)
|
|
58
|
+
if (words.length > 0) return words.slice(0, FALLBACK_WORDS).join(' ')
|
|
59
|
+
}
|
|
60
|
+
return ''
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Names for fingerprints from a JSON file with a `deltachat_keys` object, read
|
|
65
|
+
* again when the file changes.
|
|
66
|
+
* @returns `nameOf(path, fingerprint)`.
|
|
67
|
+
*/
|
|
68
|
+
export function createPeopleFile() {
|
|
69
|
+
let cached = { path: '', mtime: 0, keys: {} }
|
|
70
|
+
return {
|
|
71
|
+
nameOf(path, fingerprint) {
|
|
72
|
+
if (typeof path !== 'string' || path.length === 0) return undefined
|
|
73
|
+
try {
|
|
74
|
+
const mtime = statSync(path).mtimeMs
|
|
75
|
+
if (cached.path !== path || cached.mtime !== mtime) {
|
|
76
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
77
|
+
const keys = {}
|
|
78
|
+
for (const [key, name] of Object.entries(parsed?.deltachat_keys ?? {})) {
|
|
79
|
+
const normalized = normalizeFingerprint(key)
|
|
80
|
+
if (normalized && typeof name === 'string') keys[normalized] = name
|
|
81
|
+
}
|
|
82
|
+
cached = { path, mtime, keys }
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
return cached.keys[normalizeFingerprint(fingerprint)]
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keep a bound session's title in the chat format.
|
|
94
|
+
* @param options - `{ service, session, person, earlier, placeholder, previous, warn }`:
|
|
95
|
+
* the title service, the live session, who the chat is with, names an older
|
|
96
|
+
* title may carry instead, the task shown before a conversation title
|
|
97
|
+
* exists, and the placeholder title the bot set earlier (so a renamed chat
|
|
98
|
+
* moves it).
|
|
99
|
+
* @returns the placeholder title now set by the bot, or `previous`.
|
|
100
|
+
*/
|
|
101
|
+
export function syncTitle({ service, session, person, earlier = [], placeholder, previous, warn }) {
|
|
102
|
+
if (service === undefined || session === undefined) return previous
|
|
103
|
+
let current
|
|
104
|
+
try {
|
|
105
|
+
current = service.get(session)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
warn?.(`title not read for ${session.id}: ${error?.message ?? error}`)
|
|
108
|
+
return previous
|
|
109
|
+
}
|
|
110
|
+
const placeholderTitle = chatTitle(person, placeholder)
|
|
111
|
+
// A title someone typed stays; only the bot's own placeholder follows the chat.
|
|
112
|
+
if (current === undefined || (current.source?.kind === 'user' && current.title === previous && previous !== placeholderTitle)) {
|
|
113
|
+
service.rename(session, placeholderTitle)
|
|
114
|
+
return placeholderTitle
|
|
115
|
+
}
|
|
116
|
+
if (current.source?.kind === 'user') return previous
|
|
117
|
+
let task = taskOf(current.title, [person, ...earlier])
|
|
118
|
+
if (current.source?.kind === 'fallback' && /^\[Delta Chat message from/u.test(task)) {
|
|
119
|
+
task = firstMessageTask(typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : []) || placeholder
|
|
120
|
+
}
|
|
121
|
+
const wanted = chatTitle(person, task)
|
|
122
|
+
if (wanted !== current.title) {
|
|
123
|
+
session.append('session/title', {
|
|
124
|
+
title: wanted,
|
|
125
|
+
messageSeqs: Array.isArray(current.messageSeqs) ? [...current.messageSeqs] : [],
|
|
126
|
+
source: current.source,
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
return previous
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a session still shows the bot's placeholder, so its first
|
|
134
|
+
* conversation title should be generated.
|
|
135
|
+
* @param service - title service.
|
|
136
|
+
* @param session - live session.
|
|
137
|
+
* @param previous - the placeholder title the bot set.
|
|
138
|
+
*/
|
|
139
|
+
export function showsPlaceholder(service, session, previous) {
|
|
140
|
+
try {
|
|
141
|
+
const current = service.get(session)
|
|
142
|
+
return current?.source?.kind === 'user' && current.title === previous
|
|
143
|
+
} catch {
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/turns.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn delivery: follow every session bound to a chat, collect each turn's
|
|
3
|
+
* answer, send it when the turn ends, then clear the working reactions of
|
|
4
|
+
* its messages.
|
|
5
|
+
*/
|
|
6
|
+
import { basename } from 'node:path'
|
|
7
|
+
import { REQUEST_PREFIX } from './constants.js'
|
|
8
|
+
import { clip, localImagePaths, messageAnswer, stripSilent } from './text.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Create the turn tracker.
|
|
12
|
+
* @param deps - `{ ctx, getStore, isRunning, setting, outbound, reactions, chatState, publishStatus, fireAndForget, messageOf }`.
|
|
13
|
+
* @returns `{ onSessionEvent, currentTurn }`.
|
|
14
|
+
*/
|
|
15
|
+
export function createTurns({ ctx, getStore, isRunning, setting, outbound, reactions, chatState, publishStatus, fireAndForget, messageOf }) {
|
|
16
|
+
const { queueOut, notice, sendText, sendFiles, checkFiles } = outbound
|
|
17
|
+
/**
|
|
18
|
+
* Turns of bound sessions by session id: `{ current, turns: Map<turn, record>,
|
|
19
|
+
* pendingOurs, pendingIds, lastOurs }`; `pendingIds` are Delta Chat messages
|
|
20
|
+
* taken before their turn started, `lastOurs` whether the last turn that
|
|
21
|
+
* ended was started from Delta Chat.
|
|
22
|
+
*/
|
|
23
|
+
const sessionTurns = new Map()
|
|
24
|
+
|
|
25
|
+
/** @returns the turn bookkeeping of a session. */
|
|
26
|
+
function turnsOf(sessionId) {
|
|
27
|
+
let entry = sessionTurns.get(sessionId)
|
|
28
|
+
if (entry === undefined) {
|
|
29
|
+
entry = { current: undefined, turns: new Map(), pendingOurs: undefined, pendingIds: [], lastOurs: false }
|
|
30
|
+
sessionTurns.set(sessionId, entry)
|
|
31
|
+
}
|
|
32
|
+
return entry
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @returns the key that ties a turn's messages to it. */
|
|
36
|
+
function turnKey(sessionId, turn) {
|
|
37
|
+
return `${sessionId}\u0000${turn}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @returns the record of a session's running turn, or undefined. */
|
|
41
|
+
function currentTurn(sessionId) {
|
|
42
|
+
const entry = sessionTurns.get(sessionId)
|
|
43
|
+
return entry?.current === undefined ? undefined : entry.turns.get(entry.current)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Follow every session bound to a chat: collect each turn's answer and send
|
|
48
|
+
* it when the turn ends. Turns started from Delta Chat are always delivered;
|
|
49
|
+
* turns started elsewhere (Web UI, schedules) when `mirrorTurns` is on.
|
|
50
|
+
*/
|
|
51
|
+
function onSessionEvent(session, event) {
|
|
52
|
+
const store = getStore()
|
|
53
|
+
if (store === undefined || !isRunning()) return
|
|
54
|
+
const sessionId = session?.id
|
|
55
|
+
const chatId = store.chatOfSession(sessionId)
|
|
56
|
+
if (chatId === undefined) return
|
|
57
|
+
const entry = turnsOf(sessionId)
|
|
58
|
+
const mirror = setting('mirrorTurns', true) === true
|
|
59
|
+
if (event.type === 'turn/start') {
|
|
60
|
+
const record = { texts: [], sentTexts: [], ours: entry.pendingOurs !== undefined, lastPrompt: entry.pendingOurs, goal: false, mirrored: mirror, sent: false, stopped: false }
|
|
61
|
+
entry.pendingOurs = undefined
|
|
62
|
+
entry.current = event.data?.turn
|
|
63
|
+
entry.turns.set(event.data?.turn, record)
|
|
64
|
+
for (const messageId of entry.pendingIds) reactions.attach(messageId, turnKey(sessionId, event.data?.turn))
|
|
65
|
+
entry.pendingIds = []
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
const record = entry.current === undefined ? undefined : entry.turns.get(entry.current)
|
|
69
|
+
if (event.type === 'user/message') {
|
|
70
|
+
const source = event.data?.source
|
|
71
|
+
const rpcId = typeof source?.rpcId === 'string' ? source.rpcId : ''
|
|
72
|
+
if (source?.kind === 'user' && rpcId.startsWith(REQUEST_PREFIX)) {
|
|
73
|
+
const messageId = Number(rpcId.slice(rpcId.lastIndexOf(':') + 1))
|
|
74
|
+
if (record === undefined) {
|
|
75
|
+
entry.pendingOurs = messageId
|
|
76
|
+
entry.pendingIds.push(messageId)
|
|
77
|
+
} else {
|
|
78
|
+
record.ours = true
|
|
79
|
+
record.lastPrompt = messageId
|
|
80
|
+
reactions.attach(messageId, turnKey(sessionId, entry.current))
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (rpcId.startsWith('deltachat-claim:')) return
|
|
85
|
+
// A helper's report wakes its parent: that turn answers the chat the delegating turn came from.
|
|
86
|
+
if ((source?.kind === 'subagent-settled' || source?.kind === 'agent-message') && record !== undefined) {
|
|
87
|
+
if (entry.lastOurs) record.ours = true
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (source?.kind === 'goal' && record !== undefined) record.goal = true
|
|
91
|
+
if (!mirror) return
|
|
92
|
+
const label = source?.kind === 'schedule' ? '⏰ Scheduled task' : source?.kind !== 'user' ? undefined : rpcId.startsWith('telegram') ? '✈️ From Telegram' : '🖥 From Web'
|
|
93
|
+
if (label === undefined) return
|
|
94
|
+
const content = Array.isArray(event.data?.content) ? event.data.content : []
|
|
95
|
+
const text = content.filter((block) => block?.type === 'text').map((block) => block.text).join('\n').trim()
|
|
96
|
+
const files = content.filter((block) => block?.type === 'image' || block?.type === 'file').length
|
|
97
|
+
if (text.length === 0 && files === 0) return
|
|
98
|
+
notice(chatId, `${label}:\n${clip(text, 3000)}${files === 0 ? '' : `\n📎 ${files}`}`)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (event.type === 'assistant/message') {
|
|
102
|
+
const turn = entry.turns.get(event.data?.turn) ?? record
|
|
103
|
+
const answer = messageAnswer(event.data?.message ?? event.data)
|
|
104
|
+
if (turn !== undefined && answer.length > 0) turn.texts.push(answer)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
if (event.type === 'deliverables/presented') {
|
|
108
|
+
const turn = entry.turns.get(event.data?.turn) ?? record
|
|
109
|
+
if (turn !== undefined && !(turn.ours || turn.goal || turn.mirrored)) return
|
|
110
|
+
const paths = (Array.isArray(event.data?.files) ? event.data.files : []).map((file) => file?.path).filter((path) => typeof path === 'string')
|
|
111
|
+
if (paths.length === 0) return
|
|
112
|
+
if (turn !== undefined) turn.sent = true
|
|
113
|
+
void queueOut(chatId, () => forwardFiles(chatId, paths))
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
if (event.type === 'goal/change') {
|
|
117
|
+
fireAndForget('goal-change', () => announceGoal(chatId, sessionId))
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (event.type === 'turn/end') {
|
|
121
|
+
const turn = event.data?.turn
|
|
122
|
+
const finished = entry.turns.get(turn)
|
|
123
|
+
if (finished !== undefined) entry.lastOurs = finished.ours
|
|
124
|
+
entry.turns.delete(turn)
|
|
125
|
+
if (entry.current === turn) entry.current = undefined
|
|
126
|
+
// The reactions go once the answer is out, also when the turn failed or was stopped.
|
|
127
|
+
void queueOut(chatId, async () => {
|
|
128
|
+
try {
|
|
129
|
+
if (finished !== undefined) await deliverTurn(chatId, finished, event.data?.reason)
|
|
130
|
+
} finally {
|
|
131
|
+
await reactions.finishTurn(chatId, turnKey(sessionId, turn))
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Send files a session showed its user, with a note for those that cannot go. */
|
|
138
|
+
async function forwardFiles(chatId, paths, quiet = false) {
|
|
139
|
+
const failed = []
|
|
140
|
+
for (const path of paths) {
|
|
141
|
+
try {
|
|
142
|
+
await sendFiles(chatId, await checkFiles([path]))
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (!quiet) failed.push(`${basename(path)}: ${messageOf(error)}`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (failed.length > 0) await sendText(chatId, `⚠️ Could not send:\n${failed.join('\n')}`, { plain: true })
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Send a finished turn's answer into its chat. */
|
|
151
|
+
async function deliverTurn(chatId, turn, reason) {
|
|
152
|
+
if (!(turn.ours || turn.goal || turn.mirrored)) return
|
|
153
|
+
if (turn.stopped || reason?.kind === 'aborted' || reason?.kind === 'blocked') return
|
|
154
|
+
// Text the session already sent here with its files is not repeated.
|
|
155
|
+
const sent = new Set(turn.sentTexts.map(sameText))
|
|
156
|
+
const { text, silent } = stripSilent(turn.texts.filter((part) => !sent.has(sameText(part))).join('\n\n'))
|
|
157
|
+
if (reason?.kind === 'error' && text.length === 0) {
|
|
158
|
+
await sendText(chatId, `❌ ${reason.error?.message ?? 'The turn failed.'}`, { plain: true })
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
if (text.length > 0) {
|
|
162
|
+
const chat = chatState(chatId)
|
|
163
|
+
// Quote the prompt when the chat moved on since it was sent.
|
|
164
|
+
const quote = turn.ours && Number.isSafeInteger(turn.lastPrompt) && chat.lastInbound !== turn.lastPrompt ? turn.lastPrompt : undefined
|
|
165
|
+
await sendText(chatId, text, { quote })
|
|
166
|
+
const images = localImagePaths(text)
|
|
167
|
+
if (images.length > 0) await forwardFiles(chatId, images, true)
|
|
168
|
+
await publishStatus({ lastReplyAt: Date.now() })
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
if (turn.ours && !silent && !turn.sent && reason?.kind !== 'error') await sendText(chatId, '✅ Done.', { plain: true })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @returns text reduced to its words, for comparing an answer with a send. */
|
|
175
|
+
function sameText(text) {
|
|
176
|
+
return String(text).replace(/\s+/gu, ' ').trim()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Tell the chat when its session's goal changes phase. */
|
|
180
|
+
async function announceGoal(chatId, sessionId) {
|
|
181
|
+
const goals = ctx.get('goals')
|
|
182
|
+
const agent = ctx.get('agents')?.get(sessionId)
|
|
183
|
+
if (goals === undefined || agent === undefined) return
|
|
184
|
+
const view = goals.get(agent)
|
|
185
|
+
const chat = chatState(chatId)
|
|
186
|
+
const phase = view === undefined ? 'none' : view.phase
|
|
187
|
+
if (chat.goalPhase === phase) return
|
|
188
|
+
const previous = chat.goalPhase
|
|
189
|
+
chat.goalPhase = phase
|
|
190
|
+
if (previous === undefined) return
|
|
191
|
+
if (phase === 'complete') await notice(chatId, `🎯 Goal complete: ${clip(view.objective, 300)}`)
|
|
192
|
+
else if (phase === 'blocked') await notice(chatId, `🎯 Goal blocked: ${clip(view.objective, 300)}`)
|
|
193
|
+
else if (phase === 'paused') await notice(chatId, '🎯 Goal paused. /goal resume continues it.')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return { onSessionEvent, currentTurn }
|
|
197
|
+
}
|