@rhizogrip/spore-plugin-telegram 0.2.0 → 0.3.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
@@ -36,9 +35,8 @@ passed to a spore's runner.
36
35
  message rather than losing one. One Telegram will not take is settled refused, with
37
36
  Telegram's reason, and one it still had not taken when its retries ran out is tried again
38
37
  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`.
38
+ - **A reply never mixes turns.** Each chat message gets the answer, the end state and the files
39
+ of the turn that answered it.
42
40
  - **A restart loses nothing.** A turn running when the service stopped finishes into the
43
41
  chat's `working…` message; a turn that ended while it was down is posted when it is back.
44
42
  - **Nothing fails in silence.** A message a spore refuses is told to the chat; a message
@@ -50,11 +48,11 @@ Write to the bot and the message goes to the spore the chat is mapped to. The bo
50
48
  `working…`, shows which tools run as they run, and edits the answer into that message when
51
49
  the turn ends. How the turn ended is added only when it makes the answer partial — a wrap
52
50
  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.
51
+ turn left in `data/out/` follows as documents, up to the 50 a turn hands back, then a line
52
+ counting the rest; a file over Telegram's 50 MB is named instead.
55
53
 
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
54
+ A message sent while a turn runs lands between its rounds, and its `working…` message says it
55
+ was heard. A turn ending in a question posts the
58
56
  question, and the reply answers it. The conversation is the spore's transcript; the service
59
57
  keeps none.
60
58
 
@@ -84,18 +82,18 @@ except when the turn ended `quiet`, which chose to say nothing. A turn that ende
84
82
  answer any other way is told. A spore with no default tells no chat. A default naming a chat
85
83
  that does not reach the spore through this bot is refused, and the log says why.
86
84
 
87
- A turn a terminal asked for is the terminal's: `spore send` follows it, and no chat is told.
85
+ A turn started with `spore send` is shown only there; no chat is told.
88
86
 
89
87
  ## The allow-list is the routing
90
88
 
91
89
  A bot's `chats` say which chats may talk to it and which spore each reaches, as
92
90
  `{ "<chat id>": "<spore>" }`. They are read on every message and followed as they change, so
93
91
  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.
92
+ `sources.json`. Spores cannot read or change it.
95
93
 
96
94
  While a turn works, its chat is shown what kind of call ran — `reading a file`, `running a
97
95
  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
96
+ 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
97
  machine, and a command line or a path is yours. A chat can ask for the calls themselves:
100
98
 
101
99
  ```json
@@ -118,24 +116,18 @@ refused chat 148392011 (@alice) — to allow it, add "148392011": "<a spore>" to
118
116
  which is also how you learn your own chat id: start with `"chats": {}` and message the bot. A
119
117
  group's id is negative, and it is the group you add rather than the people in it.
120
118
 
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
119
  ## What it keeps
128
120
 
129
121
  `$XDG_STATE_HOME/spore/telegram/<bot>/state.json`, one per bot, written whole and renamed into
130
122
  place: where it stands in each spore it follows, in that spore's database, and every message a chat sent that no turn has answered
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.
123
+ yet, with the `working…` message its answer goes into. A spore added later is followed from its
124
+ latest turn; earlier answers are not posted. Nothing is kept in the repository or in the spores'
125
+ home.
134
126
 
135
127
  ## Busy
136
128
 
137
129
  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.
130
+ busy. The only limit is the machine's admission slots, which every other caller shares.
139
131
 
140
132
  ## Running it as a service
141
133
 
@@ -146,18 +138,16 @@ spore service enable telegram@triage # and at every login
146
138
  spore service log telegram@triage -f
147
139
  ```
148
140
 
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
141
+ `define` first runs the service's start-up checks (`--check`) and refuses a missing bot file or
142
+ token. The unit it
151
143
  writes is the template `~/.config/systemd/user/spore-telegram@.service`, marked as spore's, and
152
144
  each bot is an instance of it; `spore service` never touches a unit it did not write. `spore
153
145
  service rm telegram@<bot>` stops and disables one bot; `spore service rm telegram` stops them all
154
146
  and removes the template — each given `--yes` when a bot it stops is running. `spore service ls` says where each service
155
147
  stands, and whether lingering is on — without it, user services stop when you log out.
