@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 +6 -2
- package/package.json +3 -3
- package/src/adapter.js +77 -15
- package/src/api.js +8 -1
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
|
-
|
|
77
|
-
the
|
|
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.
|
|
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.
|
|
34
|
+
"@rhizogrip/spore": "^1.26.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"test": "vitest run"
|
|
38
38
|
},
|
|
39
39
|
"spore": {
|
|
40
|
-
"works": "^1.
|
|
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
|
|
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:
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
}
|