@rhizogrip/spore-plugin-telegram 0.10.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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-telegram",
3
- "version": "0.10.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.24.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.24.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
@@ -32,6 +32,8 @@ const GROUP_FIELDS = ['spores', 'people', 'details', 'show', 'overhear', 'brief'
32
32
  const PERSON = /^\d+$/
33
33
  const GROUP = /^-\d+$/
34
34
  const STYLES = { positive: 'success', destructive: 'danger' }
35
+ const FETCH_MOST = 20 * 1024 * 1024
36
+ const ALBUM_QUIET_MS = 1000
35
37
  const BODY = 'a Telegram body is { "html": "<Telegram\'s HTML, at most 4096 characters>" }'
36
38
 
37
39
  const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
@@ -114,7 +116,7 @@ export const TELEGRAM = {
114
116
  example: SHAPE,
115
117
  routes,
116
118
  usage: { help: `${HELP}; ${EXTEND}.`, briefHelp: `${HELP}.`, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
117
- limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500, typingEveryMs: 4000 },
119
+ limits: { message: 4096, file: 50 * 1024 * 1024, fetch: FETCH_MOST, editEveryMs: 2500, typingEveryMs: 4000 },
118
120
  can: { upload: true, remove: true, react: true, typing: true, reply: true, choose: true },
119
121
  showShared: 'react',
120
122
  reactions: REACTIONS,
@@ -132,7 +134,41 @@ export const TELEGRAM = {
132
134
  : `refused chat ${message.conversation}${message.person.username ? ` (@${message.person.username})` : ''} — to allow it, add "${message.conversation}": "<a spore>" to the people of ${botFile}`
133
135
  }
134
136
 
135
- 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
+ }
136
172
 
137
173
  const personOf = ({ id, first_name: first, last_name: last, username }) =>
138
174
  ({ id: String(id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username, groups: [] })
@@ -196,7 +232,7 @@ export function normalize (update, bot, me) {
196
232
  text,
197
233
  command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
198
234
  addresses: addressed ? null : [],
199
- files: FILES.filter(kind => message[kind]).length
235
+ files: filesOf(message)
200
236
  }
201
237
  }
202
238
 
@@ -217,15 +253,37 @@ export function createAdapter ({ telegram, bot, me }) {
217
253
  * One long poll after another until `signal` aborts. An update is handed to `onMessage`, or a
218
254
  * press to `onChoice`, and awaited before the offset moves past it, since moving it is what
219
255
  * confirms the update to Telegram: a message is confirmed only once the spore has it, and a
220
- * press once it is written down.
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.
221
258
  */
222
259
  async listen ({ onMessage, onChoice, signal, log, sleep }) {
223
260
  let offset
261
+ let seen = -1
262
+ let album = null
224
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
+ }
225
283
  while (!signal.aborted) {
226
284
  let updates
227
285
  try {
228
- updates = await telegram.updates(offset, { signal })
286
+ updates = await telegram.updates(offset, { seconds: album ? 0 : 30, signal })
229
287
  backoff = 1000
230
288
  } catch (err) {
231
289
  if (signal.aborted) break
@@ -238,19 +296,21 @@ export function createAdapter ({ telegram, bot, me }) {
238
296
  backoff = Math.min(backoff * 2, 60_000)
239
297
  continue
240
298
  }
241
- for (const update of updates) {
242
- const query = update.callback_query
243
- if (query) {
244
- const acknowledge = (text) => telegram.answer(query.id, text)
245
- const choice = choiceOf(query)
246
- if (choice) await onChoice({ ...choice, acknowledge })
247
- else await acknowledge(null).catch(err => log(`a press was not answered — ${said(err)}`))
248
- } else {
249
- const message = normalize(update, bot, me)
250
- 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
251
309
  }
310
+ await take(update)
252
311
  offset = update.update_id + 1
253
312
  }
313
+ if (album) await sleep(ALBUM_QUIET_MS)
254
314
  }
255
315
  if (offset !== undefined) {
256
316
  await telegram.updates(offset, { seconds: 0 }).catch(err => log(`could not confirm the last updates — ${said(err)}`))
@@ -282,6 +342,8 @@ export function createAdapter ({ telegram, bot, me }) {
282
342
 
283
343
  typing: (conversation) => telegram.typing(Number(conversation)),
284
344
 
345
+ fetch: async (ref, { most }) => telegram.download((await telegram.file(ref)).file_path, { most }),
346
+
285
347
  checkBody: (native) => {
286
348
  const fits = Object.keys(native).every(key => key === 'html') && typeof native.html === 'string' && native.html.trim() !== '' && native.html.length <= 4096
287
349
  return fits ? null : BODY
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
 
@@ -100,6 +101,12 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
100
101
  return call('sendDocument', form, { timeoutMs: 300_000 })
101
102
  },
102
103
 
103
- 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 })
104
111
  }
105
112
  }