@rhizogrip/spore-plugin-telegram 0.9.0 → 0.11.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
@@ -73,8 +73,12 @@ says so in your `working…` message, and the next turn answers it.
73
73
  /extend wall 30m give it more — wall, cost or rounds — up to the extend.<limit> its spore.cmk grants
74
74
  ```
75
75
 
76
- Only text reaches a spore for now: a photo's or a document's caption goes to it, and you are told
77
- the file did not; a message with nothing written in it is told only text reaches a spore.
76
+ A photo, a document, a voice note, a video or a sticker reaches the spore with its caption, fetched
77
+ by the service up to the 20 MB Telegram lets a bot fetch, or the bot file's `"files": "10MB"` —
78
+ `false` takes none. An album is one message: its parts are held until none has come for a second,
79
+ and confirmed to Telegram only once the spore has them all. A file that does not come is told to
80
+ you, and to the spore, with why. In a group that overhears, what is sent around the spore is named
81
+ to it and not fetched.
78
82
 
79
83
  ## Turns nobody in a chat started
80
84
 
@@ -92,6 +96,17 @@ that does not reach the spore through this bot is refused, and the log says why.
92
96
 
93
97
  A turn started with `spore send` is shown only there; no chat is told.
94
98
 
99
+ ## Approvals
100
+
101
+ An approval your program asks in a chat this bot reaches
102
+ ([INTEGRATION §Asking a person to approve](../../INTEGRATION.md#asking-a-person-to-approve)) is
103
+ posted as a message — the request's `telegram` body, `{ "html": "…" }` in Telegram's HTML, or its
104
+ `default` text and facts, a line each — with a button per option on its own row, green for
105
+ `positive` and red for `destructive` where the Telegram app shows colours. A press is taken only
106
+ from someone the approval names, and Telegram shows them `Apply — recorded`, or why it was not
107
+ taken. The message is then edited: replaced by the body your hook answered, or kept with its
108
+ buttons gone and who chose what below.
109
+
95
110
  ## The allow-list is the routing
96
111
 
97
112
  A bot's `people` say who may talk to it in private and which spore each reaches, as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-telegram",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",
@@ -31,13 +31,13 @@
31
31
  "marked": "^18.0.11"
32
32
  },
33
33
  "peerDependencies": {
34
- "@rhizogrip/spore": "^1.23.0"
34
+ "@rhizogrip/spore": "^1.26.0"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "vitest run"
38
38
  },
39
39
  "spore": {
40
- "works": "^1.23.0",
40
+ "works": "^1.26.0",
41
41
  "services": {
42
42
  "telegram": {
43
43
  "what": "a bot of yours in Telegram chats, each chat reaching its spore",
package/src/adapter.js CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { SporeError } from '@rhizogrip/spore'
8
8
 
9
- import { toHtml } from './format.js'
9
+ import { escape, toHtml } from './format.js'
10
10
 
11
11
  const COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
12
12
  const NOT_MODIFIED = /message is not modified/
@@ -31,6 +31,10 @@ const PERSON_FIELDS = ['spore', 'details', 'show', 'brief']
31
31
  const GROUP_FIELDS = ['spores', 'people', 'details', 'show', 'overhear', 'brief']
32
32
  const PERSON = /^\d+$/
33
33
  const GROUP = /^-\d+$/
34
+ const STYLES = { positive: 'success', destructive: 'danger' }
35
+ const FETCH_MOST = 20 * 1024 * 1024
36
+ const ALBUM_QUIET_MS = 1000
37
+ const BODY = 'a Telegram body is { "html": "<Telegram\'s HTML, at most 4096 characters>" }'
34
38
 
35
39
  const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
36
40
 
@@ -112,8 +116,8 @@ export const TELEGRAM = {
112
116
  example: SHAPE,
113
117
  routes,
114
118
  usage: { help: `${HELP}; ${EXTEND}.`, briefHelp: `${HELP}.`, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
115
- limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500, typingEveryMs: 4000 },
116
- can: { upload: true, remove: true, react: true, typing: true, reply: true },
119
+ limits: { message: 4096, file: 50 * 1024 * 1024, fetch: FETCH_MOST, editEveryMs: 2500, typingEveryMs: 4000 },
120
+ can: { upload: true, remove: true, react: true, typing: true, reply: true, choose: true },
117
121
  showShared: 'react',
118
122
  reactions: REACTIONS,
119
123
  hint (err, { file, bot }) {
@@ -130,7 +134,67 @@ export const TELEGRAM = {
130
134
  : `refused chat ${message.conversation}${message.person.username ? ` (@${message.person.username})` : ''} — to allow it, add "${message.conversation}": "<a spore>" to the people of ${botFile}`
131
135
  }
132
136
 
133
- const FILES = ['photo', 'document', 'video', 'audio', 'voice', 'video_note', 'animation', 'sticker']
137
+ const KINDS = {
138
+ document: (file) => [file.mime_type ?? null, ''],
139
+ video: (file) => [file.mime_type ?? 'video/mp4', '.mp4'],
140
+ audio: (file) => [file.mime_type ?? 'audio/mpeg', '.mp3'],
141
+ animation: (file) => [file.mime_type ?? 'video/mp4', '.mp4'],
142
+ voice: (file) => [file.mime_type ?? 'audio/ogg', '.ogg'],
143
+ video_note: () => ['video/mp4', '.mp4'],
144
+ sticker: (file) => file.is_animated ? ['application/x-tgsticker', '.tgs'] : file.is_video ? ['video/webm', '.webm'] : ['image/webp', '.webp']
145
+ }
146
+
147
+ /**
148
+ * The files a message carries, as the relay lists them: the largest size of a photo, and the one
149
+ * file of any other kind, each under its own name or one made of its kind and Telegram's id for
150
+ * it. One Telegram will not let a bot fetch is listed with why.
151
+ */
152
+ function filesOf (message) {
153
+ const found = []
154
+ const photo = message.photo?.at(-1)
155
+ if (photo) found.push({ name: `photo-${photo.file_unique_id}.jpg`, mime: 'image/jpeg', bytes: photo.file_size ?? null, id: photo.file_id })
156
+ for (const [kind, typed] of Object.entries(KINDS)) {
157
+ const file = message[kind]
158
+ if (!file) continue
159
+ const [mime, extension] = typed(file)
160
+ found.push({ name: file.file_name ?? `${kind.replace('_', '-')}-${file.file_unique_id}${extension}`, mime, bytes: file.file_size ?? null, id: file.file_id })
161
+ }
162
+ return found.map(({ id, ...file }) => file.bytes !== null && file.bytes > FETCH_MOST
163
+ ? { ...file, ref: null, why: 'Telegram lets a bot fetch files up to 20 MB' }
164
+ : { ...file, ref: id })
165
+ }
166
+
167
+ /** An album's parts as one message: what its captioned part said, and every part's files. */
168
+ export function merged (messages) {
169
+ const said = messages.find(message => message.text !== null) ?? messages[0]
170
+ return { ...said, files: messages.flatMap(message => message.files), addresses: messages.some(message => message.addresses === null) ? null : [] }
171
+ }
172
+
173
+ const personOf = ({ id, first_name: first, last_name: last, username }) =>
174
+ ({ id: String(id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username, groups: [] })
175
+
176
+ const whereOf = (chat) => chat.type === 'private' ? 'a private chat on Telegram' : `the Telegram group "${chat.title}"`
177
+
178
+ /** What an approval shows, in Telegram's HTML: the host's own, or the default body — its text, then each fact on a line. */
179
+ const htmlOf = ({ native, fallback }) => native
180
+ ? native.html
181
+ : [
182
+ fallback.text ? toHtml(fallback.text) : null,
183
+ fallback.facts?.length ? fallback.facts.map(([label, value]) => `<b>${escape(label)}</b>: ${escape(value)}`).join('\n') : null
184
+ ].filter(Boolean).join('\n\n')
185
+
186
+ /** A press on one of the bot's buttons as the relay takes it, or null for one on a message Telegram no longer shows. */
187
+ export function choiceOf (query) {
188
+ if (!query.message?.chat) return null
189
+ return {
190
+ conversation: String(query.message.chat.id),
191
+ note: query.message.message_id,
192
+ data: query.data ?? null,
193
+ inputs: null,
194
+ person: personOf(query.from),
195
+ where: whereOf(query.message.chat)
196
+ }
197
+ }
134
198
 
135
199
  /** Whether the bot's own `@username` is among what Telegram marked as mentions. */
136
200
  const mentions = (message, me) => {
@@ -157,19 +221,18 @@ export function normalize (update, bot, me) {
157
221
  const addressed = !shared || Boolean(command && (command[2] || VERBS.has(command[1].toLowerCase()))) ||
158
222
  mentions(message, me) || message.reply_to_message?.from?.id === me.id
159
223
  const text = command ? written : written?.replace(new RegExp(`@${me.username}\\b[,:]?\\s*`, 'gi'), '').trim() || null
160
- const { first_name: first, last_name: last, username } = message.from
161
224
  return {
162
225
  id: `${bot}:${update.update_id}`,
163
226
  conversation: String(message.chat.id),
164
227
  message: message.message_id,
165
- person: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username, groups: [] },
228
+ person: personOf(message.from),
166
229
  spore: null,
167
- where: shared ? `the Telegram group "${message.chat.title}"` : 'a private chat on Telegram',
230
+ where: whereOf(message.chat),
168
231
  shared,
169
232
  text,
170
233
  command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
171
234
  addresses: addressed ? null : [],
172
- files: FILES.filter(kind => message[kind]).length
235
+ files: filesOf(message)
173
236
  }
174
237
  }
175
238
 
@@ -187,17 +250,40 @@ export function createAdapter ({ telegram, bot, me }) {
187
250
  },
188
251
 
189
252
  /**
190
- * One long poll after another until `signal` aborts. An update is handed to `onMessage` and
191
- * awaited before the offset moves past it, since moving it is what confirms the update to
192
- * Telegram: a message is confirmed only once the spore has it.
253
+ * One long poll after another until `signal` aborts. An update is handed to `onMessage`, or a
254
+ * press to `onChoice`, and awaited before the offset moves past it, since moving it is what
255
+ * confirms the update to Telegram: a message is confirmed only once the spore has it, and a
256
+ * press once it is written down. An album's parts are held, unconfirmed, until none has come for
257
+ * a second, and handed on as one message.
193
258
  */
194
- async listen ({ onMessage, signal, log, sleep }) {
259
+ async listen ({ onMessage, onChoice, signal, log, sleep }) {
195
260
  let offset
261
+ let seen = -1
262
+ let album = null
196
263
  let backoff = 1000
264
+ const take = async (update) => {
265
+ const query = update.callback_query
266
+ if (query) {
267
+ const acknowledge = (text) => telegram.answer(query.id, text)
268
+ const choice = choiceOf(query)
269
+ if (choice) await onChoice({ ...choice, acknowledge })
270
+ else await acknowledge(null).catch(err => log(`a press was not answered — ${said(err)}`))
271
+ return
272
+ }
273
+ const message = normalize(update, bot, me)
274
+ if (message) await onMessage(message)
275
+ }
276
+ const flush = async () => {
277
+ const { parts } = album
278
+ album = null
279
+ const messages = parts.map(update => normalize(update, bot, me)).filter(Boolean)
280
+ if (messages.length) await onMessage(merged(messages))
281
+ offset = parts.at(-1).update_id + 1
282
+ }
197
283
  while (!signal.aborted) {
198
284
  let updates
199
285
  try {
200
- updates = await telegram.updates(offset, { signal })
286
+ updates = await telegram.updates(offset, { seconds: album ? 0 : 30, signal })
201
287
  backoff = 1000
202
288
  } catch (err) {
203
289
  if (signal.aborted) break
@@ -210,11 +296,21 @@ export function createAdapter ({ telegram, bot, me }) {
210
296
  backoff = Math.min(backoff * 2, 60_000)
211
297
  continue
212
298
  }
213
- for (const update of updates) {
214
- const message = normalize(update, bot, me)
215
- if (message) await onMessage(message)
299
+ const fresh = updates.filter(update => update.update_id > seen)
300
+ if (album && !fresh.some(update => update.message?.media_group_id === album.id)) await flush()
301
+ for (const update of fresh) {
302
+ seen = update.update_id
303
+ const group = update.message?.media_group_id
304
+ if (album && group !== album.id) await flush()
305
+ if (group) {
306
+ album ??= { id: group, parts: [] }
307
+ album.parts.push(update)
308
+ continue
309
+ }
310
+ await take(update)
216
311
  offset = update.update_id + 1
217
312
  }
313
+ if (album) await sleep(ALBUM_QUIET_MS)
218
314
  }
219
315
  if (offset !== undefined) {
220
316
  await telegram.updates(offset, { seconds: 0 }).catch(err => log(`could not confirm the last updates — ${said(err)}`))
@@ -244,6 +340,32 @@ export function createAdapter ({ telegram, bot, me }) {
244
340
 
245
341
  react: (conversation, message, emoji) => telegram.react(Number(conversation), message, emoji),
246
342
 
247
- typing: (conversation) => telegram.typing(Number(conversation))
343
+ typing: (conversation) => telegram.typing(Number(conversation)),
344
+
345
+ fetch: async (ref, { most }) => telegram.download((await telegram.file(ref)).file_path, { most }),
346
+
347
+ checkBody: (native) => {
348
+ const fits = Object.keys(native).every(key => key === 'html') && typeof native.html === 'string' && native.html.trim() !== '' && native.html.length <= 4096
349
+ return fits ? null : BODY
350
+ },
351
+
352
+ /** The question, each choice a button on its own row, coloured where it has a style. */
353
+ ask: async (conversation, shown, choices) => {
354
+ const text = htmlOf(shown)
355
+ const keyboard = choices.map(choice => [{ text: choice.label, callback_data: choice.data, ...(choice.style ? { style: STYLES[choice.style] } : {}) }])
356
+ const message = await telegram.ask(Number(conversation), text, keyboard)
357
+ return { note: message.message_id, shown: { text, parse_mode: 'HTML', reply_markup: { inline_keyboard: keyboard } } }
358
+ },
359
+
360
+ /** The question closed: replaced by `native` or `fallback`, or kept as `shown` with how it ended below. */
361
+ settle: async (conversation, note, replacement) => {
362
+ const text = replacement.shown ? `${replacement.shown.text}\n\n<b>${escape(replacement.outcome)}</b>` : htmlOf(replacement)
363
+ try {
364
+ await telegram.close(Number(conversation), Number(note), text)
365
+ } catch (err) {
366
+ if (!NOT_MODIFIED.test(err.detail ?? '')) throw err
367
+ }
368
+ return { text, parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } }
369
+ }
248
370
  }
249
371
  }
package/src/api.js CHANGED
@@ -8,6 +8,7 @@ import { readFileSync } from 'node:fs'
8
8
  import { basename } from 'node:path'
9
9
 
10
10
  import { SporeError } from '@rhizogrip/spore'
11
+ import { download } from '@rhizogrip/spore/relay'
11
12
 
12
13
  const BASE = 'https://api.telegram.org'
13
14
 
@@ -49,7 +50,7 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
49
50
  return {
50
51
  /** `offset` confirms every update before it to Telegram. */
51
52
  updates: (offset, { seconds = 30, signal } = {}) =>
52
- call('getUpdates', { offset, timeout: seconds, allowed_updates: ['message'] }, { timeoutMs: (seconds + 15) * 1000, signal }),
53
+ call('getUpdates', { offset, timeout: seconds, allowed_updates: ['message', 'callback_query'] }, { timeoutMs: (seconds + 15) * 1000, signal }),
53
54
 
54
55
  say: (chat, text, { answering = null, html = false } = {}) => call('sendMessage', {
55
56
  chat_id: chat,
@@ -62,6 +63,28 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
62
63
  revise: (chat, message, text) =>
63
64
  call('editMessageText', { chat_id: chat, message_id: message, text, link_preview_options: { is_disabled: true } }),
64
65
 
66
+ /** A question in Telegram's HTML with `keyboard`, rows of inline buttons, under it. */
67
+ ask: (chat, html, keyboard) => call('sendMessage', {
68
+ chat_id: chat,
69
+ text: html,
70
+ parse_mode: 'HTML',
71
+ link_preview_options: { is_disabled: true },
72
+ reply_markup: { inline_keyboard: keyboard }
73
+ }),
74
+
75
+ /** A question's text replaced, and its buttons gone. */
76
+ close: (chat, message, html) => call('editMessageText', {
77
+ chat_id: chat,
78
+ message_id: message,
79
+ text: html,
80
+ parse_mode: 'HTML',
81
+ link_preview_options: { is_disabled: true },
82
+ reply_markup: { inline_keyboard: [] }
83
+ }),
84
+
85
+ /** A press answered, with `text` as the short notice Telegram shows, or none. */
86
+ answer: (query, text) => call('answerCallbackQuery', { callback_query_id: query, ...(text ? { text } : {}) }),
87
+
65
88
  remove: (chat, message) => call('deleteMessage', { chat_id: chat, message_id: message }),
66
89
 
67
90
  /** `emoji` on the message as the bot's one reaction, or none when it is null. */
@@ -78,6 +101,12 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
78
101
  return call('sendDocument', form, { timeoutMs: 300_000 })
79
102
  },
80
103
 
81
- me: () => call('getMe')
104
+ me: () => call('getMe'),
105
+
106
+ /** Where Telegram keeps a file a person sent: `{ file_path, file_size }`. */
107
+ file: (id) => call('getFile', { file_id: id }),
108
+
109
+ /** A file's bytes by its `file_path`, refused past `most`. */
110
+ download: (path, { most }) => download(`${base}/file/bot${token}/${path}`, { most, fetchImpl })
82
111
  }
83
112
  }
package/src/format.js CHANGED
@@ -9,7 +9,7 @@ import { Lexer } from 'marked'
9
9
 
10
10
  const LINKABLE = /^(https?:|mailto:|tg:)/i
11
11
 
12
- const escape = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
12
+ export const escape = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
13
13
  const attribute = (text) => escape(text).replace(/"/g, '&quot;')
14
14
 
15
15
  const plain = (tokens) => tokens.map(token => (token.tokens ? plain(token.tokens) : token.text ?? '')).join('')