@rhizogrip/spore-plugin-telegram 0.2.0 → 0.4.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
@@ -1,8 +1,7 @@
1
1
  # @rhizogrip/spore-plugin-telegram
2
2
 
3
3
  A service that puts your spores in Telegram chats. It runs on the machine the spores are
4
- on, once per bot, as a systemd user unit, and it is an application against the package:
5
- everything it does goes through `@rhizogrip/spore`'s exports. It gives spores nothing, so it needs
4
+ on, once per bot, as a systemd user unit. It gives spores nothing, so it needs
6
5
  no `spore plugin allow`: `spore service` offers it once it is installed.
7
6
 
8
7
  ```bash
@@ -29,37 +28,40 @@ passed to a spore's runner.
29
28
  ## What it guarantees
30
29
 
31
30
  - **A message reaches its spore once.** An update is confirmed to Telegram only after
32
- `send` has taken it, and it goes to `send` under `telegram:<update_id>` as its
33
- `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.
34
34
  - **A finished turn reaches its chat at least once.** What it owes a chat is an entry in the
35
35
  spore's outbox, settled only after Telegram accepted it; a crash between the two repeats a
36
36
  message rather than losing one. One Telegram will not take is settled refused, with
37
37
  Telegram's reason, and one it still had not taken when its retries ran out is tried again
38
38
  after the spore's next turn or the service's next start.
39
- - **A reply is its own turn's.** The answer, how the turn ended and what it handed back
40
- come from the turn as `watchAll` yields it — its record, whose `output` is the answer, and
41
- the messages it answered as `asked`.
42
- - **A restart loses nothing.** A turn running when the service stopped finishes into the
43
- chat's `working…` message; a turn that ended while it was down is posted when it is back.
39
+ - **A reply never mixes turns.** Each chat message gets the answer, the end state and the files
40
+ of the turn that answered it.
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.
44
43
  - **Nothing fails in silence.** A message a spore refuses is told to the chat; a message
45
44
  Telegram will not take is logged with its chat and turn.
46
45
 
47
46
  ## A turn in a chat
48
47
 
49
- Write to the bot and the message goes to the spore the chat is mapped to. The bot posts
50
- `working…`, shows which tools run as they run, and edits the answer into that message when
51
- the turn ends. How the turn ended is added only when it makes the answer partial — a wrap
52
- or a stop. A longer answer than Telegram's 4096 continues in further messages. What the
53
- turn left in `data/out/` follows as documents; a file over Telegram's 50 MB is named
54
- instead.
55
-
56
- A message sent while a turn runs lands between its rounds, because that is what `send`
57
- does, and its `working…` message says it was heard. A turn ending in a question posts the
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`. How
53
+ the turn ended is added only when it makes the answer partial — a wrap or a stop. A longer answer
54
+ than Telegram's 4096 continues in further messages. What the turn left in `data/out/` follows as
55
+ documents, up to the 50 a turn hands back, then a line counting the rest; a file over Telegram's
56
+ 50 MB is named instead.
57
+
58
+ A message sent while a turn runs lands between its rounds, and its `working…` message says it
59
+ was heard. A turn ending in a question posts the
58
60
  question, and the reply answers it. The conversation is the spore's transcript; the service
59
61
  keeps none.
60
62
 
61
63
  A turn that ends without reading your message — one that failed before it got there —
62
- says so in your `working…` message, and the next turn answers it there.
64
+ says so in your `working…` message, and the next turn answers it.
63
65
 
64
66
  ```
65
67
  /status what the spore is doing, its turns and what they cost, and the running turn's limits
@@ -68,7 +70,8 @@ says so in your `working…` message, and the next turn answers it there.
68
70
  /extend wall 30m give it more — wall, cost or rounds — up to the extend.<limit> its spore.cmk grants
69
71
  ```
70
72
 
71
- Only text reaches a spore for now.
73
+ Only text reaches a spore for now: a photo's or a document's caption goes to it, and you are told
74
+ the file did not; a message with nothing written in it is told only text reaches a spore.
72
75
 
73
76
  ## Turns nobody in a chat started
74
77
 
@@ -84,18 +87,18 @@ except when the turn ended `quiet`, which chose to say nothing. A turn that ende
84
87
  answer any other way is told. A spore with no default tells no chat. A default naming a chat
85
88
  that does not reach the spore through this bot is refused, and the log says why.
86
89
 
87
- A turn a terminal asked for is the terminal's: `spore send` follows it, and no chat is told.
90
+ A turn started with `spore send` is shown only there; no chat is told.
88
91
 
89
92
  ## The allow-list is the routing
90
93
 
91
94
  A bot's `chats` say which chats may talk to it and which spore each reaches, as
92
95
  `{ "<chat id>": "<spore>" }`. They are read on every message and followed as they change, so
93
96
  a chat is added, moved or removed by editing a line. It lives beside `config.json` and
94
- `sources.json`: it says who reaches what, and nothing a confined agent reads may say that.
97
+ `sources.json`. Spores cannot read or change it.
95
98
 
96
99
  While a turn works, its chat is shown what kind of call ran — `reading a file`, `running a
