@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 +36 -41
- package/bin/spore-telegram.js +13 -77
- package/package.json +3 -3
- package/src/adapter.js +141 -0
- package/src/api.js +8 -1
- package/src/bots.js +0 -116
- package/src/delivery.js +0 -28
- package/src/forward.js +0 -136
- package/src/service.js +0 -409
- package/src/state.js +0 -33
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
130
|
-
place: where it stands in each spore it follows, in that spore's database, and every
|
|
131
|
-
yet, with
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
158
|
-
|
|
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
|
package/bin/spore-telegram.js
CHANGED
|
@@ -2,82 +2,18 @@
|
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { runService } from '@rhizogrip/spore/relay'
|
|
6
6
|
|
|
7
7
|
import { createTelegram } from '../src/api.js'
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
+
"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', {
|
|
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
|
-
}
|