@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/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 (`![alt](/abs/path)` 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
+ }