@rhizogrip/spore-plugin-telegram 0.3.0 → 0.5.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/README.md CHANGED
@@ -28,8 +28,9 @@ passed to a spore's runner.
28
28
  ## What it guarantees
29
29
 
30
30
  - **A message reaches its spore once.** An update is confirmed to Telegram only after
31
- `send` has taken it, and it goes to `send` under `telegram:<update_id>` as its
32
- `requestId`, so an update Telegram hands over again is not said twice.
31
+ `send` has taken it, and it goes to `send` under `telegram:<bot>:<update_id>` as its
32
+ `requestId`, so an update Telegram hands over again is not said twice, and two bots' updates
33
+ are never taken for one another.
33
34
  - **A finished turn reaches its chat at least once.** What it owes a chat is an entry in the
34
35
  spore's outbox, settled only after Telegram accepted it; a crash between the two repeats a
35
36
  message rather than losing one. One Telegram will not take is settled refused, with
@@ -37,19 +38,24 @@ passed to a spore's runner.
37
38
  after the spore's next turn or the service's next start.
38
39
  - **A reply never mixes turns.** Each chat message gets the answer, the end state and the files
39
40
  of the turn that answered it.
40
- - **A restart loses nothing.** A turn running when the service stopped finishes into the
41
- chat's `working…` message; a turn that ended while it was down is posted when it is back.
41
+ - **A restart loses nothing.** A turn running when the service stopped is still answered, as a
42
+ reply to the message it answers; a turn that ended while it was down is posted when it is back.
42
43
  - **Nothing fails in silence.** A message a spore refuses is told to the chat; a message
43
44
  Telegram will not take is logged with its chat and turn.
44
45
 
45
46
  ## A turn in a chat
46
47
 
47
- Write to the bot and the message goes to the spore the chat is mapped to. The bot posts
48
- `working…`, shows which tools run as they run, and edits the answer into that message when
49
- the turn ends. How the turn ended is added only when it makes the answer partial — a wrap
50
- or a stop. A longer answer than Telegram's 4096 continues in further messages. What the
51
- turn left in `data/out/` follows as documents, up to the 50 a turn hands back, then a line
52
- counting the rest; a file over Telegram's 50 MB is named instead.
48
+ Write to the bot and the message goes to the spore the chat is mapped to, which is told who
49
+ wrote it and where — your name and username, the private chat or the group. The bot posts
50
+ `working…` and shows which tools run as they run. When the turn ends, the answer arrives as a new
51
+ message replying to yours, so it notifies you however long the turn took, and `working…` is
52
+ removed; one Telegram will no longer let the bot remove, past 48 hours, says `answered below`. The
53
+ answer's Markdown is shown as Telegram formats it — bold, italic, code, code blocks, links, quotes;
54
+ a heading as a bold line, a table in a monospace block — and as plain text if Telegram refuses it. How
55
+ the turn ended is added only when it makes the answer partial — a wrap or a stop. A longer answer
56
+ than Telegram's 4096 continues in further messages. What the turn left in `data/out/` follows as
57
+ documents, up to the 50 a turn hands back, then a line counting the rest; a file over Telegram's
58
+ 50 MB is named instead.
53
59
 
54
60
  A message sent while a turn runs lands between its rounds, and its `working…` message says it
55
61
  was heard. A turn ending in a question posts the
@@ -57,7 +63,7 @@ question, and the reply answers it. The conversation is the spore's transcript;
57
63
  keeps none.
58
64
 
59
65
  A turn that ends without reading your message — one that failed before it got there —
60
- says so in your `working…` message, and the next turn answers it there.
66
+ says so in your `working…` message, and the next turn answers it.
61
67
 
62
68
  ```
63
69
  /status what the spore is doing, its turns and what they cost, and the running turn's limits
@@ -66,7 +72,8 @@ says so in your `working…` message, and the next turn answers it there.
66
72
  /extend wall 30m give it more — wall, cost or rounds — up to the extend.<limit> its spore.cmk grants
67
73
  ```
68
74
 
69
- Only text reaches a spore for now.
75
+ Only text reaches a spore for now: a photo's or a document's caption goes to it, and you are told
76
+ the file did not; a message with nothing written in it is told only text reaches a spore.
70
77
 
71
78
  ## Turns nobody in a chat started
72
79
 
@@ -118,9 +125,9 @@ group's id is negative, and it is the group you add rather than the people in it
118
125
 
119
126
  ## What it keeps
120
127
 
121
- `$XDG_STATE_HOME/spore/telegram/<bot>/state.json`, one per bot, written whole and renamed into
122
- place: where it stands in each spore it follows, in that spore's database, and every message a chat sent that no turn has answered
123
- yet, with the `working…` message its answer goes into. A spore added later is followed from its
128
+ `$XDG_STATE_HOME/spore/telegram/<bot>/state.json`, one per bot, mode 0600, written whole and
129
+ renamed into place: where it stands in each spore it follows, in that spore's database, and every
130
+ message a chat sent that no turn has answered yet, with its `working…` message. A spore added later is followed from its
124
131
  latest turn; earlier answers are not posted. Nothing is kept in the repository or in the spores'
125
132
  home.
126
133
 
@@ -2,82 +2,18 @@
2
2
  import { dirname, resolve } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
- import { loadEnvironment, recordStarted } from '@rhizogrip/spore'
5
+ import { runService } from '@rhizogrip/spore/relay'
6
6
 
7
7
  import { createTelegram } from '../src/api.js'