156
148
 
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.
149
+ Restarting the service does not stop the turns it started (`KillMode=process`). The service
150
+ picks them up again from what it saved. After a refusal (exit 2), systemd does not restart it.
161
151
 
162
152
  `~/.config/spore/environment` is what the service and every runner it starts need, since a
163
153
  systemd user session starts with almost nothing: each bot's token, `PATH`, the provider
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.3.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",
package/src/forward.js CHANGED
@@ -71,8 +71,9 @@ export function track (items, event) {
71
71
  const item = items.find(i => i.callId === event.callId)
72
72
  if (!item) return false
73
73
  item.durationMs = event.durationMs
74
- item.failed = Boolean(event.error)
75
- return item.failed || item.durationMs >= SLOW_MS
74
+ item.exit = event.outcome === 'exit' ? event.exit : null
75
+ item.failed = event.outcome === undefined ? Boolean(event.error) : !['ok', 'exit'].includes(event.outcome)
76
+ return item.failed || item.exit !== null || item.durationMs >= SLOW_MS
76
77
  }
77
78
  const note = (text) => {
78
79
  items.push({ plain: text, detailed: text })
@@ -89,6 +90,7 @@ export function render (items, { detailed = false, last = 6 } = {}) {
89
90
  for (const item of items) {
90
91
  const text = (detailed ? item.detailed : item.plain) +
91
92
  (item.failed ? ' — failed' : '') +
93
+ (item.exit != null ? ` — exit ${item.exit}` : '') +
92
94
  (item.durationMs >= SLOW_MS ? ` — ${Math.round(item.durationMs / 1000)} s` : '')
93
95
  const previous = lines.at(-1)
94
96
  if (previous?.text === text) previous.count++
package/src/service.js CHANGED
@@ -8,6 +8,7 @@ import { announce, pieces, render, reply, track, waiting } from './forward.js'
8
8
  import { loadState, saveState } from './state.js'
9
9
 
10
10
  const MAX_DOCUMENT = 50 * 1024 * 1024
11
+ const leftOut = (more) => `and ${more} more file${more === 1 ? '' : 's'} in data/out/, in the spore's workspace`
11
12
  const COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
12
13
  const NOT_MODIFIED = /message is not modified/
13
14
  const TAKES_ARGUMENTS = new Set(['extend', 'wrap'])
@@ -77,7 +78,7 @@ export function createService ({ telegram, botFile, statePath, env, log, configF
77
78
  for (const part of rest) await attempt(() => telegram.say(chat, part))
78
79
  }
79
80
 
80
- const hand = async (attempt, chat, ws, files) => {
81
+ const hand = async (attempt, chat, ws, { files, more }) => {
81
82
  for (const file of files) {
82
83
  const path = resolve(ws, file.path)
83
84
  if (!existsSync(path)) {
@@ -91,6 +92,7 @@ export function createService ({ telegram, botFile, statePath, env, log, configF
91
92
  }
92
93
  await attempt(() => telegram.document(chat, { path, mime: file.mime }, file.path))
93
94
  }
95
+ if (more) await attempt(() => telegram.say(chat, leftOut(more)))
94
96
  }
95
97
 
96
98
  const status = (ws) => {
@@ -316,7 +318,7 @@ export function createService ({ telegram, botFile, statePath, env, log, configF
316
318
  const turn = turnsSince(ws, entry.turnNo - 1).ended[0]
317
319
  const heard = turn.asked.filter(message => entry.answers.includes(message.inboxNo))
318
320
  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)
321
+ const handed = { files: turn.record.deliverables ?? [], more: turn.record.deliverablesMore ?? 0 }
320
322
  try {
321
323
  await delivery.to(chat, async (attempt) => {
322
324
  if (notes.length) {
@@ -324,7 +326,7 @@ export function createService ({ telegram, botFile, statePath, env, log, configF
324
326
  } else {
325
327
  for (const part of pieces(text)) await attempt(() => telegram.say(chat, part))
326
328
  }
327
- await hand(attempt, chat, ws, files)
329
+ await hand(attempt, chat, ws, handed)
328
330
  })
329
331
  } catch (err) {
330
332
  if (err.retryable || stopping.signal.aborted) {