97
100
  command`, `asking forgejo` by the source's name, a run of the same one counted, a slow one
98
- timed, a failed one marked — and never what it was given: a Telegram message leaves the
101
+ timed, a command that exited non-zero given its exit code, a call that failed marked — and never what it was given: a Telegram message leaves the
99
102
  machine, and a command line or a path is yours. A chat can ask for the calls themselves:
100
103
 
101
104
  ```json
@@ -118,24 +121,18 @@ refused chat 148392011 (@alice) — to allow it, add "148392011": "<a spore>" to
118
121
  which is also how you learn your own chat id: start with `"chats": {}` and message the bot. A
119
122
  group's id is negative, and it is the group you add rather than the people in it.
120
123
 
121
- ## What a chat is told
122
-
123
- Which tool ran, and whether it worked — never its arguments or its result. On a console
124
- those are the point; in a chat they are a path or a command line leaving the machine.
125
- `forward.js` is that rule. The answer and the stop reason cross in full.
126
-
127
124
  ## What it keeps
128
125
 
129
- `$XDG_STATE_HOME/spore/telegram/<bot>/state.json`, one per bot, written whole and renamed into
130
- 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
131
- yet, with the `working…` message its answer goes into. What a turn owes a chat is not kept
132
- here: it is the spore's outbox. A spore it starts following starts from where it is. Nothing
133
- is kept in the repository or in the spores' home.
126
+ `$XDG_STATE_HOME/spore/telegram/<bot>/state.json`, one per bot, mode 0600, written whole and
127
+ renamed into place: where it stands in each spore it follows, in that spore's database, and every
128
+ message a chat sent that no turn has answered yet, with its `working…` message. A spore added later is followed from its
129
+ latest turn; earlier answers are not posted. Nothing is kept in the repository or in the spores'
130
+ home.
134
131
 
135
132
  ## Busy
136
133
 
137
134
  A turn that would start with no admission slot free is refused, and the chat is told it is
138
- busy. There is no bound of the service's own: the user's slots are the one bound, shared with every other caller.
135
+ busy. The only limit is the machine's admission slots, which every other caller shares.
139
136
 
140
137
  ## Running it as a service
141
138
 
@@ -146,18 +143,16 @@ spore service enable telegram@triage # and at every login
146
143
  spore service log telegram@triage -f
147
144
  ```
148
145
 
149
- `define` runs the service's own start-up checks (`spore-telegram.js <bot> --check`) first, so a
150
- missing bot file or token is refused there rather than as a unit that keeps failing. The unit it
146
+ `define` first runs the service's start-up checks (`--check`) and refuses a missing bot file or
147
+ token. The unit it
151
148
  writes is the template `~/.config/systemd/user/spore-telegram@.service`, marked as spore's, and
152
149
  each bot is an instance of it; `spore service` never touches a unit it did not write. `spore
