@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/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
+ }
@@ -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
+ }
@@ -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
+ }