@soffits/dsh-telegram-bot 0.0.0-stage → 0.4.1
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/CREDITS.md +24 -0
- package/LICENSE +22 -0
- package/MAINTAINING.md +39 -0
- package/README.md +293 -2
- package/bin/dsh-telegram-send.js +110 -0
- package/client.js +809 -0
- package/cordis.patch.yml +8 -0
- package/docs/bot-setting.gif +0 -0
- package/icon.svg +12 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/ru.json +6 -0
- package/package.json +72 -4
- package/src/admission.js +21 -0
- package/src/app.js +6371 -0
- package/src/client.js +809 -0
- package/src/config.js +73 -0
- package/src/constants.js +80 -0
- package/src/errors.js +16 -0
- package/src/final-text.js +45 -0
- package/src/formatting.js +390 -0
- package/src/index.js +24 -0
- package/src/message-context.js +339 -0
- package/src/models.js +242 -0
- package/src/outbox.js +160 -0
- package/src/sent-log.js +112 -0
- package/src/state.js +49 -0
- package/src/status.js +43 -0
- package/src/steer.js +104 -0
- package/src/stickers.js +185 -0
- package/src/topics.js +211 -0
- package/src/update-ledger.js +58 -0
- package/src/utils.js +139 -0
- package/src/voice.js +151 -0
package/src/outbox.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File spool for sending to topics from outside the Host process.
|
|
3
|
+
*
|
|
4
|
+
* A sender writes one request file into the outbox directory; the running
|
|
5
|
+
* plugin picks it up, sends it, and writes a result file next to it. Requests
|
|
6
|
+
* wait in the spool while the bot is stopped, and senders need no bot token
|
|
7
|
+
* or network access to the Host: only write access to the DSH home.
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { watch } from 'node:fs'
|
|
11
|
+
import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { STATUS_DIR } from './constants.js'
|
|
14
|
+
|
|
15
|
+
/** Default spool directory. */
|
|
16
|
+
export const OUTBOX_DIR = join(STATUS_DIR, 'outbox')
|
|
17
|
+
/** Result files older than this are removed by the plugin. */
|
|
18
|
+
const RESULT_TTL_MS = 24 * 60 * 60 * 1000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Write one send request atomically.
|
|
22
|
+
* @param request - `{ to, text, files, from }`, as for the `telegramTopics` service's `send`.
|
|
23
|
+
* @param dir - spool directory.
|
|
24
|
+
* @returns the request id.
|
|
25
|
+
*/
|
|
26
|
+
export async function enqueue(request, dir = OUTBOX_DIR) {
|
|
27
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
28
|
+
const id = `${Date.now()}-${randomUUID().slice(0, 8)}`
|
|
29
|
+
const temporary = join(dir, `${id}.tmp`)
|
|
30
|
+
await writeFile(temporary, JSON.stringify({ ...request, id, createdAt: Date.now() }) + '\n', { mode: 0o600 })
|
|
31
|
+
await rename(temporary, join(dir, `${id}.json`))
|
|
32
|
+
return id
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wait for the plugin's result for one request.
|
|
37
|
+
* @param id - request id from {@link enqueue}.
|
|
38
|
+
* @param timeoutMs - how long to wait.
|
|
39
|
+
* @param dir - spool directory.
|
|
40
|
+
* @returns the result document, or undefined after the timeout.
|
|
41
|
+
*/
|
|
42
|
+
export async function waitForResult(id, timeoutMs, dir = OUTBOX_DIR) {
|
|
43
|
+
const path = join(dir, `${id}.result.json`)
|
|
44
|
+
const deadline = Date.now() + timeoutMs
|
|
45
|
+
for (;;) {
|
|
46
|
+
try {
|
|
47
|
+
const result = JSON.parse(await readFile(path, 'utf8'))
|
|
48
|
+
await rm(path, { force: true })
|
|
49
|
+
return result
|
|
50
|
+
} catch {
|
|
51
|
+
// Not written yet.
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() >= deadline) return undefined
|
|
54
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Plugin side: process spooled requests one at a time.
|
|
60
|
+
* @param options.handle - async function sending one request; its value is the result.
|
|
61
|
+
* @param options.ready - whether requests can be sent now (the bot is running).
|
|
62
|
+
* @param options.onError - reports unexpected spool failures.
|
|
63
|
+
* @param options.dir - spool directory.
|
|
64
|
+
* @param options.intervalMs - rescan period; file watching only speeds it up.
|
|
65
|
+
* @returns `{ start, stop, drain }`.
|
|
66
|
+
*/
|
|
67
|
+
export function createOutbox({ handle, ready, onError, dir = OUTBOX_DIR, intervalMs = 1000 }) {
|
|
68
|
+
let timer
|
|
69
|
+
let watcher
|
|
70
|
+
let draining
|
|
71
|
+
let again = false
|
|
72
|
+
|
|
73
|
+
/** Put requests that were being sent when the Host stopped back in line. */
|
|
74
|
+
async function recover() {
|
|
75
|
+
for (const name of await readdir(dir).catch(() => [])) {
|
|
76
|
+
if (name.endsWith('.processing')) {
|
|
77
|
+
await rename(join(dir, name), join(dir, name.replace(/\.processing$/u, '.json'))).catch(() => {})
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Remove result files nobody collected. */
|
|
83
|
+
async function sweep(names) {
|
|
84
|
+
for (const name of names) {
|
|
85
|
+
if (!name.endsWith('.result.json')) continue
|
|
86
|
+
const path = join(dir, name)
|
|
87
|
+
const info = await stat(path).catch(() => undefined)
|
|
88
|
+
if (info !== undefined && Date.now() - info.mtimeMs > RESULT_TTL_MS) await rm(path, { force: true })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Send every waiting request, oldest first. */
|
|
93
|
+
async function drainOnce() {
|
|
94
|
+
if (!ready()) return
|
|
95
|
+
const names = (await readdir(dir).catch(() => [])).sort()
|
|
96
|
+
await sweep(names)
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
if (!name.endsWith('.json') || name.endsWith('.result.json')) continue
|
|
99
|
+
if (!ready()) return
|
|
100
|
+
const id = name.slice(0, -'.json'.length)
|
|
101
|
+
const claimed = join(dir, `${id}.processing`)
|
|
102
|
+
try {
|
|
103
|
+
await rename(join(dir, name), claimed)
|
|
104
|
+
} catch {
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
let outcome
|
|
108
|
+
try {
|
|
109
|
+
const request = JSON.parse(await readFile(claimed, 'utf8'))
|
|
110
|
+
outcome = { ok: true, result: await handle(request) }
|
|
111
|
+
} catch (error) {
|
|
112
|
+
outcome = { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await writeFile(join(dir, `${id}.result.json`), JSON.stringify(outcome) + '\n', { mode: 0o600 })
|
|
116
|
+
await rm(claimed, { force: true })
|
|
117
|
+
} catch (error) {
|
|
118
|
+
onError(error)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Run one drain, coalescing triggers that arrive while it runs. */
|
|
124
|
+
function drain() {
|
|
125
|
+
if (draining !== undefined) {
|
|
126
|
+
again = true
|
|
127
|
+
return draining
|
|
128
|
+
}
|
|
129
|
+
draining = (async () => {
|
|
130
|
+
do {
|
|
131
|
+
again = false
|
|
132
|
+
await drainOnce()
|
|
133
|
+
} while (again)
|
|
134
|
+
})().catch(onError).finally(() => { draining = undefined })
|
|
135
|
+
return draining
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function start() {
|
|
139
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
140
|
+
await recover()
|
|
141
|
+
try {
|
|
142
|
+
watcher = watch(dir, () => { void drain() })
|
|
143
|
+
watcher.on('error', () => {})
|
|
144
|
+
} catch {
|
|
145
|
+
// The interval still picks requests up.
|
|
146
|
+
}
|
|
147
|
+
timer = setInterval(() => { void drain() }, intervalMs)
|
|
148
|
+
timer.unref?.()
|
|
149
|
+
void drain()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stop() {
|
|
153
|
+
if (timer !== undefined) clearInterval(timer)
|
|
154
|
+
timer = undefined
|
|
155
|
+
watcher?.close()
|
|
156
|
+
watcher = undefined
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return { start, stop, drain }
|
|
160
|
+
}
|
package/src/sent-log.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remember messages sent into topics through the plugin (the Host service,
|
|
3
|
+
* the `telegram_send` tool and the `dsh-telegram-send` spool), so a later
|
|
4
|
+
* reply to one of them can tell the agent what was sent, when and by whom,
|
|
5
|
+
* even when the topic's session has changed since.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { STATUS_DIR } from './constants.js'
|
|
10
|
+
|
|
11
|
+
/** Default file of the log. */
|
|
12
|
+
export const SENT_LOG_PATH = join(STATUS_DIR, 'sent-messages.json')
|
|
13
|
+
/** Most sends kept; older ones are dropped first. */
|
|
14
|
+
export const SENT_LOG_MAX_ENTRIES = 1000
|
|
15
|
+
/** Sends older than this are dropped. */
|
|
16
|
+
export const SENT_LOG_MAX_AGE_MS = 180 * 24 * 60 * 60 * 1000
|
|
17
|
+
/** Longest text kept per send, in characters. */
|
|
18
|
+
export const SENT_LOG_MAX_TEXT = 20_000
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Open the log. Reading happens once, on first use; a missing or broken file
|
|
22
|
+
* starts an empty log.
|
|
23
|
+
* @param options.path - log file.
|
|
24
|
+
* @param options.now - clock, for tests.
|
|
25
|
+
* @param options.onError - reports a failed write.
|
|
26
|
+
* @returns `{ record, find }`.
|
|
27
|
+
*/
|
|
28
|
+
export function createSentLog({ path = SENT_LOG_PATH, now = Date.now, onError = () => {} } = {}) {
|
|
29
|
+
let entries
|
|
30
|
+
let loading
|
|
31
|
+
let writing = Promise.resolve()
|
|
32
|
+
|
|
33
|
+
async function load() {
|
|
34
|
+
if (entries !== undefined) return entries
|
|
35
|
+
loading ??= (async () => {
|
|
36
|
+
try {
|
|
37
|
+
const data = JSON.parse(await readFile(path, 'utf8'))
|
|
38
|
+
entries = Array.isArray(data?.entries) ? data.entries.filter((entry) => entry !== null && typeof entry === 'object') : []
|
|
39
|
+
} catch {
|
|
40
|
+
entries = []
|
|
41
|
+
}
|
|
42
|
+
return entries
|
|
43
|
+
})()
|
|
44
|
+
return loading
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function prune() {
|
|
48
|
+
const oldest = now() - SENT_LOG_MAX_AGE_MS
|
|
49
|
+
entries = entries.filter((entry) => Date.parse(entry.sentAt ?? '') >= oldest)
|
|
50
|
+
if (entries.length > SENT_LOG_MAX_ENTRIES) entries = entries.slice(-SENT_LOG_MAX_ENTRIES)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function save() {
|
|
54
|
+
const snapshot = JSON.stringify({ version: 1, entries }) + '\n'
|
|
55
|
+
writing = writing.then(async () => {
|
|
56
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
57
|
+
const temporary = `${path}.tmp`
|
|
58
|
+
await writeFile(temporary, snapshot, { mode: 0o600 })
|
|
59
|
+
await rename(temporary, path)
|
|
60
|
+
}).catch(onError)
|
|
61
|
+
return writing
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
/**
|
|
66
|
+
* Remember one send.
|
|
67
|
+
* @param entry - `{ chatId, threadId, topic, messageIds, text, files,
|
|
68
|
+
* sticker, fromSession, job, label, boundSession, recorded }`;
|
|
69
|
+
* `sticker` is `{ id, emoji, setName }` when one was sent.
|
|
70
|
+
*/
|
|
71
|
+
async record(entry) {
|
|
72
|
+
await load()
|
|
73
|
+
const text = typeof entry.text === 'string' ? entry.text : ''
|
|
74
|
+
entries.push({
|
|
75
|
+
chatId: entry.chatId,
|
|
76
|
+
threadId: entry.threadId ?? null,
|
|
77
|
+
topic: entry.topic ?? null,
|
|
78
|
+
messageIds: Array.isArray(entry.messageIds) ? entry.messageIds : [],
|
|
79
|
+
sentAt: new Date(now()).toISOString(),
|
|
80
|
+
text: text.length > SENT_LOG_MAX_TEXT ? text.slice(0, SENT_LOG_MAX_TEXT) : text,
|
|
81
|
+
textCut: text.length > SENT_LOG_MAX_TEXT ? text.length - SENT_LOG_MAX_TEXT : 0,
|
|
82
|
+
files: Array.isArray(entry.files) ? entry.files : [],
|
|
83
|
+
...(entry.sticker === undefined || entry.sticker === null ? {} : { sticker: entry.sticker }),
|
|
84
|
+
fromSession: entry.fromSession ?? null,
|
|
85
|
+
job: entry.job ?? null,
|
|
86
|
+
label: entry.label ?? null,
|
|
87
|
+
boundSession: entry.boundSession ?? null,
|
|
88
|
+
recorded: entry.recorded === true,
|
|
89
|
+
})
|
|
90
|
+
prune()
|
|
91
|
+
await save()
|
|
92
|
+
},
|
|
93
|
+
/**
|
|
94
|
+
* Find the send one Telegram message belongs to.
|
|
95
|
+
* @param chatId - chat of the message.
|
|
96
|
+
* @param messageId - its id.
|
|
97
|
+
* @returns the entry, or undefined.
|
|
98
|
+
*/
|
|
99
|
+
async find(chatId, messageId) {
|
|
100
|
+
if (chatId === undefined || chatId === null || !Number.isSafeInteger(messageId)) return undefined
|
|
101
|
+
await load()
|
|
102
|
+
const chat = String(chatId)
|
|
103
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
104
|
+
const entry = entries[index]
|
|
105
|
+
if (String(entry.chatId) === chat && entry.messageIds.includes(messageId)) return entry
|
|
106
|
+
}
|
|
107
|
+
return undefined
|
|
108
|
+
},
|
|
109
|
+
/** Wait for pending writes (tests and shutdown). */
|
|
110
|
+
flush: () => writing,
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Durable Telegram state file. */
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { STATUS_DIR } from './constants.js'
|
|
5
|
+
|
|
6
|
+
/** Durable Telegram chat → DSH session mapping. */
|
|
7
|
+
const STATE_PATH = join(STATUS_DIR, 'state.json')
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drop the model copies older versions kept on session records. The session
|
|
11
|
+
* itself records its model; a copy here went stale and was read back as truth.
|
|
12
|
+
* @param sessions - session records by id.
|
|
13
|
+
* @returns the records without `provider`, `model` and `effort`.
|
|
14
|
+
*/
|
|
15
|
+
function withoutModelCopies(sessions) {
|
|
16
|
+
return Object.fromEntries(Object.entries(sessions).map(([id, record]) => {
|
|
17
|
+
if (record === null || typeof record !== 'object') return [id, record]
|
|
18
|
+
const { provider: _provider, model: _model, effort: _effort, ...rest } = record
|
|
19
|
+
return [id, rest]
|
|
20
|
+
}))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the chat/session mapping. A missing or malformed file starts empty.
|
|
25
|
+
* @returns the mapping document.
|
|
26
|
+
*/
|
|
27
|
+
async function loadState() {
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(await readFile(STATE_PATH, 'utf8'))
|
|
30
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
31
|
+
return {
|
|
32
|
+
version: 2,
|
|
33
|
+
sessions: parsed.sessions !== null && typeof parsed.sessions === 'object' ? withoutModelCopies(parsed.sessions) : {},
|
|
34
|
+
threads: parsed.threads !== null && typeof parsed.threads === 'object' ? parsed.threads : {},
|
|
35
|
+
updateLedger: parsed.updateLedger !== null && typeof parsed.updateLedger === 'object' && !Array.isArray(parsed.updateLedger) ? parsed.updateLedger : {},
|
|
36
|
+
updateOffset: Number.isSafeInteger(parsed.updateOffset) && parsed.updateOffset >= 0 ? parsed.updateOffset : 0,
|
|
37
|
+
topicNames: parsed.topicNames !== null && typeof parsed.topicNames === 'object' && !Array.isArray(parsed.topicNames) ? parsed.topicNames : {},
|
|
38
|
+
directChats: parsed.directChats !== null && typeof parsed.directChats === 'object' && !Array.isArray(parsed.directChats) ? parsed.directChats : {},
|
|
39
|
+
directTopics: parsed.directTopics !== null && typeof parsed.directTopics === 'object' && !Array.isArray(parsed.directTopics) ? parsed.directTopics : {},
|
|
40
|
+
goalPanels: parsed.goalPanels !== null && typeof parsed.goalPanels === 'object' && !Array.isArray(parsed.goalPanels) ? parsed.goalPanels : (parsed.goalPins !== null && typeof parsed.goalPins === 'object' && !Array.isArray(parsed.goalPins) ? parsed.goalPins : {}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Missing or corrupt state is nonfatal; the next session creates a mapping.
|
|
45
|
+
}
|
|
46
|
+
return { version: 2, sessions: {}, threads: {}, updateLedger: {}, updateOffset: 0, topicNames: {}, directChats: {}, directTopics: {}, goalPanels: {} }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export { STATE_PATH, loadState }
|
package/src/status.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Local runtime diagnostics snapshot. */
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { STATUS_DIR } from './constants.js'
|
|
5
|
+
|
|
6
|
+
/** Last published runtime snapshot, mirrored to a local diagnostics file. */
|
|
7
|
+
let runtimeStatus = {
|
|
8
|
+
state: 'starting',
|
|
9
|
+
detail: '',
|
|
10
|
+
username: '',
|
|
11
|
+
updatedAt: new Date().toISOString(),
|
|
12
|
+
transport: 'https-ipv4',
|
|
13
|
+
version: '0.3.0',
|
|
14
|
+
agentBridge: 'v1',
|
|
15
|
+
codeRevision: 'upstream-0.3.0-merge',
|
|
16
|
+
lastPollAt: 0,
|
|
17
|
+
lastMessageAt: 0,
|
|
18
|
+
lastChatId: null,
|
|
19
|
+
lastFromId: null,
|
|
20
|
+
lastUpdateType: null,
|
|
21
|
+
lastUpdateAt: 0,
|
|
22
|
+
lastOutcome: null,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Persist a local runtime snapshot for diagnostics. This file is intentionally
|
|
27
|
+
* separate from the user settings: status changes must never rewrite the
|
|
28
|
+
* profile patch or trigger a configuration reload.
|
|
29
|
+
* @param patch - fields to merge into the current snapshot.
|
|
30
|
+
* @returns resolves after the best-effort write.
|
|
31
|
+
*/
|
|
32
|
+
async function publishStatus(patch) {
|
|
33
|
+
runtimeStatus = { ...runtimeStatus, ...patch, updatedAt: new Date().toISOString() }
|
|
34
|
+
try {
|
|
35
|
+
await mkdir(STATUS_DIR, { recursive: true })
|
|
36
|
+
await writeFile(join(STATUS_DIR, 'status.json'), JSON.stringify(runtimeStatus, null, 2) + '\n', { mode: 0o600 })
|
|
37
|
+
} catch {
|
|
38
|
+
// Diagnostics must never take the runtime down.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
export { runtimeStatus, publishStatus }
|
package/src/steer.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steering: a Telegram message sent while the bound session's turn runs goes
|
|
3
|
+
* into that turn, the way the DSH Web UI sends a message during a turn. DSH
|
|
4
|
+
* delivers it at the agent's next step; if the turn has already ended, DSH
|
|
5
|
+
* opens a new turn for it. Fork-only code, kept out of `app.js`.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import { promptRequestId } from './admission.js'
|
|
9
|
+
|
|
10
|
+
/** Reaction that tells the sender a message reached the running turn. */
|
|
11
|
+
export const STEER_REACTION = '👀'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How Telegram messages that arrive during a running turn are handled.
|
|
15
|
+
* @param value - the `busyMessages` setting.
|
|
16
|
+
* @returns `queue` (the bot holds them until the turn ends) or `steer`.
|
|
17
|
+
*/
|
|
18
|
+
export function busyMode(value) {
|
|
19
|
+
return value === 'queue' ? 'queue' : 'steer'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The durable request id of a Telegram prompt. With a Telegram message id it is
|
|
24
|
+
* the same id the native text path uses, so a redelivered update is not
|
|
25
|
+
* admitted twice.
|
|
26
|
+
* @param chatId - Telegram chat.
|
|
27
|
+
* @param messageId - Telegram message id, when known.
|
|
28
|
+
* @returns the request id recorded on the user message.
|
|
29
|
+
*/
|
|
30
|
+
export function steerRequestId(chatId, messageId) {
|
|
31
|
+
return Number.isSafeInteger(chatId) && Number.isSafeInteger(messageId)
|
|
32
|
+
? promptRequestId(chatId, messageId)
|
|
33
|
+
: `telegram-steer:${randomUUID()}`
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** @returns whether a pending or recorded message carries this request id. */
|
|
37
|
+
function hasRequest(agent, requestId) {
|
|
38
|
+
const matches = (message) => message?.source?.kind === 'user' && message.source.rpcId === requestId
|
|
39
|
+
const inbox = agent?.inbox
|
|
40
|
+
if (inbox?.nextTurn?.some?.(matches) || inbox?.nextStep?.some?.(matches)) return true
|
|
41
|
+
if (typeof agent?.session?.snapshotEvents !== 'function') return false
|
|
42
|
+
return agent.session.snapshotEvents().some((event) => event?.type === 'user/message' && matches(event.data))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Send one prompt into the session's running turn. Text goes through the
|
|
47
|
+
* Session controller's `steer` mode, the Web UI's own path; attachments are
|
|
48
|
+
* already stored, so a message with them goes straight to `agent.steer`, the
|
|
49
|
+
* method that mode calls. Either way DSH puts the message in the next-step
|
|
50
|
+
* inbox and wakes the agent: a running turn takes it at its next step, and a
|
|
51
|
+
* turn that ended meanwhile is followed by a new one, so the message is never
|
|
52
|
+
* dropped. A request id already admitted is skipped, so it is never sent twice.
|
|
53
|
+
* @param ctx - plugin context.
|
|
54
|
+
* @param agent - the session's live agent.
|
|
55
|
+
* @param prompt - `{ sessionId, chatId, messageId, text, attachments }`.
|
|
56
|
+
* @param signal - request lifetime.
|
|
57
|
+
* @returns the request id, or `undefined` when this DSH cannot steer.
|
|
58
|
+
*/
|
|
59
|
+
export async function steerPrompt(ctx, agent, { sessionId, chatId, messageId, text, attachments = [] }, signal) {
|
|
60
|
+
const requestId = steerRequestId(chatId, messageId)
|
|
61
|
+
const content = [...(text.trim().length === 0 ? [] : [{ type: 'text', text }]), ...attachments]
|
|
62
|
+
const controller = ctx.get('sessionController')
|
|
63
|
+
if (attachments.length === 0 && content.length > 0 && typeof controller?.prompt === 'function') {
|
|
64
|
+
await controller.prompt({ sessionId, requestId, mode: 'steer', content }, signal)
|
|
65
|
+
return requestId
|
|
66
|
+
}
|
|
67
|
+
if (hasRequest(agent, requestId)) return requestId
|
|
68
|
+
const message = { id: randomUUID(), role: 'user', content, source: { kind: 'user', rpcId: requestId } }
|
|
69
|
+
if (typeof agent?.steer === 'function') agent.steer(message)
|
|
70
|
+
else if (typeof agent?.send === 'function') agent.send(message, 'next-step', true)
|
|
71
|
+
else return undefined
|
|
72
|
+
return requestId
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Remove Telegram messages from one chat that the agent has not taken yet,
|
|
77
|
+
* from both the next-step and the next-turn inbox.
|
|
78
|
+
* @param agent - the session's live agent.
|
|
79
|
+
* @param chatId - Telegram chat whose messages are removed.
|
|
80
|
+
* @returns how many were removed.
|
|
81
|
+
*/
|
|
82
|
+
export function clearTelegramInbox(agent, chatId) {
|
|
83
|
+
const inbox = agent?.inbox
|
|
84
|
+
if (typeof inbox?.remove !== 'function') return 0
|
|
85
|
+
const prefix = `telegram:${chatId}:`
|
|
86
|
+
const ours = (message) => message?.source?.kind === 'user'
|
|
87
|
+
&& typeof message.source.rpcId === 'string'
|
|
88
|
+
&& (message.source.rpcId.startsWith(prefix) || message.source.rpcId.startsWith('telegram-steer:'))
|
|
89
|
+
const pending = [...(inbox.nextStep ?? []), ...(inbox.nextTurn ?? [])].filter(ours)
|
|
90
|
+
for (const message of pending) inbox.remove(message.id)
|
|
91
|
+
return pending.length
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether the agent still holds input it has not taken. DSH starts another
|
|
96
|
+
* turn for it right after the current one ends.
|
|
97
|
+
* @param agent - live agent.
|
|
98
|
+
*/
|
|
99
|
+
export function hasPendingInput(agent) {
|
|
100
|
+
const inbox = agent?.inbox
|
|
101
|
+
if (inbox === undefined) return false
|
|
102
|
+
if (typeof inbox.hasPending === 'boolean') return inbox.hasPending
|
|
103
|
+
return (inbox.nextStep?.length ?? 0) > 0 || (inbox.nextTurn?.length ?? 0) > 0
|
|
104
|
+
}
|
package/src/stickers.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sticker library: stickers the bot has seen or loaded from a set, keyed by
|
|
3
|
+
* Telegram's `file_unique_id`, with the `file_id` needed to send them again
|
|
4
|
+
* and a meaning note the agent writes, so it can pick a sticker by what it
|
|
5
|
+
* says rather than by id.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { STATUS_DIR } from './constants.js'
|
|
10
|
+
|
|
11
|
+
/** Default library file. */
|
|
12
|
+
export const STICKER_LIBRARY_PATH = join(STATUS_DIR, 'stickers.json')
|
|
13
|
+
/** Folder for cached preview images. */
|
|
14
|
+
export const STICKER_PREVIEW_DIR = join(STATUS_DIR, 'sticker-previews')
|
|
15
|
+
/** Longest meaning note kept, in characters. */
|
|
16
|
+
export const STICKER_MEANING_MAX = 1000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The kind of a Telegram sticker.
|
|
20
|
+
* @param sticker - Bot API Sticker.
|
|
21
|
+
* @returns `static`, `animated` (.tgs) or `video` (.webm).
|
|
22
|
+
*/
|
|
23
|
+
export function stickerKind(sticker) {
|
|
24
|
+
if (sticker?.is_video === true) return 'video'
|
|
25
|
+
if (sticker?.is_animated === true) return 'animated'
|
|
26
|
+
return 'static'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One line that names a sticker for the agent.
|
|
31
|
+
* @param entry - library entry.
|
|
32
|
+
* @returns e.g. `😀 from set "cats" (sticker AgAD..., animated)`.
|
|
33
|
+
*/
|
|
34
|
+
export function describeSticker(entry) {
|
|
35
|
+
const parts = []
|
|
36
|
+
if (typeof entry?.emoji === 'string' && entry.emoji.length > 0) parts.push(entry.emoji)
|
|
37
|
+
if (typeof entry?.setName === 'string' && entry.setName.length > 0) {
|
|
38
|
+
parts.push(`from set "${entry.setName}"${typeof entry.setTitle === 'string' && entry.setTitle.length > 0 && entry.setTitle !== entry.setName ? ` (${entry.setTitle})` : ''}`)
|
|
39
|
+
}
|
|
40
|
+
parts.push(`(sticker ${entry?.id}${entry?.kind === 'static' || entry?.kind === undefined ? '' : `, ${entry.kind}`})`)
|
|
41
|
+
return parts.join(' ')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Open the library. Reading happens once, on first use; a missing or broken
|
|
46
|
+
* file starts an empty library.
|
|
47
|
+
* @param options.path - library file.
|
|
48
|
+
* @param options.now - clock, for tests.
|
|
49
|
+
* @param options.onError - reports a failed write.
|
|
50
|
+
* @returns the library API.
|
|
51
|
+
*/
|
|
52
|
+
export function createStickerLibrary({ path = STICKER_LIBRARY_PATH, now = Date.now, onError = () => {} } = {}) {
|
|
53
|
+
let stickers
|
|
54
|
+
let loading
|
|
55
|
+
let writing = Promise.resolve()
|
|
56
|
+
|
|
57
|
+
async function load() {
|
|
58
|
+
if (stickers !== undefined) return stickers
|
|
59
|
+
loading ??= (async () => {
|
|
60
|
+
try {
|
|
61
|
+
const data = JSON.parse(await readFile(path, 'utf8'))
|
|
62
|
+
stickers = data?.stickers !== null && typeof data?.stickers === 'object' ? data.stickers : {}
|
|
63
|
+
} catch {
|
|
64
|
+
stickers = {}
|
|
65
|
+
}
|
|
66
|
+
return stickers
|
|
67
|
+
})()
|
|
68
|
+
return loading
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function save() {
|
|
72
|
+
const snapshot = JSON.stringify({ version: 1, stickers }, null, 1) + '\n'
|
|
73
|
+
writing = writing.then(async () => {
|
|
74
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
75
|
+
const temporary = `${path}.tmp`
|
|
76
|
+
await writeFile(temporary, snapshot, { mode: 0o600 })
|
|
77
|
+
await rename(temporary, path)
|
|
78
|
+
}).catch(onError)
|
|
79
|
+
return writing
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Add or refresh stickers without saving.
|
|
84
|
+
* @returns the stored entries.
|
|
85
|
+
*/
|
|
86
|
+
function upsert(list, { setTitle, source }) {
|
|
87
|
+
const time = new Date(now()).toISOString()
|
|
88
|
+
const stored = []
|
|
89
|
+
for (const sticker of list) {
|
|
90
|
+
const id = sticker?.file_unique_id
|
|
91
|
+
if (typeof id !== 'string' || id.length === 0 || typeof sticker.file_id !== 'string') continue
|
|
92
|
+
const old = stickers[id]
|
|
93
|
+
const entry = {
|
|
94
|
+
id,
|
|
95
|
+
fileId: sticker.file_id,
|
|
96
|
+
setName: typeof sticker.set_name === 'string' ? sticker.set_name : (old?.setName ?? null),
|
|
97
|
+
setTitle: typeof setTitle === 'string' ? setTitle : (old?.setTitle ?? null),
|
|
98
|
+
emoji: typeof sticker.emoji === 'string' ? sticker.emoji : (old?.emoji ?? null),
|
|
99
|
+
kind: stickerKind(sticker),
|
|
100
|
+
thumbnailId: sticker.thumbnail?.file_id ?? sticker.thumb?.file_id ?? old?.thumbnailId ?? null,
|
|
101
|
+
meaning: old?.meaning ?? '',
|
|
102
|
+
source: old?.source ?? source ?? 'received',
|
|
103
|
+
addedAt: old?.addedAt ?? time,
|
|
104
|
+
updatedAt: time,
|
|
105
|
+
}
|
|
106
|
+
stickers[id] = entry
|
|
107
|
+
stored.push(entry)
|
|
108
|
+
}
|
|
109
|
+
return stored
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
/**
|
|
114
|
+
* Add or refresh stickers (a received one, or a whole set). The meaning
|
|
115
|
+
* already written for a sticker is kept.
|
|
116
|
+
* @param list - Bot API Sticker objects.
|
|
117
|
+
* @param options - `{ setTitle, source }`: set title, and `received` or `set`.
|
|
118
|
+
* @returns the stored entries.
|
|
119
|
+
*/
|
|
120
|
+
async remember(list, options = {}) {
|
|
121
|
+
await load()
|
|
122
|
+
const stored = upsert(Array.isArray(list) ? list : [list], options)
|
|
123
|
+
if (stored.length > 0) await save()
|
|
124
|
+
return stored
|
|
125
|
+
},
|
|
126
|
+
/**
|
|
127
|
+
* @param id - `file_unique_id`.
|
|
128
|
+
* @returns the entry, or undefined.
|
|
129
|
+
*/
|
|
130
|
+
async get(id) {
|
|
131
|
+
await load()
|
|
132
|
+
return typeof id === 'string' ? stickers[id] : undefined
|
|
133
|
+
},
|
|
134
|
+
/**
|
|
135
|
+
* Write what a sticker means and when to use it.
|
|
136
|
+
* @param id - `file_unique_id`.
|
|
137
|
+
* @param meaning - free text; empty clears it.
|
|
138
|
+
* @returns the entry.
|
|
139
|
+
* @throws when the sticker is not in the library.
|
|
140
|
+
*/
|
|
141
|
+
async setMeaning(id, meaning) {
|
|
142
|
+
await load()
|
|
143
|
+
const entry = typeof id === 'string' ? stickers[id] : undefined
|
|
144
|
+
if (entry === undefined) throw new Error(`Sticker ${id} is not in the library.`)
|
|
145
|
+
entry.meaning = String(meaning ?? '').trim().slice(0, STICKER_MEANING_MAX)
|
|
146
|
+
entry.updatedAt = new Date(now()).toISOString()
|
|
147
|
+
await save()
|
|
148
|
+
return entry
|
|
149
|
+
},
|
|
150
|
+
/**
|
|
151
|
+
* Find stickers. Every given filter must match; `query` matches any of its
|
|
152
|
+
* words in the meaning, emoji, set name or title, and better matches come
|
|
153
|
+
* first. Without filters, the latest stickers come first.
|
|
154
|
+
* @param filters - `{ query, emoji, set, limit }`.
|
|
155
|
+
* @returns `{ total, stickers }`.
|
|
156
|
+
*/
|
|
157
|
+
async find({ query, emoji, set, limit = 20 } = {}) {
|
|
158
|
+
await load()
|
|
159
|
+
const words = String(query ?? '').toLowerCase().split(/\s+/u).filter((word) => word.length > 0)
|
|
160
|
+
const wantedEmoji = typeof emoji === 'string' && emoji.trim().length > 0 ? emoji.trim() : undefined
|
|
161
|
+
const wantedSet = typeof set === 'string' && set.trim().length > 0 ? set.trim().toLowerCase() : undefined
|
|
162
|
+
const scored = []
|
|
163
|
+
for (const entry of Object.values(stickers)) {
|
|
164
|
+
if (wantedEmoji !== undefined && !String(entry.emoji ?? '').includes(wantedEmoji)) continue
|
|
165
|
+
if (wantedSet !== undefined && String(entry.setName ?? '').toLowerCase() !== wantedSet && String(entry.setTitle ?? '').toLowerCase() !== wantedSet) continue
|
|
166
|
+
let score = 0
|
|
167
|
+
if (words.length > 0) {
|
|
168
|
+
const meaning = String(entry.meaning ?? '').toLowerCase()
|
|
169
|
+
const other = [entry.emoji, entry.setName, entry.setTitle].map((value) => String(value ?? '').toLowerCase()).join(' ')
|
|
170
|
+
for (const word of words) {
|
|
171
|
+
if (meaning.includes(word)) score += 2
|
|
172
|
+
else if (other.includes(word)) score += 1
|
|
173
|
+
}
|
|
174
|
+
if (score === 0) continue
|
|
175
|
+
}
|
|
176
|
+
scored.push({ entry, score })
|
|
177
|
+
}
|
|
178
|
+
scored.sort((left, right) => right.score - left.score || String(right.entry.updatedAt).localeCompare(String(left.entry.updatedAt)))
|
|
179
|
+
const count = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 100) : 20
|
|
180
|
+
return { total: scored.length, stickers: scored.slice(0, count).map(({ entry }) => entry) }
|
|
181
|
+
},
|
|
182
|
+
/** Wait for pending writes (tests and shutdown). */
|
|
183
|
+
flush: () => writing,
|
|
184
|
+
}
|
|
185
|
+
}
|