153
150
  service rm telegram@<bot>` stops and disables one bot; `spore service rm telegram` stops them all
154
151
  and removes the template — each given `--yes` when a bot it stops is running. `spore service ls` says where each service
155
152
  stands, and whether lingering is on — without it, user services stop when you log out.
156
153
 
157
- The unit keeps a restart of the service from being a stop of every turn it started
158
- (`KillMode=process`): their runners are in its control group, and systemd otherwise kills
159
- the whole group. The service picks them up again from what it saved. A refusal — exit 2 —
160
- is left refused rather than retried every few seconds.
154
+ Restarting the service does not stop the turns it started (`KillMode=process`). The service
155
+ picks them up again from what it saved. After a refusal (exit 2), systemd does not restart it.
161
156
 
162
157
  `~/.config/spore/environment` is what the service and every runner it starts need, since a
163
158
  systemd user session starts with almost nothing: each bot's token, `PATH`, the provider
@@ -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.2.0",
3
+ "version": "0.4.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",
@@ -28,13 +28,13 @@
28
28
  "NOTICE"
29
29
  ],
30
30
  "peerDependencies": {
31
- "@rhizogrip/spore": "^1"
31
+ "@rhizogrip/spore": "^1.15.0"
32
32
  },
33
33
  "scripts": {
34
34
  "test": "vitest run"
35
35
  },
36
36
  "spore": {
37
- "works": "^1",
37
+ "works": "^1.15.0",
38
38
  "services": {
39
39
  "telegram": {
40
40
  "what": "a bot of yours in Telegram chats, each chat reaching its spore",
package/src/adapter.js ADDED
@@ -0,0 +1,141 @@
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
+ const COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
10
+ const NOT_MODIFIED = /message is not modified/
11
+ const SHAPE = '{ "token": "${TRIAGE_BOT_TOKEN}", "chats": { "<chat id>": "<spore>" } }' // eslint-disable-line no-template-curly-in-string
12
+
13
+ const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
14
+
15
+ const invalid = (path, problem, detail) => new SporeError(`${path} ${problem}`, { type: 'TELEGRAM_BOT_INVALID', detail })
16
+
17
+ /**
18
+ * `{ "<chat id>": "<spore>" }`, or `{ "<chat id>": { "spore": "<spore>", "details": true } }` for a
19
+ * chat shown what each call does — its command or path — rather than only what kind of call it
20
+ * is: a Telegram message leaves the machine, so that is a choice made per chat.
21
+ */
22
+ function routes (raw, path) {
23
+ if (typeof raw.chats !== 'object' || raw.chats === null || Array.isArray(raw.chats)) {
24
+ throw invalid(path, 'has no chats', 'chats is { "<chatId>": "<spore>" } — {} while you learn your chat id: the log names every chat it refuses')
25
+ }
26
+ const table = new Map()
27
+ for (const [chat, entry] of Object.entries(raw.chats)) {
28
+ const spore = typeof entry === 'string' ? entry : entry?.spore
29
+ const extra = typeof entry === 'object' && entry !== null ? Object.keys(entry).filter(key => !['spore', 'details'].includes(key)) : []
30
+ if (typeof spore !== 'string' || !spore || extra.length || (typeof entry === 'object' && entry.details !== undefined && typeof entry.details !== 'boolean')) {
31
+ 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 }')
32
+ }
33
+ table.set(chat, { spores: [spore], details: typeof entry === 'object' && entry.details === true })
34
+ }
35
+ return table
36
+ }
37
+
38
+ 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.'
39
+
40
+ export const TELEGRAM = {
41
+ kind: 'telegram',
42
+ label: 'Telegram',
43
+ noun: 'chat',
44
+ fields: ['token', 'chats'],
45
+ credentials: ['token'],
46
+ stateFields: [],
47
+ sourceVariables: [],
48
+ refusals: ['TELEGRAM_CONFLICT'],
49
+ example: SHAPE,
50
+ routes,
51
+ usage: { help: HELP, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
52
+ limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500 },
53
+ can: { upload: true, remove: true },
54
+ hint (err, { file, bot }) {
55
+ if (err.type === 'TELEGRAM_NO_BOT') {
56
+ const variable = `${bot.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BOT_TOKEN`
57
+ 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`
58
+ }
59
+ if (err.type === 'TELEGRAM_FAILED') return `the token comes from @BotFather, in the variable ${file} names`
60
+ if (err.type === 'TELEGRAM_CONFLICT') return 'one bot token is read by one process — stop the other one, or remove its webhook'
61
+ return null
62
+ },
63
+ refusal: (message, botFile) =>
64
+ `refused chat ${message.conversation}${message.person.username ? ` (@${message.person.username})` : ''} — to allow it, add "${message.conversation}": "<a spore>" to the chats of ${botFile}`
65
+ }
66
+
67
+ const FILES = ['photo', 'document', 'video', 'audio', 'voice', 'video_note', 'animation', 'sticker']
68
+
69
+ export function normalize (update, bot) {
70
+ const message = update.message
71
+ if (message?.chat?.id === undefined) return null
72
+ const text = (message.text ?? message.caption ?? '').trim() || null
73
+ const command = text?.match(COMMAND)
74
+ const { first_name: first, last_name: last, username } = message.from
75
+ return {
76
+ id: `${bot}:${update.update_id}`,
77
+ conversation: String(message.chat.id),
78
+ message: message.message_id,
79
+ person: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username },
80
+ where: message.chat.type === 'private' ? 'a private chat on Telegram' : `the Telegram group "${message.chat.title}"`,
81
+ text,
82
+ command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
83
+ addresses: null,
84
+ files: FILES.filter(kind => message[kind]).length
85
+ }
86
+ }
87
+
88
+ export function createAdapter ({ telegram, bot }) {
89
+ return {
90
+ ...TELEGRAM,
91
+
92
+ /**
93
+ * One long poll after another until `signal` aborts. An update is handed to `onMessage` and
94
+ * awaited before the offset moves past it, since moving it is what confirms the update to
95
+ * Telegram: a message is confirmed only once the spore has it.
96
+ */
97
+ async listen ({ onMessage, signal, log, sleep }) {
98
+ let offset
99
+ let backoff = 1000
100
+ while (!signal.aborted) {
101
+ let updates
102
+ try {
103
+ updates = await telegram.updates(offset, { signal })
104
+ backoff = 1000
105
+ } catch (err) {
106
+ if (signal.aborted) break
107
+ if (err.context?.status === 409) {
108
+ throw new SporeError('another process is taking this bot\'s updates', { type: 'TELEGRAM_CONFLICT', detail: err.detail })
109
+ }
110
+ if (!err.retryable) throw err
111
+ log(`poll failed — ${said(err)}`)
112
+ await sleep(err.context?.retryAfter ? err.context.retryAfter * 1000 : backoff)
113
+ backoff = Math.min(backoff * 2, 60_000)
114
+ continue
115
+ }
116
+ for (const update of updates) {
117
+ const message = normalize(update, bot)
118
+ if (message) await onMessage(message)
119
+ offset = update.update_id + 1
120
+ }
121
+ }
122
+ if (offset !== undefined) {
123
+ await telegram.updates(offset, { seconds: 0 }).catch(err => log(`could not confirm the last updates — ${said(err)}`))
124
+ }
125
+ },
126
+
127
+ post: async (conversation, text, { answering = null } = {}) => (await telegram.say(Number(conversation), text, { answering })).message_id,
128
+
129
+ edit: async (conversation, note, text) => {
130
+ try {
131
+ await telegram.revise(Number(conversation), note, text)
132
+ } catch (err) {
133
+ if (!NOT_MODIFIED.test(err.detail ?? '')) throw err
134
+ }
135
+ },
136
+
137
+ remove: (conversation, note) => telegram.remove(Number(conversation), note),
138
+
139
+ upload: (conversation, file, caption) => telegram.document(Number(conversation), file, caption)
140
+ }
141
+ }
package/src/api.js CHANGED
@@ -51,11 +51,18 @@ 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 } = {}) => call('sendMessage', {
55
+ chat_id: chat,
56
+ text,
57
+ link_preview_options: { is_disabled: true },
58
+ ...(answering === null ? {} : { reply_parameters: { message_id: answering, allow_sending_without_reply: true } })
59
+ }),
55
60
 