8
- import { allowed, botNames, botPath, botsDir, botToken } from '../src/bots.js'
9
- import { createService } from '../src/service.js'
10
- import { statePath } from '../src/state.js'
11
-
12
- const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
13
-
14
- const REFUSED = 2
15
-
16
- const log = (line) => console.error(`${new Date().toISOString()} ${line}`)
17
-
18
- const refuse = (err, hint) => {
19
- console.error(`spore-telegram: ${err.message}`)
20
- if (err.detail) console.error(` ${err.detail}`)
21
- if (hint) console.error(` ${hint}`)
22
- process.exit(REFUSED)
23
- }
24
-
25
- const bot = process.argv.slice(2).find(arg => !arg.startsWith('--'))
26
- if (!bot) {
27
- refuse(new Error('which bot?'), `spore-telegram <bot> runs ${botsDir()}/<bot>.json${botNames().length ? ` — here: ${botNames().join(', ')}` : ''}`)
28
- }
29
-
30
- const launched = { ...process.env }
31
- try {
32
- loadEnvironment()
33
- } catch (err) {
34
- refuse(err)
35
- }
36
-
37
- let file
38
- let telegram
39
- try {
40
- file = botPath(bot)
41
- allowed(file)
42
- telegram = createTelegram(botToken(file, process.env))
43
- } catch (err) {
44
- const variable = `${bot.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BOT_TOKEN`
45
- refuse(err, err.type === 'TELEGRAM_NO_BOT'
46
- ? `mkdir -p ${botsDir()} && echo '{"token": "$\{${variable}}", "chats": {}}' > ${file}\n with chats {}, start the bot and message it — the log names every chat it refuses, with its id`
47
- : null)
48
- }
49
-
50
- let me
51
- try {
52
- me = await telegram.me()
53
- } catch (err) {
54
- refuse(err, `the token comes from @BotFather, in the variable ${file} names`)
55
- }
56
-
57
- const state = statePath(bot)
58
- let service
59
- try {
60
- service = createService({ telegram, botFile: file, statePath: state, env: launched, log })
61
- } catch (err) {
62
- refuse(err)
63
- }
64
-
65
- if (process.argv.includes('--check')) {
66
- console.log(`spore-telegram ${bot} · @${me.username} · ${file} · state ${state} · ready`)
67
- process.exit(0)
68
- }
69
-
70
- const controller = new AbortController()
71
- for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => controller.abort())
72
-
73
- recordStarted(`telegram@${bot}`, { root: ROOT })
74
- log(`spore-telegram ${bot} · @${me.username} · ${file} · state ${state}`)
75
- try {
76
- await service.run(controller.signal)
77
- } catch (err) {
78
- if (err.type === 'TELEGRAM_CONFLICT') refuse(err, 'one bot token is read by one process — stop the other one, or remove its webhook')
79
- log(`stopped — ${err.message}${err.detail ? ` — ${err.detail}` : ''}`)
80
- process.exit(1)
81
- }
82
- log('stopped')
83
- process.exit(0)
8
+ import { TELEGRAM, createAdapter } from '../src/adapter.js'
9
+
10
+ await runService({
11
+ spec: TELEGRAM,
12
+ program: 'spore-telegram',
13
+ root: resolve(dirname(fileURLToPath(import.meta.url)), '..'),
14
+ open: async ({ bot, credentials }) => {
15
+ const telegram = createTelegram(credentials.token)
16
+ const me = await telegram.me()
17
+ return { adapter: createAdapter({ telegram, bot }), who: `@${me.username}` }
18
+ }
19
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-telegram",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Telegram for spores: a bot of yours in chats, each chat reaching its spore, run as a service",
5
5
  "author": {
6
6
  "name": "Christophe Le Bars",
@@ -27,14 +27,17 @@
27
27
  "README.md",
28
28
  "NOTICE"
29
29
  ],
30
+ "dependencies": {
31
+ "marked": "^18.0.11"
32
+ },
30
33
  "peerDependencies": {
31
- "@rhizogrip/spore": "^1"
34
+ "@rhizogrip/spore": "^1.16.0"
32
35
  },
33
36
  "scripts": {
34
37
  "test": "vitest run"
35
38
  },
36
39
  "spore": {
37
- "works": "^1",
40
+ "works": "^1.16.0",
38
41
  "services": {
39
42
  "telegram": {
40
43
  "what": "a bot of yours in Telegram chats, each chat reaching its spore",
package/src/adapter.js ADDED
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Telegram for the relay (`@rhizogrip/spore/relay`): the bot file it reads, the long poll that
3
+ * hears chats, and the Bot API calls that answer them. A chat is a conversation, keyed by its id;
4
+ * the bot file maps each to the spore it reaches.
5
+ */
6
+
7
+ import { SporeError } from '@rhizogrip/spore'
8
+
9
+ import { toHtml } from './format.js'
10
+
11
+ const COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
12
+ const NOT_MODIFIED = /message is not modified/
13
+ const CANNOT_PARSE = /can't parse entities/i
14
+ const SHAPE = '{ "token": "${TRIAGE_BOT_TOKEN}", "chats": { "<chat id>": "<spore>" } }' // eslint-disable-line no-template-curly-in-string
15
+
16
+ const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
17
+
18
+ const invalid = (path, problem, detail) => new SporeError(`${path} ${problem}`, { type: 'TELEGRAM_BOT_INVALID', detail })
19
+
20
+ /**
21
+ * `{ "<chat id>": "<spore>" }`, or `{ "<chat id>": { "spore": "<spore>", "details": true } }` for a
22
+ * chat shown what each call does — its command or path — rather than only what kind of call it
23
+ * is: a Telegram message leaves the machine, so that is a choice made per chat.
24
+ */
25
+ function routes (raw, path) {
26
+ if (typeof raw.chats !== 'object' || raw.chats === null || Array.isArray(raw.chats)) {
27
+ throw invalid(path, 'has no chats', 'chats is { "<chatId>": "<spore>" } — {} while you learn your chat id: the log names every chat it refuses')
28
+ }
29
+ const table = new Map()
30
+ for (const [chat, entry] of Object.entries(raw.chats)) {
31
+ const spore = typeof entry === 'string' ? entry : entry?.spore
32
+ const extra = typeof entry === 'object' && entry !== null ? Object.keys(entry).filter(key => !['spore', 'details'].includes(key)) : []
33
+ if (typeof spore !== 'string' || !spore || extra.length || (typeof entry === 'object' && entry.details !== undefined && typeof entry.details !== 'boolean')) {
34
+ throw invalid(path, `maps ${chat} to something that is not a spore`, 'each chat is a chat id against the name of a spore it may reach, or { "spore": "<name>", "details": true }')
35
+ }
36
+ table.set(chat, { spores: [spore], details: typeof entry === 'object' && entry.details === true })
37
+ }
38
+ return table
39
+ }
40
+
41
+ const HELP = 'Anything you write here goes to the spore. /status says what it is doing; /stop ends the turn it is running; /wrap [10m] asks it to wrap up; /extend wall 30m gives it more, as far as its spore.cmk allows.'
42
+
43
+ export const TELEGRAM = {
44
+ kind: 'telegram',
45
+ label: 'Telegram',
46
+ noun: 'chat',
47
+ fields: ['token', 'chats'],
48
+ credentials: ['token'],
49
+ stateFields: [],
50
+ sourceVariables: [],
51
+ refusals: ['TELEGRAM_CONFLICT'],
52
+ example: SHAPE,
53
+ routes,
54
+ usage: { help: HELP, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
55
+ limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500 },
56
+ can: { upload: true, remove: true },
57
+ hint (err, { file, bot }) {
58
+ if (err.type === 'TELEGRAM_NO_BOT') {
59
+ const variable = `${bot.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BOT_TOKEN`
60
+ return `mkdir -p ${file.slice(0, file.lastIndexOf('/'))} && echo '{"token": "$\{${variable}}", "chats": {}}' > ${file}\n with chats {}, start the bot and message it — the log names every chat it refuses, with its id`
61
+ }
62
+ if (err.type === 'TELEGRAM_FAILED') return `the token comes from @BotFather, in the variable ${file} names`
63
+ if (err.type === 'TELEGRAM_CONFLICT') return 'one bot token is read by one process — stop the other one, or remove its webhook'
64
+ return null
65
+ },
66
+ refusal: (message, botFile) =>
67
+ `refused chat ${message.conversation}${message.person.username ? ` (@${message.person.username})` : ''} — to allow it, add "${message.conversation}": "<a spore>" to the chats of ${botFile}`
68
+ }
69
+
70
+ const FILES = ['photo', 'document', 'video', 'audio', 'voice', 'video_note', 'animation', 'sticker']
71
+
72
+ export function normalize (update, bot) {
73
+ const message = update.message
74
+ if (message?.chat?.id === undefined) return null
75
+ const text = (message.text ?? message.caption ?? '').trim() || null
76
+ const command = text?.match(COMMAND)
77
+ const { first_name: first, last_name: last, username } = message.from
78
+ return {
79
+ id: `${bot}:${update.update_id}`,
80
+ conversation: String(message.chat.id),
81
+ message: message.message_id,
82
+ person: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username },
83
+ where: message.chat.type === 'private' ? 'a private chat on Telegram' : `the Telegram group "${message.chat.title}"`,
84
+ text,
85
+ command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
86
+ addresses: null,
87
+ files: FILES.filter(kind => message[kind]).length
88
+ }
89
+ }
90
+
91
+ export function createAdapter ({ telegram, bot }) {
92
+ return {
93
+ ...TELEGRAM,
94
+
95
+ /**
96
+ * One long poll after another until `signal` aborts. An update is handed to `onMessage` and
97
+ * awaited before the offset moves past it, since moving it is what confirms the update to
98
+ * Telegram: a message is confirmed only once the spore has it.
99
+ */
100
+ async listen ({ onMessage, signal, log, sleep }) {
101
+ let offset
102
+ let backoff = 1000
103
+ while (!signal.aborted) {
104
+ let updates
105
+ try {
106
+ updates = await telegram.updates(offset, { signal })
107
+ backoff = 1000
108
+ } catch (err) {
109
+ if (signal.aborted) break
110
+ if (err.context?.status === 409) {
111
+ throw new SporeError('another process is taking this bot\'s updates', { type: 'TELEGRAM_CONFLICT', detail: err.detail })
112
+ }
113
+ if (!err.retryable) throw err
114
+ log(`poll failed — ${said(err)}`)
115
+ await sleep(err.context?.retryAfter ? err.context.retryAfter * 1000 : backoff)
116
+ backoff = Math.min(backoff * 2, 60_000)
117
+ continue
118
+ }
119
+ for (const update of updates) {
120
+ const message = normalize(update, bot)
121
+ if (message) await onMessage(message)
122
+ offset = update.update_id + 1
123
+ }
124
+ }
125
+ if (offset !== undefined) {
126
+ await telegram.updates(offset, { seconds: 0 }).catch(err => log(`could not confirm the last updates — ${said(err)}`))
127
+ }
128
+ },
129
+
130
+ post: async (conversation, text, { answering = null, markdown = false } = {}) => {
131
+ try {
132
+ return (await telegram.say(Number(conversation), markdown ? toHtml(text) : text, { answering, html: markdown })).message_id
133
+ } catch (err) {
134
+ if (!markdown || !CANNOT_PARSE.test(err.detail ?? '')) throw err
135
+ throw new SporeError('Telegram refused the formatting', { type: 'TELEGRAM_FORMAT_REFUSED', detail: err.detail, context: { formatRefused: true } })
136
+ }
137
+ },
138
+
139
+ edit: async (conversation, note, text) => {
140
+ try {
141
+ await telegram.revise(Number(conversation), note, text)
142
+ } catch (err) {
143
+ if (!NOT_MODIFIED.test(err.detail ?? '')) throw err
144
+ }
145
+ },
146
+
147
+ remove: (conversation, note) => telegram.remove(Number(conversation), note),
148
+
149
+ upload: (conversation, file, caption) => telegram.document(Number(conversation), file, caption)
150
+ }
151
+ }
package/src/api.js CHANGED
@@ -51,11 +51,19 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
51
51
  updates: (offset, { seconds = 30, signal } = {}) =>
52
52
  call('getUpdates', { offset, timeout: seconds, allowed_updates: ['message'] }, { timeoutMs: (seconds + 15) * 1000, signal }),
53
53
 
54
- say: (chat, text) => call('sendMessage', { chat_id: chat, text, link_preview_options: { is_disabled: true } }),
54
+ say: (chat, text, { answering = null, html = false } = {}) => call('sendMessage', {
55
+ chat_id: chat,
56
+ text,
57
+ link_preview_options: { is_disabled: true },
58
+ ...(html ? { parse_mode: 'HTML' } : {}),
59
+ ...(answering === null ? {} : { reply_parameters: { message_id: answering, allow_sending_without_reply: true } })
60
+ }),
55
61
 
56
62
  revise: (chat, message, text) =>
57
63
  call('editMessageText', { chat_id: chat, message_id: message, text, link_preview_options: { is_disabled: true } }),
58
64
 
65
+ remove: (chat, message) => call('deleteMessage', { chat_id: chat, message_id: message }),
66
+
59
67
  document: (chat, { path, mime }, caption) => {
60
68
  const form = new FormData()
61
69
  form.append('chat_id', String(chat))
package/src/format.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Markdown, as a model writes it, in the HTML Telegram renders. Bold, italic, strikethrough, code,
3
+ * code blocks with their language, links and quotes are Telegram's own; a heading becomes a bold
4
+ * line, a list keeps its bullets as text, a table becomes a monospace block. Telegram refuses a
5
+ * message holding any tag outside its small set, so everything else is escaped and shown as written.
6
+ */
7
+
8
+ import { Lexer } from 'marked'
9
+
10
+ const LINKABLE = /^(https?:|mailto:|tg:)/i
11
+
12
+ const escape = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
13
+ const attribute = (text) => escape(text).replace(/"/g, '&quot;')
14
+
15
+ const plain = (tokens) => tokens.map(token => (token.tokens ? plain(token.tokens) : token.text ?? '')).join('')
16
+
17
+ function inline (tokens) {
18
+ return tokens.map((token) => {
19
+ if (token.type === 'strong') return `<b>${inline(token.tokens)}</b>`
20
+ if (token.type === 'em') return `<i>${inline(token.tokens)}</i>`
21
+ if (token.type === 'del') return `<s>${inline(token.tokens)}</s>`
22
+ if (token.type === 'codespan') return `<code>${escape(token.text)}</code>`
23
+ if (token.type === 'br') return '\n'
24
+ if (token.type === 'link') return LINKABLE.test(token.href) ? `<a href="${attribute(token.href)}">${inline(token.tokens)}</a>` : inline(token.tokens)
25
+ if (token.type === 'image') return LINKABLE.test(token.href) ? `<a href="${attribute(token.href)}">${escape(token.text || token.href)}</a>` : escape(token.text)
26
+ if (token.type === 'text') return token.tokens ? inline(token.tokens) : escape(token.text)
27
+ if (token.type === 'escape') return escape(token.text)
28
+ if (token.type === 'checkbox') return token.checked ? '☑ ' : '☐ '
29
+ return escape(token.raw)
30
+ }).join('')
31
+ }
32
+
33
+ function list (token, depth) {
34
+ const indent = ' '.repeat(depth)
35
+ return token.items.map((item, i) => {
36
+ const mark = token.ordered ? `${Number(token.start) + i}.` : '•'
37
+ const box = item.tokens.filter(child => child.type === 'checkbox').map(child => inline([child])).join('')
38
+ const parts = item.tokens.filter(child => child.type !== 'checkbox').map(child => (child.type === 'list' ? list(child, depth + 1) : block([child], depth + 1)))
39
+ return `${indent}${mark} ${box}${parts.join('\n')}`
40
+ }).join('\n')
41
+ }
42
+
43
+ function table (token) {
44
+ const rows = [token.header, ...token.rows].map(row => row.map(cell => plain(cell.tokens)))
45
+ const widths = rows[0].map((_, column) => Math.max(...rows.map(row => row[column].length)))
46
+ const line = (row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' | ').trimEnd()
47
+ return `<pre>${escape([line(rows[0]), widths.map(width => '-'.repeat(width)).join('-+-'), ...rows.slice(1).map(line)].join('\n'))}</pre>`
48
+ }
49
+
50
+ function block (tokens, depth = 0) {
51
+ const out = []
52
+ for (const token of tokens) {
53
+ if (token.type === 'space') continue
54
+ if (token.type === 'code') {
55
+ const language = token.lang?.split(/\s/)[0]
56
+ out.push(`<pre><code${language ? ` class="language-${attribute(language)}"` : ''}>${escape(token.text)}</code></pre>`)
57
+ } else if (token.type === 'heading') {
58
+ out.push(`<b>${inline(token.tokens)}</b>`)
59
+ } else if (token.type === 'paragraph' || token.type === 'text') {
60
+ out.push(inline(token.tokens ?? [token]))
61
+ } else if (token.type === 'blockquote') {
62
+ out.push(`<blockquote>${block(token.tokens, depth)}</blockquote>`)
63
+ } else if (token.type === 'list') {
64
+ out.push(list(token, depth))
65
+ } else if (token.type === 'table') {
66
+ out.push(table(token))
67
+ } else if (token.type === 'hr') {
68
+ out.push('──────────')
69
+ } else {
70
+ out.push(escape(token.raw.trimEnd()))
71
+ }
72
+ }
73
+ return out.join('\n\n')
74
+ }
75
+
76
+ export const toHtml = (markdown) => block(Lexer.lex(String(markdown)))
package/src/bots.js DELETED
@@ -1,116 +0,0 @@
1
- /**
2
- * The bots: for each, its token and who may talk to it, and to which spore.
3
- *
4
- * One file per bot, `telegram/<bot>.json` beside `config.json`, because a bot is a face a spore
5
- * shows on Telegram and each runs as a service instance of its own. The routing is in the same
6
- * file because a bot needs it anyway: a chat absent from it is refused. Beside `config.json` and
7
- * `sources.json`, for the same reason — it says who reaches what, and nothing a confined agent can
8
- * read may say that. The token is `${VAR}`, from the environment, never kept in the file.
9
- *
10
- * Spores keep the names a person gave them. Deriving one per chat would forbid what the routing
11
- * allows: two chats on one spore, a spore reached through two bots, or a chat moved to another
12
- * spore by editing a line. Restricting any of it is policy, and policy about chats lives above
13
- * spore.
14
- */
15
-
16
- import { existsSync, readFileSync, readdirSync } from 'node:fs'
17
- import { basename, dirname, join, resolve } from 'node:path'
18
-
19
- import { CONFIG_FILE, SporeError } from '@rhizogrip/spore'
20
-
21
- export const BOTS_DIR = 'telegram'
22
- const NAME = /^[a-z0-9][a-z0-9._-]*$/i
23
- const VARIABLE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/
24
-
25
- export const botsDir = (configFile = CONFIG_FILE) => resolve(dirname(configFile), BOTS_DIR)
26
-
27
- export function botPath (name, configFile = CONFIG_FILE) {
28
- if (!NAME.test(name)) {
29
- throw new SporeError(`"${name}" cannot be a bot's name`, { type: 'TELEGRAM_BOT_INVALID', detail: 'letters, digits, ., - and _' })
30
- }
31
- return join(botsDir(configFile), `${name}.json`)
32
- }
33
-
34
- export function botNames (configFile = CONFIG_FILE) {
35
- const dir = botsDir(configFile)
36
- if (!existsSync(dir)) return []
37
- return readdirSync(dir).filter(file => file.endsWith('.json')).map(file => basename(file, '.json')).sort()
38
- }
39
-
40
- function read (path) {
41
- if (!existsSync(path)) {
42
- throw new SporeError(`no ${path}`, {
43
- type: 'TELEGRAM_NO_BOT',
44
- detail: 'it names the bot\'s token and which chats may talk to it, as { "token": "${VAR}", "chats": { "<chatId>": "<spore>" } } — a bot with no chats is an open door to something that spends money' // eslint-disable-line no-template-curly-in-string
45
- })
46
- }
47
- let raw
48
- try {
49
- raw = JSON.parse(readFileSync(path, 'utf8'))
50
- } catch (err) {
51
- throw new SporeError(`${path} is not readable JSON`, { type: 'TELEGRAM_BOT_INVALID', detail: err.message })
52
- }
53
- const unknown = Object.keys(raw ?? {}).filter(key => !['token', 'chats'].includes(key))
54
- if (unknown.length) throw new SporeError(`${path} has a field spore does not know — ${unknown.join(', ')}`, { type: 'TELEGRAM_BOT_INVALID', detail: 'a bot has a token and chats' })
55
- if (typeof raw.token !== 'string' || !VARIABLE.test(raw.token)) {
56
- throw new SporeError(`${path} has no token spore can read`, {
57
- type: 'TELEGRAM_BOT_INVALID',
58
- detail: 'token names the variable holding the token from @BotFather, as "${REVIEWER_BOT_TOKEN}", set in environment beside config.json' // eslint-disable-line no-template-curly-in-string
59
- })
60
- }
61
- if (typeof raw.chats !== 'object' || raw.chats === null || Array.isArray(raw.chats)) {
62
- throw new SporeError(`${path} has no chats`, {
63
- type: 'TELEGRAM_BOT_INVALID',
64
- detail: 'chats is { "<chatId>": "<spore>" } — {} while you learn your chat id: the log names every chat it refuses'
65
- })
66
- }
67
- return raw
68
- }
69
-
70
- /**
71
- * `{ "<chatId>": "<spore>" }`, or `{ "<chatId>": { "spore": "<spore>", "details": true } }` for a
72
- * chat shown what each call does — its command or path — rather than only what kind of call it
73
- * is: a Telegram message leaves the machine, so that is a choice made per chat. Read on every
74
- * update rather than held: adding a chat should not need a restart.
75
- */
76
- function entries (path) {
77
- return Object.entries(read(path).chats).map(([chat, entry]) => {
78
- const spore = typeof entry === 'string' ? entry : entry?.spore
79
- const extra = typeof entry === 'object' && entry !== null ? Object.keys(entry).filter(key => !['spore', 'details'].includes(key)) : []
80
- if (typeof spore !== 'string' || !spore || extra.length || (typeof entry === 'object' && entry.details !== undefined && typeof entry.details !== 'boolean')) {
81
- throw new SporeError(`${path} maps ${chat} to something that is not a spore`, {
82
- type: 'TELEGRAM_BOT_INVALID',
83
- detail: 'each chat is a chat id against the name of a spore it may reach, or { "spore": "<name>", "details": true }'
84
- })
85
- }
86
- return { chat, spore, details: typeof entry === 'object' && entry.details === true }
87
- })
88
- }
89
-
90
- export function allowed (path) {
91
- return Object.fromEntries(entries(path).map(({ chat, spore }) => [chat, spore]))
92
- }
93
-
94
- /** The chats shown each call's command or path, by id. */
95
- export function detailedChats (path) {
96
- return new Set(entries(path).filter(entry => entry.details).map(entry => Number(entry.chat)))
97
- }
98
-
99
- export const tokenVariable = (path) => VARIABLE.exec(read(path).token)[1]
100
-
101
- export function botToken (path, env) {
102
- const variable = tokenVariable(path)
103
- if (!env[variable]) {
104
- throw new SporeError(`${path} wants $${variable}, which is not set`, {
105
- type: 'TELEGRAM_NO_TOKEN',
106
- detail: 'the token from @BotFather goes in environment beside config.json; it never reaches a spore'
107
- })
108
- }
109
- return env[variable]
110
- }
111
-
112
- /**
113
- * Every variable a bot here keeps its token in: none of them is passed to a spore's runner. A bot
114
- * file that cannot be read is refused rather than skipped, since its token would pass unnoticed.
115
- */
116
- export const tokenVariables = (configFile = CONFIG_FILE) => botNames(configFile).map(name => tokenVariable(botPath(name, configFile)))
package/src/delivery.js DELETED
@@ -1,28 +0,0 @@
1
- export function createDelivery ({ sleep, signal, now = Date.now, giveUpMs = 10 * 60_000 }) {
2
- const chains = new Map()
3
-
4
- const attempt = async (step) => {
5
- const started = now()
6
- let backoff = 1000
7
- for (;;) {
8
- try {
9
- return await step()
10
- } catch (err) {
11
- if (!err.retryable || signal.aborted || now() - started > giveUpMs) throw err
12
- const after = err.context?.retryAfter
13
- await sleep(after ? after * 1000 : backoff)
14
- backoff = Math.min(backoff * 2, 60_000)
15
- }
16
- }
17
- }
18
-
19
- const to = (chat, work) => {
20
- const next = (chains.get(chat) ?? Promise.resolve()).then(() => work(attempt))
21
- const settled = next.catch(() => {})
22
- chains.set(chat, settled)
23
- settled.then(() => { if (chains.get(chat) === settled) chains.delete(chat) })
24
- return next
25
- }
26
-
27
- return { to, attempt }
28
- }
package/src/forward.js DELETED
@@ -1,138 +0,0 @@
1
- /**
2
- * What a chat is told while a turn works: what kind of call ran — reading a file, running a
3
- * command, asking a source by the name its host gave it — and never what it was given or what
4
- * came back. A Telegram message leaves the machine, and a command line or a path is the
5
- * operator's. A chat whose allow-list entry says `details` is shown the command or the path as
6
- * well: that is a choice made per chat. The answer and the stop reason cross in full.
7
- */
8
-
9
- export const MAX_MESSAGE = 4096
10
-
11
- const PLAIN_ENDINGS = new Set(['answer', 'question'])
12
-
13
- const LONGEST = 70
14
- const SLOW_MS = 5000
15
-
16
- // Spore's own programs, by the variable an agent runs them through, and what each is called.
17
- const PROGRAMS = [
18
- [/^node\s+"?\$SPORE_SOURCE"?\s*/, 'source '],
19
- [/^node\s+"?\$SPORE_FANOUT"?\s*/, 'fanout '],
20
- [/^"?\$SPORE_BROWSER"?\s*/, 'browser '],
21
- [/^"?\$SPORE_BRAVE"?\s*/, 'search '],
22
- [/^"?\$SPORE_SPAWN"?\s*/, 'spawn '],
23
- [/^"?\$SPORE_SQL"?\s*/, 'sql ']
24
- ]
25
-
26
- const firstLine = (text) => String(text ?? '').split('\n')[0].replace(/\s+/g, ' ').trim()
27
- const cut = (text) => (text.length > LONGEST ? `${text.slice(0, LONGEST - 1)}…` : text)
28
-
29
- function kind (line) {
30
- const source = /^node\s+"?\$SPORE_SOURCE"?\s+([\w.-]+)/.exec(line)
31
- if (source) return `asking ${source[1]}`
32
- if (/^"?\$SPORE_BROWSER/.test(line)) return 'using the browser'
33
- if (/^"?\$SPORE_BRAVE/.test(line)) return 'searching the web'
34
- if (/^(node\s+"?\$SPORE_FANOUT|"?\$SPORE_SPAWN)/.test(line)) return 'starting sub-agents'
35
- if (/^"?\$SPORE_SQL/.test(line)) return 'reading its records'
36
- return 'running a command'
37
- }
38
-
39
- function command (text) {
40
- const line = firstLine(text)
41
- const plain = kind(line)
42
- const program = PROGRAMS.find(([pattern]) => pattern.test(line))
43
- const detailed = program ? cut(line.replace(program[0], program[1]).trim()) : `running ${cut(line)}`
44
- return { plain, detailed }
45
- }
46
-
47
- /** A call as a chat is told it, plainly and in detail, or null for one it has no use for. */
48
- export function describe (event) {
49
- const { name, args = {} } = event
50
- const file = (verb, noun) => ({ plain: `${verb} a ${noun}`, detailed: `${verb} ${cut(firstLine(args.path))}` })
51
- if (name === 'read') return file('reading', 'file')
52
- if (name === 'write') return file('writing', 'file')
53
- if (name === 'edit') return file('editing', 'file')
54
- if (name === 'exec' || name === 'wsexec') return command(args.command)
55
- if (name === 'ask' || name === 'quiet') return null
56
- return { plain: name, detailed: name }
57
- }
58
-
59
- /**
60
- * A turn's events folded into what a chat is shown: a call when it starts, its time once it
61
- * ends slow, and that it failed. Returns whether anything changed.
62
- */
63
- export function track (items, event) {
64
- if (event.event === 'tool_start') {
65
- const told = describe(event)
66
- if (!told) return false
67
- items.push({ callId: event.callId, ...told })
68
- return true
69
- }
70
- if (event.event === 'tool_end') {
71
- const item = items.find(i => i.callId === event.callId)
72
- if (!item) return false
73
- item.durationMs = event.durationMs
74
- item.exit = event.outcome === 'exit' ? event.exit : null
75
- item.failed = event.outcome === undefined ? Boolean(event.error) : !['ok', 'exit'].includes(event.outcome)
76
- return item.failed || item.exit !== null || item.durationMs >= SLOW_MS
77
- }
78
- const note = (text) => {
79
- items.push({ plain: text, detailed: text })
80
- return true
81
- }
82
- if (event.event === 'wrap') return note(`wrapping up — reached its ${event.dimension} limit`)
83
- if (event.event === 'interject') return note('heard you')
84
- return false
85
- }
86
-
87
- /** The lines a chat sees, the last few, a run of the same call counted rather than repeated. */
88
- export function render (items, { detailed = false, last = 6 } = {}) {
89
- const lines = []
90
- for (const item of items) {
91
- const text = (detailed ? item.detailed : item.plain) +
92
- (item.failed ? ' — failed' : '') +
93
- (item.exit != null ? ` — exit ${item.exit}` : '') +
94
- (item.durationMs >= SLOW_MS ? ` — ${Math.round(item.durationMs / 1000)} s` : '')
95
- const previous = lines.at(-1)
96
- if (previous?.text === text) previous.count++
97
- else lines.push({ text, count: 1 })
98
- }
99
- return lines.slice(-last).map(({ text, count }) => `· ${text}${count > 1 ? ` ×${count}` : ''}`)
100
- }
101
-
102
- const ending = (record) => [record.stopReason, record.error].filter(Boolean).join(' · ')
103
-
104
- export function reply ({ record }) {
105
- const { output } = record
106
- if (output) return PLAIN_ENDINGS.has(record.stopReason) ? output : `${output}\n\n— ${ending(record)}`
107
- if (record.stopReason === 'quiet') return 'nothing to say'
108
- return `no answer · ${ending(record)}`
109
- }
110
-
111
- /** What a chat is told of a turn that answered `asked`, none of it said in a chat: who said it, then the answer. */
112
- export function announce ({ record, asked }) {
113
- const named = (field) => [...new Set(asked.filter(entry => entry[field]).map(entry => `"${entry[field]}"`))]
114
- const origins = [
115
- named('schedule').length ? `schedule ${named('schedule').join(', ')}` : null,
116
- named('hook').length ? `webhook ${named('hook').join(', ')}` : null,
117
- named('job').length ? `job ${named('job').join(', ')}` : null
118
- ].filter(Boolean)
119
- return `${origins.length ? origins.join(' · ') : 'sent from elsewhere'}\n\n${reply({ record })}`
120
- }
121
-
122
- /** A turn ended without reading this chat's message, and nothing is running to read it. */
123
- export function waiting ({ record }) {
124
- return `no answer yet · ${ending(record)} — your message waits for the next turn`
125
- }
126
-
127
- export function pieces (text, limit = MAX_MESSAGE) {
128
- const out = []
129
- let rest = String(text)
130
- while (rest.length > limit) {
131
- const cut = rest.lastIndexOf('\n', limit)
132
- const at = cut > limit / 2 ? cut : limit
133
- out.push(rest.slice(0, at))
134
- rest = rest.slice(at).replace(/^\n/, '')
135
- }
136
- if (rest) out.push(rest)
137
- return out
138
- }
package/src/service.js DELETED
@@ -1,411 +0,0 @@
1
- import { existsSync, statSync } from 'node:fs'
2
- import { basename, resolve } from 'node:path'
3
-
4
- import { CONFIG_FILE, changeText, databaseOf, durationText, extendTurn, isWorkspace, lastEventSeq, loadEnvironment, outbox, readConfig, RUNNING, runningLines, send, settleOutbox, show, SporeError, stateOf, stop, turnsSince, watchAll, workspaceFor, wrapTurn } from '@rhizogrip/spore'
5
- import { allowed, detailedChats, tokenVariables } from './bots.js'
6
- import { createDelivery } from './delivery.js'
7
- import { announce, pieces, render, reply, track, waiting } from './forward.js'
8
- import { loadState, saveState } from './state.js'
9
-
10
- const MAX_DOCUMENT = 50 * 1024 * 1024
11
- const leftOut = (more) => `and ${more} more file${more === 1 ? '' : 's'} in data/out/, in the spore's workspace`
12
- const COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
13
- const NOT_MODIFIED = /message is not modified/
14
- const TAKES_ARGUMENTS = new Set(['extend', 'wrap'])
15
- const HELP = 'Anything you write here goes to the spore. /status says what it is doing; /stop ends the turn it is running; /wrap [10m] asks it to wrap up; /extend wall 30m gives it more, as far as its spore.cmk allows.'
16
-
17
- const pause = (ms, signal) => new Promise(resolve => {
18
- const timer = setTimeout(resolve, ms)
19
- signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
20
- })
21
-
22
- const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
23
-
24
- /**
25
- * One bot, routed by `botFile`. `env` is the environment the service was started with, before
26
- * `environment` beside `config.json` was read: that file is read again for every turn, so a key
27
- * added to it reaches the next turn without a restart, and a variable the service was started with
28
- * still wins. No bot's token is passed on.
29
- */
30
- export function createService ({ telegram, botFile, statePath, env, log, configFile = CONFIG_FILE, sleep = pause, editEveryMs = 2500, followEveryMs = 1000 }) {
31
- const sendEnv = () => {
32
- const fresh = { ...env }
33
- loadEnvironment(configFile, fresh)
34
- for (const variable of tokenVariables(configFile)) delete fresh[variable]
35
- return fresh
36
- }
37
- const replyTo = `telegram:${basename(botFile, '.json')}/`
38
- const state = loadState(statePath)
39
- const stopping = new AbortController()
40
- const delivery = createDelivery({ sleep: (ms) => sleep(ms, stopping.signal), signal: stopping.signal })
41
- const notes = new WeakMap()
42
- const shown = new Map()
43
- let chatsOf = new Map()
44
- let detailed = new Set()
45
- let routeFailure = null
46
-
47
- const save = () => saveState(statePath, state)
48
- const noteOf = (entry) => notes.get(entry) ?? Promise.resolve(entry.message)
49
-
50
- const route = () => {
51
- const config = readConfig(configFile)
52
- return { config, table: allowed(botFile), detailed: detailedChats(botFile) }
53
- }
54
- const sporeOf = (name, config) => {
55
- const ws = workspaceFor(name, { config }).path
56
- return isWorkspace(ws) ? ws : null
57
- }
58
-
59
- const tell = (chat, text) => delivery.to(chat, async (attempt) => {
60
- for (const part of pieces(text)) await attempt(() => telegram.say(chat, part))
61
- }).catch(err => log(`chat ${chat}: not delivered — ${said(err)}`))
62
-
63
- const edit = async (attempt, chat, message, text) => {
64
- const [first, ...rest] = pieces(text)
65
- if (message === null) {
66
- await attempt(() => telegram.say(chat, first))
67
- } else {
68
- try {
69
- await attempt(() => telegram.revise(chat, message, first))
70
- } catch (err) {
71
- if (!NOT_MODIFIED.test(err.detail ?? '')) {
72
- if (err.retryable) throw err
73
- log(`chat ${chat}: message ${message} cannot be edited (${err.detail}), sending the text anew`)
74
- await attempt(() => telegram.say(chat, first))
75
- }
76
- }
77
- }
78
- for (const part of rest) await attempt(() => telegram.say(chat, part))
79
- }
80
-
81
- const hand = async (attempt, chat, ws, { files, more }) => {
82
- for (const file of files) {
83
- const path = resolve(ws, file.path)
84
- if (!existsSync(path)) {
85
- await attempt(() => telegram.say(chat, `${file.path} is no longer there`))
86
- continue
87
- }
88
- const bytes = statSync(path).size
89
- if (bytes > MAX_DOCUMENT) {
90
- await attempt(() => telegram.say(chat, `${file.path} is ${(bytes / 1024 / 1024).toFixed(0)} MB, more than Telegram takes; it is in the spore's workspace`))
91
- continue
92
- }
93
- await attempt(() => telegram.document(chat, { path, mime: file.mime }, file.path))
94
- }
95
- if (more) await attempt(() => telegram.say(chat, leftOut(more)))
96
- }
97
-
98
- const status = (ws) => {
99
- const one = show({ ws, configFile })
100
- return [
101
- `${one.name} · ${one.model}`,
102
- `${one.state}${one.draining ? ', finishing' : ''} · ${one.lastTurnNo} turns · ${one.billing !== null && one.billing.kind !== 'metered' ? `not metered · ${one.billing.label}` : `$${one.treeUsd.toFixed(4)}`}`,
103
- ...(one.turnLimits ? runningLines(one.turnLimits, one.extend) : []),
104
- one.last ? `last: ${[one.last.stopReason, one.last.error].filter(Boolean).join(' · ')}` : null,
105
- one.waiting ? `${one.waiting} waiting` : null,
106
- one.paused ? `paused${one.paused.at ? ` since ${one.paused.at}` : ''}${one.paused.why ? ` (${one.paused.why})` : ''}` : null,
107
- one.held.length ? `${one.held.length} routed to wait` : null,
108
- ...one.jobs.running.map(job => `job ${job.name}: ${durationText(Date.now() - Date.parse(job.startedAt))} of ${durationText(job.wallMs)}${job.progress ? ` · ${job.progress}` : ''}`)
109
- ].filter(Boolean).join('\n')
110
- }
111
-
112
- const change = (chat, ws, act) => {
113
- let answer
114
- try {
115
- answer = act()
116
- } catch (err) {
117
- return tell(chat, said(err))
118
- }
119
- return tell(chat, changeText(answer))
120
- }
121
-
122
- const obey = (chat, ws, command, args = '') => {
123
- if (command === 'stop') return tell(chat, stop(ws).stopped ? 'stopping it' : 'nothing is running')
124
- if (command === 'status') return tell(chat, status(ws))
125
- if (command === 'wrap') return change(chat, ws, () => wrapTurn(ws, args.trim() || undefined))
126
- if (command === 'extend') {
127
- const [dimension, amount] = args.trim().split(/\s+/)
128
- if (!dimension) return tell(chat, '/extend wall 30m, /extend cost 1.50 or /extend rounds 20')
129
- return change(chat, ws, () => extendTurn(ws, dimension, amount))
130
- }
131
- return tell(chat, HELP)
132
- }
133
-
134
- const follows = (ws) => {
135
- if (state.cursors[ws]) return false
136
- state.cursors[ws] = { seq: lastEventSeq(ws), turn: stateOf(ws).lastTurnNo, database: databaseOf(ws) }
137
- return true
138
- }
139
-
140
- const pass = (chat, ws, text, update) => {
141
- if (follows(ws)) save()
142
- let sent
143
- try {
144
- sent = send(ws, text, { requestId: `telegram:${update}`, replyTo: `${replyTo}${chat}`, env: sendEnv() })
145
- } catch (err) {
146
- log(`chat ${chat}: send refused — ${err.type} ${said(err)}`)
147
- return tell(chat, err.type === 'ADMISSION_FULL' ? `busy — ${said(err)}` : `not delivered — ${said(err)}`)
148
- }
149
- if (sent.repeated) return log(`chat ${chat}: update ${update} was already delivered, as inbox ${sent.inbox}`)
150
-
151
- const entry = { inbox: sent.inbox, chat, message: null }
152
- ;(state.pending[ws] ??= []).push(entry)
153
- save()
154
- notes.set(entry, delivery.to(chat, attempt => attempt(() => telegram.say(chat, sent.started ? 'working…' : 'heard you — still working…')))
155
- .then(note => note.message_id, err => {
156
- log(`chat ${chat}: no working message — ${said(err)}`)
157
- return null
158
- })
159
- .then(message => {
160
- entry.message = message
161
- save()
162
- return message
163
- }))
164
- }
165
-
166
- const handle = async (update) => {
167
- const message = update.message
168
- const chat = message?.chat?.id
169
- if (chat === undefined) return
170
-
171
- let read
172
- try {
173
- read = route()
174
- } catch (err) {
175
- return log(`refused chat ${chat}: the allow-list is unreadable — ${said(err)}`)
176
- }
177
- const name = read.table[String(chat)]
178
- if (!name) {
179
- const who = message.from?.username ? ` (@${message.from.username})` : ''
180
- return log(`refused chat ${chat}${who} — to allow it, add "${chat}": "<a spore>" to the chats of ${botFile}`)
181
- }
182
- let ws
183
- try {
184
- ws = sporeOf(name, read.config)
185
- } catch (err) {
186
- log(`chat ${chat}: "${name}" — ${said(err)}`)
187
- return tell(chat, `this chat points at "${name}", which is not a spore name — tell whoever runs me`)
188
- }
189
- if (!ws) {
190
- log(`chat ${chat}: no spore "${name}"`)
191
- return tell(chat, `there is no spore called "${name}" — tell whoever runs me`)
192
- }
193
-
194
- const text = message.text?.trim()
195
- if (!text) return tell(chat, 'only text reaches a spore for now')
196
- const command = text.match(COMMAND)
197
- const asked = command?.[1].toLowerCase()
198
- if (command && (!command[3] || TAKES_ARGUMENTS.has(asked))) return obey(chat, ws, asked, command[3])
199
- pass(chat, ws, text, update.update_id)
200
- }
201
-
202
- const listen = async () => {
203
- const { signal } = stopping
204
- let offset
205
- let backoff = 1000
206
- while (!signal.aborted) {
207
- let updates
208
- try {
209
- updates = await telegram.updates(offset, { signal })
210
- backoff = 1000
211
- } catch (err) {
212
- if (signal.aborted) break
213
- if (err.context?.status === 409) {
214
- throw new SporeError('another process is taking this bot\'s updates', { type: 'TELEGRAM_CONFLICT', detail: err.detail })
215
- }
216
- if (!err.retryable) throw err
217
- log(`poll failed — ${said(err)}`)
218
- await sleep(err.context?.retryAfter ? err.context.retryAfter * 1000 : backoff, signal)
219
- backoff = Math.min(backoff * 2, 60_000)
220
- continue
221
- }
222
- for (const update of updates) {
223
- try {
224
- await handle(update)
225
- } catch (err) {
226
- log(`update ${update.update_id}: ${said(err)}`)
227
- const chat = update.message?.chat?.id
228
- if (chat !== undefined) tell(chat, `that failed — ${err.message}`)
229
- }
230
- offset = update.update_id + 1
231
- }
232
- }
233
- if (offset !== undefined) {
234
- await telegram.updates(offset, { seconds: 0 }).catch(err => log(`could not confirm the last updates — ${said(err)}`))
235
- }
236
- }
237
-
238
- const spores = () => {
239
- let read
240
- try {
241
- read = route()
242
- routeFailure = null
243
- } catch (err) {
244
- if (routeFailure !== err.message) log(`allow-list: ${said(err)} — following the spores it last named`)
245
- routeFailure = err.message
246
- return [...chatsOf.keys()]
247
- }
248
- detailed = read.detailed
249
- const next = new Map()
250
- for (const [chat, name] of Object.entries(read.table)) {
251
- let ws
252
- try {
253
- ws = sporeOf(name, read.config)
254
- } catch {
255
- // Told to the chat, and logged, when it next speaks.
256
- continue
257
- }
258
- if (ws) next.set(ws, [...(next.get(ws) ?? []), Number(chat)])
259
- }
260
- let moved = false
261
- for (const ws of Object.keys(state.cursors)) {
262
- if (next.has(ws)) continue
263
- delete state.cursors[ws]
264
- delete state.pending[ws]
265
- moved = true
266
- }
267
- for (const ws of next.keys()) moved = follows(ws) || moved
268
- if (moved) save()
269
- chatsOf = next
270
- return [...next.keys()]
271
- }
272
-
273
- const repaint = (ws) => {
274
- const lines = shown.get(ws)
275
- lines.timer = null
276
- lines.at = Date.now()
277
- for (const entry of state.pending[ws] ?? []) {
278
- const text = ['working…', ...render(lines.items, { detailed: detailed.has(entry.chat) })].join('\n')
279
- delivery.to(entry.chat, async (attempt) => {
280
- const message = await noteOf(entry)
281
- if (message === null || !state.pending[ws]?.includes(entry)) return
282
- await attempt(() => telegram.revise(entry.chat, message, text))
283
- }).catch(err => { if (!NOT_MODIFIED.test(err.detail ?? '')) log(`chat ${entry.chat}: progress not shown — ${said(err)}`) })
284
- }
285
- }
286
-
287
- const onEvent = (ws, event) => {
288
- if (!state.pending[ws]?.length) return
289
- const lines = shown.get(ws) ?? { items: [], timer: null, at: 0 }
290
- shown.set(ws, lines)
291
- if (!track(lines.items, event)) return
292
- if (!lines.timer) lines.timer = setTimeout(() => repaint(ws), Math.max(0, editEveryMs - (Date.now() - lines.at)))
293
- }
294
-
295
- const forget = (ws, notes) => {
296
- const kept = (state.pending[ws] ?? []).filter(note => !notes.includes(note))
297
- if (kept.length) state.pending[ws] = kept
298
- else delete state.pending[ws]
299
- save()
300
- }
301
-
302
- /**
303
- * One outbox entry of this bot's posted to its chat — into the `working…` messages of what it
304
- * answers when the chat has them — and settled: delivered once Telegram took it, refused when
305
- * the chat does not reach this spore or Telegram will not take it. One Telegram still did not
306
- * take when its retries ran out stays pending for the next pass. True when it is settled.
307
- */
308
- const deliver = async (ws, entry) => {
309
- const chat = Number(entry.recipient.slice(replyTo.length))
310
- const notes = (state.pending[ws] ?? []).filter(note => note.chat === chat && entry.answers.includes(note.inbox))
311
- if (!(chatsOf.get(ws) ?? []).includes(chat)) {
312
- const reason = `chat ${entry.recipient.slice(replyTo.length)} does not reach this spore through ${botFile}`
313
- settleOutbox(ws, entry.outboxNo, { refused: reason })
314
- log(`turn ${entry.turnNo} of ${ws}: refused — ${reason}`)
315
- forget(ws, notes)
316
- return true
317
- }
318
- const turn = turnsSince(ws, entry.turnNo - 1).ended[0]
319
- const heard = turn.asked.filter(message => entry.answers.includes(message.inboxNo))
320
- const text = heard.some(message => message.replyTo) ? reply(turn) : announce({ record: turn.record, asked: heard })
321
- const handed = { files: turn.record.deliverables ?? [], more: turn.record.deliverablesMore ?? 0 }
322
- try {
323
- await delivery.to(chat, async (attempt) => {
324
- if (notes.length) {
325
- for (const note of notes) await edit(attempt, chat, await noteOf(note), text)
326
- } else {
327
- for (const part of pieces(text)) await attempt(() => telegram.say(chat, part))
328
- }
329
- await hand(attempt, chat, ws, handed)
330
- })
331
- } catch (err) {
332
- if (err.retryable || stopping.signal.aborted) {
333
- log(`turn ${entry.turnNo} of ${ws}: not delivered to chat ${chat} yet — ${said(err)}`)
334
- return false
335
- }
336
- settleOutbox(ws, entry.outboxNo, { refused: said(err) })
337
- log(`turn ${entry.turnNo} of ${ws}: chat ${chat} refused it — ${said(err)}`)
338
- forget(ws, notes)
339
- return true
340
- }
341
- settleOutbox(ws, entry.outboxNo)
342
- forget(ws, notes)
343
- return true
344
- }
345
-
346
- /**
347
- * This bot's pending outbox entries of `ws`, delivered in order: the messages still owed an
348
- * answer, or null when the spore's database could not be read or written — a busy one — and
349
- * what it holds waits for the next pass.
350
- */
351
- const drain = async (ws) => {
352
- const owed = new Set()
353
- try {
354
- for (const entry of outbox(ws, { kind: 'telegram', state: 'pending' })) {
355
- if (!entry.recipient.startsWith(replyTo)) continue
356
- if (!(await deliver(ws, entry))) entry.answers.forEach(inboxNo => owed.add(inboxNo))
357
- }
358
- } catch (err) {
359
- log(`${ws}: its outbox could not be read or settled — ${said(err)}`)
360
- return null
361
- }
362
- return owed
363
- }
364
-
365
- const onTurn = async (ws, turn) => {
366
- clearTimeout(shown.get(ws)?.timer)
367
- shown.delete(ws)
368
-
369
- const read = new Set(turn.asked.map(entry => entry.inboxNo))
370
- const left = (state.pending[ws] ?? []).filter(entry => !read.has(entry.inbox))
371
- if (left.length && stateOf(ws).state !== RUNNING) {
372
- const work = left.map(entry => delivery.to(entry.chat, async (attempt) => edit(attempt, entry.chat, await noteOf(entry), waiting(turn))))
373
- for (const outcome of await Promise.allSettled(work)) {
374
- if (outcome.status === 'rejected') log(`turn ${turn.record.turnNo} of ${ws}: not told it is waiting — ${said(outcome.reason)}`)
375
- }
376
- }
377
-
378
- const owed = await drain(ws)
379
- if (owed) forget(ws, (state.pending[ws] ?? []).filter(note => read.has(note.inbox) && !owed.has(note.inbox)))
380
- }
381
-
382
- const follow = async () => {
383
- const { signal } = stopping
384
- for (const ws of spores()) await drain(ws)
385
- for await (const item of watchAll(spores, { cursors: state.cursors, tick: followEveryMs, signal })) {
386
- if (item.event) {
387
- onEvent(item.ws, item.event)
388
- continue
389
- }
390
- await onTurn(item.ws, item.turn)
391
- if (signal.aborted) break
392
- state.cursors[item.ws] = item.cursor
393
- save()
394
- }
395
- }
396
-
397
- const run = async (signal) => {
398
- signal.addEventListener('abort', () => stopping.abort(), { once: true })
399
- const failed = []
400
- const halt = (err) => {
401
- failed.push(err)
402
- stopping.abort()
403
- }
404
- await Promise.all([listen().catch(halt), follow().catch(halt)])
405
- for (const lines of shown.values()) clearTimeout(lines.timer)
406
- save()
407
- if (failed.length) throw failed[0]
408
- }
409
-
410
- return { run }
411
- }
package/src/state.js DELETED
@@ -1,33 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
2
- import { homedir } from 'node:os'
3
- import { dirname, join } from 'node:path'
4
-
5
- import { SporeError } from '@rhizogrip/spore'
6
-
7
- export const statePath = (bot, env = process.env) =>
8
- join(env.XDG_STATE_HOME || join(homedir(), '.local', 'state'), 'spore', 'telegram', bot, 'state.json')
9
-
10
- /**
11
- * `cursors` is `watchAll`'s, per workspace. `pending` is, per workspace, every message a chat
12
- * sent that no turn has answered yet: its `inbox` number, the chat, and the `working…`
13
- * message the answer is edited into.
14
- */
15
- export function loadState (path) {
16
- if (!existsSync(path)) return { cursors: {}, pending: {} }
17
- try {
18
- const { cursors, pending } = JSON.parse(readFileSync(path, 'utf8'))
19
- return { cursors, pending }
20
- } catch (err) {
21
- throw new SporeError(`${path} is not readable`, {
22
- type: 'TELEGRAM_STATE_INVALID',
23
- detail: `${err.message} — it holds where the bot stood; move it aside to start from now`
24
- })
25
- }
26
- }
27
-
28
- export function saveState (path, { cursors, pending }) {
29
- mkdirSync(dirname(path), { recursive: true })
30
- const written = `${path}.${process.pid}`
31
- writeFileSync(written, JSON.stringify({ cursors, pending }, null, 2))
32
- renameSync(written, path)
33
- }