@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 +17 -2
- package/package.json +3 -3
- package/src/adapter.js +139 -17
- package/src/api.js +31 -2
- package/src/format.js +1 -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
|
|
|
@@ -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.
|
|
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
|
@@ -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
|
|
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:
|
|
228
|
+
person: personOf(message.from),
|
|
166
229
|
spore: null,
|
|
167
|
-
where:
|
|
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:
|
|
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
|
|
191
|
-
* awaited before the offset moves past it, since moving it is what
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
12
|
+
export const escape = (text) => String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
13
13
|
const attribute = (text) => escape(text).replace(/"/g, '"')
|
|
14
14
|
|
|
15
15
|
const plain = (tokens) => tokens.map(token => (token.tokens ? plain(token.tokens) : token.text ?? '')).join('')
|