56
61
  revise: (chat, message, text) =>
57
62
  call('editMessageText', { chat_id: chat, message_id: message, text, link_preview_options: { is_disabled: true } }),
58
63
 
64
+ remove: (chat, message) => call('deleteMessage', { chat_id: chat, message_id: message }),
65
+
59
66
  document: (chat, { path, mime }, caption) => {
60
67
  const form = new FormData()
61
68
  form.append('chat_id', String(chat))
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,136 +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.failed = Boolean(event.error)
75
- return item.failed || item.durationMs >= SLOW_MS
76
- }
77
- const note = (text) => {
78
- items.push({ plain: text, detailed: text })
79
- return true
80
- }
81
- if (event.event === 'wrap') return note(`wrapping up — reached its ${event.dimension} limit`)
82
- if (event.event === 'interject') return note('heard you')
83
- return false
84
- }
85
-
86
- /** The lines a chat sees, the last few, a run of the same call counted rather than repeated. */
87
- export function render (items, { detailed = false, last = 6 } = {}) {
88
- const lines = []
89
- for (const item of items) {
90
- const text = (detailed ? item.detailed : item.plain) +
91
- (item.failed ? ' — failed' : '') +
92
- (item.durationMs >= SLOW_MS ? ` — ${Math.round(item.durationMs / 1000)} s` : '')
93
- const previous = lines.at(-1)
94
- if (previous?.text === text) previous.count++
95
- else lines.push({ text, count: 1 })
96
- }
97
- return lines.slice(-last).map(({ text, count }) => `· ${text}${count > 1 ? ` ×${count}` : ''}`)
98
- }
99
-
100
- const ending = (record) => [record.stopReason, record.error].filter(Boolean).join(' · ')
101
-
102
- export function reply ({ record }) {
103
- const { output } = record
104
- if (output) return PLAIN_ENDINGS.has(record.stopReason) ? output : `${output}\n\n— ${ending(record)}`
105
- if (record.stopReason === 'quiet') return 'nothing to say'
106
- return `no answer · ${ending(record)}`
107
- }
108
-
109
- /** What a chat is told of a turn that answered `asked`, none of it said in a chat: who said it, then the answer. */
110
- export function announce ({ record, asked }) {
111
- const named = (field) => [...new Set(asked.filter(entry => entry[field]).map(entry => `"${entry[field]}"`))]
112
- const origins = [
113
- named('schedule').length ? `schedule ${named('schedule').join(', ')}` : null,
114
- named('hook').length ? `webhook ${named('hook').join(', ')}` : null,
115
- named('job').length ? `job ${named('job').join(', ')}` : null
116
- ].filter(Boolean)
117
- return `${origins.length ? origins.join(' · ') : 'sent from elsewhere'}\n\n${reply({ record })}`
118
- }
119
-
120
- /** A turn ended without reading this chat's message, and nothing is running to read it. */
121
- export function waiting ({ record }) {
122
- return `no answer yet · ${ending(record)} — your message waits for the next turn`
123
- }
124
-
125
- export function pieces (text, limit = MAX_MESSAGE) {
126
- const out = []
127
- let rest = String(text)
128
- while (rest.length > limit) {
129
- const cut = rest.lastIndexOf('\n', limit)
130
- const at = cut > limit / 2 ? cut : limit
131
- out.push(rest.slice(0, at))
132
- rest = rest.slice(at).replace(/^\n/, '')
133
- }
134
- if (rest) out.push(rest)
135
- return out
136
- }
package/src/service.js DELETED
@@ -1,409 +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 COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
12
- const NOT_MODIFIED = /message is not modified/
13
- const TAKES_ARGUMENTS = new Set(['extend', 'wrap'])
14
- 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.'
15
-
16
- const pause = (ms, signal) => new Promise(resolve => {
17
- const timer = setTimeout(resolve, ms)
18
- signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
19
- })
20
-
21
- const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
22
-
23
- /**
24
- * One bot, routed by `botFile`. `env` is the environment the service was started with, before
25
- * `environment` beside `config.json` was read: that file is read again for every turn, so a key
26
- * added to it reaches the next turn without a restart, and a variable the service was started with
27
- * still wins. No bot's token is passed on.
28
- */
29
- export function createService ({ telegram, botFile, statePath, env, log, configFile = CONFIG_FILE, sleep = pause, editEveryMs = 2500, followEveryMs = 1000 }) {
30
- const sendEnv = () => {
31
- const fresh = { ...env }
32
- loadEnvironment(configFile, fresh)
33
- for (const variable of tokenVariables(configFile)) delete fresh[variable]
34
- return fresh
35
- }
36
- const replyTo = `telegram:${basename(botFile, '.json')}/`
37
- const state = loadState(statePath)
38
- const stopping = new AbortController()
39
- const delivery = createDelivery({ sleep: (ms) => sleep(ms, stopping.signal), signal: stopping.signal })
40
- const notes = new WeakMap()
41
- const shown = new Map()
42
- let chatsOf = new Map()
43
- let detailed = new Set()
44
- let routeFailure = null
45
-
46
- const save = () => saveState(statePath, state)
47
- const noteOf = (entry) => notes.get(entry) ?? Promise.resolve(entry.message)
48
-
49
- const route = () => {
50
- const config = readConfig(configFile)
51
- return { config, table: allowed(botFile), detailed: detailedChats(botFile) }
52
- }
53
- const sporeOf = (name, config) => {
54
- const ws = workspaceFor(name, { config }).path
55
- return isWorkspace(ws) ? ws : null
56
- }
57
-
58
- const tell = (chat, text) => delivery.to(chat, async (attempt) => {
59
- for (const part of pieces(text)) await attempt(() => telegram.say(chat, part))
60
- }).catch(err => log(`chat ${chat}: not delivered — ${said(err)}`))
61
-
62
- const edit = async (attempt, chat, message, text) => {
63
- const [first, ...rest] = pieces(text)
64
- if (message === null) {
65
- await attempt(() => telegram.say(chat, first))
66
- } else {
67
- try {
68
- await attempt(() => telegram.revise(chat, message, first))
69
- } catch (err) {
70
- if (!NOT_MODIFIED.test(err.detail ?? '')) {
71
- if (err.retryable) throw err
72
- log(`chat ${chat}: message ${message} cannot be edited (${err.detail}), sending the text anew`)
73
- await attempt(() => telegram.say(chat, first))
74
- }
75
- }
76
- }
77
- for (const part of rest) await attempt(() => telegram.say(chat, part))
78
- }
79
-
80
- const hand = async (attempt, chat, ws, files) => {
81
- for (const file of files) {
82
- const path = resolve(ws, file.path)
83
- if (!existsSync(path)) {
84
- await attempt(() => telegram.say(chat, `${file.path} is no longer there`))
85
- continue
86
- }
87
- const bytes = statSync(path).size
88
- if (bytes > MAX_DOCUMENT) {
89
- 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`))
90
- continue
91
- }
92
- await attempt(() => telegram.document(chat, { path, mime: file.mime }, file.path))
93
- }
94
- }
95
-
96
- const status = (ws) => {
97
- const one = show({ ws, configFile })
98
- return [
99
- `${one.name} · ${one.model}`,
100
- `${one.state}${one.draining ? ', finishing' : ''} · ${one.lastTurnNo} turns · ${one.billing !== null && one.billing.kind !== 'metered' ? `not metered · ${one.billing.label}` : `$${one.treeUsd.toFixed(4)}`}`,
101
- ...(one.turnLimits ? runningLines(one.turnLimits, one.extend) : []),
102
- one.last ? `last: ${[one.last.stopReason, one.last.error].filter(Boolean).join(' · ')}` : null,
103
- one.waiting ? `${one.waiting} waiting` : null,
104
- one.paused ? `paused${one.paused.at ? ` since ${one.paused.at}` : ''}${one.paused.why ? ` (${one.paused.why})` : ''}` : null,
105
- one.held.length ? `${one.held.length} routed to wait` : null,
106
- ...one.jobs.running.map(job => `job ${job.name}: ${durationText(Date.now() - Date.parse(job.startedAt))} of ${durationText(job.wallMs)}${job.progress ? ` · ${job.progress}` : ''}`)
107
- ].filter(Boolean).join('\n')
108
- }
109
-
110
- const change = (chat, ws, act) => {
111
- let answer
112
- try {
113
- answer = act()
114
- } catch (err) {
115
- return tell(chat, said(err))
116
- }
117
- return tell(chat, changeText(answer))
118
- }
119
-
120
- const obey = (chat, ws, command, args = '') => {
121
- if (command === 'stop') return tell(chat, stop(ws).stopped ? 'stopping it' : 'nothing is running')
122
- if (command === 'status') return tell(chat, status(ws))
123
- if (command === 'wrap') return change(chat, ws, () => wrapTurn(ws, args.trim() || undefined))
124
- if (command === 'extend') {
125
- const [dimension, amount] = args.trim().split(/\s+/)
126
- if (!dimension) return tell(chat, '/extend wall 30m, /extend cost 1.50 or /extend rounds 20')
127
- return change(chat, ws, () => extendTurn(ws, dimension, amount))
128
- }
129
- return tell(chat, HELP)
130
- }
131
-
132
- const follows = (ws) => {
133
- if (state.cursors[ws]) return false
134
- state.cursors[ws] = { seq: lastEventSeq(ws), turn: stateOf(ws).lastTurnNo, database: databaseOf(ws) }
135
- return true
136
- }
137
-
138
- const pass = (chat, ws, text, update) => {
139
- if (follows(ws)) save()
140
- let sent
141
- try {
142
- sent = send(ws, text, { requestId: `telegram:${update}`, replyTo: `${replyTo}${chat}`, env: sendEnv() })
143
- } catch (err) {
144
- log(`chat ${chat}: send refused — ${err.type} ${said(err)}`)
145
- return tell(chat, err.type === 'ADMISSION_FULL' ? `busy — ${said(err)}` : `not delivered — ${said(err)}`)
146
- }
147
- if (sent.repeated) return log(`chat ${chat}: update ${update} was already delivered, as inbox ${sent.inbox}`)
148
-
149
- const entry = { inbox: sent.inbox, chat, message: null }
150
- ;(state.pending[ws] ??= []).push(entry)
151
- save()
152
- notes.set(entry, delivery.to(chat, attempt => attempt(() => telegram.say(chat, sent.started ? 'working…' : 'heard you — still working…')))
153
- .then(note => note.message_id, err => {
154
- log(`chat ${chat}: no working message — ${said(err)}`)
155
- return null
156
- })
157
- .then(message => {
158
- entry.message = message
159
- save()
160
- return message
161
- }))
162
- }
163
-
164
- const handle = async (update) => {
165
- const message = update.message
166
- const chat = message?.chat?.id
167
- if (chat === undefined) return
168
-
169
- let read
170
- try {
171
- read = route()
172
- } catch (err) {
173
- return log(`refused chat ${chat}: the allow-list is unreadable — ${said(err)}`)
174
- }
175
- const name = read.table[String(chat)]
176
- if (!name) {
177
- const who = message.from?.username ? ` (@${message.from.username})` : ''
178
- return log(`refused chat ${chat}${who} — to allow it, add "${chat}": "<a spore>" to the chats of ${botFile}`)
179
- }
180
- let ws
181
- try {
182
- ws = sporeOf(name, read.config)
183
- } catch (err) {
184
- log(`chat ${chat}: "${name}" — ${said(err)}`)
185
- return tell(chat, `this chat points at "${name}", which is not a spore name — tell whoever runs me`)
186
- }
187
- if (!ws) {
188
- log(`chat ${chat}: no spore "${name}"`)
189
- return tell(chat, `there is no spore called "${name}" — tell whoever runs me`)
190
- }
191
-
192
- const text = message.text?.trim()
193
- if (!text) return tell(chat, 'only text reaches a spore for now')
194
- const command = text.match(COMMAND)
195
- const asked = command?.[1].toLowerCase()
196
- if (command && (!command[3] || TAKES_ARGUMENTS.has(asked))) return obey(chat, ws, asked, command[3])
197
- pass(chat, ws, text, update.update_id)
198
- }
199
-
200
- const listen = async () => {
201
- const { signal } = stopping
202
- let offset
203
- let backoff = 1000
204
- while (!signal.aborted) {
205
- let updates
206
- try {
207
- updates = await telegram.updates(offset, { signal })
208
- backoff = 1000
209
- } catch (err) {
210
- if (signal.aborted) break
211
- if (err.context?.status === 409) {
212
- throw new SporeError('another process is taking this bot\'s updates', { type: 'TELEGRAM_CONFLICT', detail: err.detail })
213
- }
214
- if (!err.retryable) throw err
215
- log(`poll failed — ${said(err)}`)
216
- await sleep(err.context?.retryAfter ? err.context.retryAfter * 1000 : backoff, signal)
217
- backoff = Math.min(backoff * 2, 60_000)
218
- continue
219
- }
220
- for (const update of updates) {
221
- try {
222
- await handle(update)
223
- } catch (err) {
224
- log(`update ${update.update_id}: ${said(err)}`)
225
- const chat = update.message?.chat?.id
226
- if (chat !== undefined) tell(chat, `that failed — ${err.message}`)
227
- }
228
- offset = update.update_id + 1
229
- }
230
- }
231
- if (offset !== undefined) {
232
- await telegram.updates(offset, { seconds: 0 }).catch(err => log(`could not confirm the last updates — ${said(err)}`))
233
- }
234
- }
235
-
236
- const spores = () => {
237
- let read
238
- try {
239
- read = route()
240
- routeFailure = null
241
- } catch (err) {
242
- if (routeFailure !== err.message) log(`allow-list: ${said(err)} — following the spores it last named`)
243
- routeFailure = err.message
244
- return [...chatsOf.keys()]
245
- }
246
- detailed = read.detailed
247
- const next = new Map()
248
- for (const [chat, name] of Object.entries(read.table)) {
249
- let ws
250
- try {
251
- ws = sporeOf(name, read.config)
252
- } catch {
253
- // Told to the chat, and logged, when it next speaks.
254
- continue
255
- }
256
- if (ws) next.set(ws, [...(next.get(ws) ?? []), Number(chat)])
257
- }
258
- let moved = false
259
- for (const ws of Object.keys(state.cursors)) {
260
- if (next.has(ws)) continue
261
- delete state.cursors[ws]
262
- delete state.pending[ws]
263
- moved = true
264
- }
265
- for (const ws of next.keys()) moved = follows(ws) || moved
266
- if (moved) save()
267
- chatsOf = next
268
- return [...next.keys()]
269
- }
270
-
271
- const repaint = (ws) => {
272
- const lines = shown.get(ws)
273
- lines.timer = null
274
- lines.at = Date.now()
275
- for (const entry of state.pending[ws] ?? []) {
276
- const text = ['working…', ...render(lines.items, { detailed: detailed.has(entry.chat) })].join('\n')
277
- delivery.to(entry.chat, async (attempt) => {
278
- const message = await noteOf(entry)
279
- if (message === null || !state.pending[ws]?.includes(entry)) return
280
- await attempt(() => telegram.revise(entry.chat, message, text))
281
- }).catch(err => { if (!NOT_MODIFIED.test(err.detail ?? '')) log(`chat ${entry.chat}: progress not shown — ${said(err)}`) })
282
- }
283
- }
284
-
285
- const onEvent = (ws, event) => {
286
- if (!state.pending[ws]?.length) return
287
- const lines = shown.get(ws) ?? { items: [], timer: null, at: 0 }
288
- shown.set(ws, lines)
289
- if (!track(lines.items, event)) return
290
- if (!lines.timer) lines.timer = setTimeout(() => repaint(ws), Math.max(0, editEveryMs - (Date.now() - lines.at)))
291
- }
292
-
293
- const forget = (ws, notes) => {
294
- const kept = (state.pending[ws] ?? []).filter(note => !notes.includes(note))
295
- if (kept.length) state.pending[ws] = kept
296
- else delete state.pending[ws]
297
- save()
298
- }
299
-
300
- /**
301
- * One outbox entry of this bot's posted to its chat — into the `working…` messages of what it
302
- * answers when the chat has them — and settled: delivered once Telegram took it, refused when
303
- * the chat does not reach this spore or Telegram will not take it. One Telegram still did not
304
- * take when its retries ran out stays pending for the next pass. True when it is settled.
305
- */
306
- const deliver = async (ws, entry) => {
307
- const chat = Number(entry.recipient.slice(replyTo.length))
308
- const notes = (state.pending[ws] ?? []).filter(note => note.chat === chat && entry.answers.includes(note.inbox))
309
- if (!(chatsOf.get(ws) ?? []).includes(chat)) {
310
- const reason = `chat ${entry.recipient.slice(replyTo.length)} does not reach this spore through ${botFile}`
311
- settleOutbox(ws, entry.outboxNo, { refused: reason })
312
- log(`turn ${entry.turnNo} of ${ws}: refused — ${reason}`)
313
- forget(ws, notes)
314
- return true
315
- }
316
- const turn = turnsSince(ws, entry.turnNo - 1).ended[0]
317
- const heard = turn.asked.filter(message => entry.answers.includes(message.inboxNo))
318
- const text = heard.some(message => message.replyTo) ? reply(turn) : announce({ record: turn.record, asked: heard })
319
- const files = (turn.record.produced ?? []).filter(file => file.delivered)
320
- try {
321
- await delivery.to(chat, async (attempt) => {
322
- if (notes.length) {
323
- for (const note of notes) await edit(attempt, chat, await noteOf(note), text)
324
- } else {
325
- for (const part of pieces(text)) await attempt(() => telegram.say(chat, part))
326
- }
327
- await hand(attempt, chat, ws, files)
328
- })
329
- } catch (err) {
330
- if (err.retryable || stopping.signal.aborted) {
331
- log(`turn ${entry.turnNo} of ${ws}: not delivered to chat ${chat} yet — ${said(err)}`)
332
- return false
333
- }
334
- settleOutbox(ws, entry.outboxNo, { refused: said(err) })
335
- log(`turn ${entry.turnNo} of ${ws}: chat ${chat} refused it — ${said(err)}`)
336
- forget(ws, notes)
337
- return true
338
- }
339
- settleOutbox(ws, entry.outboxNo)
340
- forget(ws, notes)
341
- return true
342
- }
343
-
344
- /**
345
- * This bot's pending outbox entries of `ws`, delivered in order: the messages still owed an
346
- * answer, or null when the spore's database could not be read or written — a busy one — and
347
- * what it holds waits for the next pass.
348
- */
349
- const drain = async (ws) => {
350
- const owed = new Set()
351
- try {
352
- for (const entry of outbox(ws, { kind: 'telegram', state: 'pending' })) {
353
- if (!entry.recipient.startsWith(replyTo)) continue
354
- if (!(await deliver(ws, entry))) entry.answers.forEach(inboxNo => owed.add(inboxNo))
355
- }
356
- } catch (err) {
357
- log(`${ws}: its outbox could not be read or settled — ${said(err)}`)
358
- return null
359
- }
360
- return owed
361
- }
362
-
363
- const onTurn = async (ws, turn) => {
364
- clearTimeout(shown.get(ws)?.timer)
365
- shown.delete(ws)
366
-
367
- const read = new Set(turn.asked.map(entry => entry.inboxNo))
368
- const left = (state.pending[ws] ?? []).filter(entry => !read.has(entry.inbox))
369
- if (left.length && stateOf(ws).state !== RUNNING) {
370
- const work = left.map(entry => delivery.to(entry.chat, async (attempt) => edit(attempt, entry.chat, await noteOf(entry), waiting(turn))))
371
- for (const outcome of await Promise.allSettled(work)) {
372
- if (outcome.status === 'rejected') log(`turn ${turn.record.turnNo} of ${ws}: not told it is waiting — ${said(outcome.reason)}`)
373
- }
374
- }
375
-
376
- const owed = await drain(ws)
377
- if (owed) forget(ws, (state.pending[ws] ?? []).filter(note => read.has(note.inbox) && !owed.has(note.inbox)))
378
- }
379
-
380
- const follow = async () => {
381
- const { signal } = stopping
382
- for (const ws of spores()) await drain(ws)
383
- for await (const item of watchAll(spores, { cursors: state.cursors, tick: followEveryMs, signal })) {
384
- if (item.event) {
385
- onEvent(item.ws, item.event)
386
- continue
387
- }
388
- await onTurn(item.ws, item.turn)
389
- if (signal.aborted) break
390
- state.cursors[item.ws] = item.cursor
391
- save()
392
- }
393
- }
394
-
395
- const run = async (signal) => {
396
- signal.addEventListener('abort', () => stopping.abort(), { once: true })
397
- const failed = []
398
- const halt = (err) => {
399
- failed.push(err)
400
- stopping.abort()
401
- }
402
- await Promise.all([listen().catch(halt), follow().catch(halt)])
403
- for (const lines of shown.values()) clearTimeout(lines.timer)
404
- save()
405
- if (failed.length) throw failed[0]
406
- }
407
-
408
- return { run }
409
- }
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
- }