@rhizogrip/spore-plugin-telegram 0.9.0 → 0.10.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 +11 -0
- package/package.json +3 -3
- package/src/adapter.js +72 -12
- package/src/api.js +23 -1
- package/src/format.js +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,17 @@ that does not reach the spore through this bot is refused, and the log says why.
|
|
|
92
92
|
|
|
93
93
|
A turn started with `spore send` is shown only there; no chat is told.
|
|
94
94
|
|
|
95
|
+
## Approvals
|
|
96
|
+
|
|
97
|
+
An approval your program asks in a chat this bot reaches
|
|
98
|
+
([INTEGRATION §Asking a person to approve](../../INTEGRATION.md#asking-a-person-to-approve)) is
|
|
99
|
+
posted as a message — the request's `telegram` body, `{ "html": "…" }` in Telegram's HTML, or its
|
|
100
|
+
`default` text and facts, a line each — with a button per option on its own row, green for
|
|
101
|
+
`positive` and red for `destructive` where the Telegram app shows colours. A press is taken only
|
|
102
|
+
from someone the approval names, and Telegram shows them `Apply — recorded`, or why it was not
|
|
103
|
+
taken. The message is then edited: replaced by the body your hook answered, or kept with its
|
|
104
|
+
buttons gone and who chose what below.
|
|
105
|
+
|
|
95
106
|
## The allow-list is the routing
|
|
96
107
|
|
|
97
108
|
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.10.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.24.0"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"test": "vitest run"
|
|
38
38
|
},
|
|
39
39
|
"spore": {
|
|
40
|
-
"works": "^1.
|
|
40
|
+
"works": "^1.24.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,8 @@ 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 BODY = 'a Telegram body is { "html": "<Telegram\'s HTML, at most 4096 characters>" }'
|
|
34
36
|
|
|
35
37
|
const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
|
|
36
38
|
|
|
@@ -113,7 +115,7 @@ export const TELEGRAM = {
|
|
|
113
115
|
routes,
|
|
114
116
|
usage: { help: `${HELP}; ${EXTEND}.`, briefHelp: `${HELP}.`, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
|
|
115
117
|
limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500, typingEveryMs: 4000 },
|
|
116
|
-
can: { upload: true, remove: true, react: true, typing: true, reply: true },
|
|
118
|
+
can: { upload: true, remove: true, react: true, typing: true, reply: true, choose: true },
|
|
117
119
|
showShared: 'react',
|
|
118
120
|
reactions: REACTIONS,
|
|
119
121
|
hint (err, { file, bot }) {
|
|
@@ -132,6 +134,32 @@ export const TELEGRAM = {
|
|
|
132
134
|
|
|
133
135
|
const FILES = ['photo', 'document', 'video', 'audio', 'voice', 'video_note', 'animation', 'sticker']
|
|
134
136
|
|
|
137
|
+
const personOf = ({ id, first_name: first, last_name: last, username }) =>
|
|
138
|
+
({ id: String(id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username, groups: [] })
|
|
139
|
+
|
|
140
|
+
const whereOf = (chat) => chat.type === 'private' ? 'a private chat on Telegram' : `the Telegram group "${chat.title}"`
|
|
141
|
+
|
|
142
|
+
/** What an approval shows, in Telegram's HTML: the host's own, or the default body — its text, then each fact on a line. */
|
|
143
|
+
const htmlOf = ({ native, fallback }) => native
|
|
144
|
+
? native.html
|
|
145
|
+
: [
|
|
146
|
+
fallback.text ? toHtml(fallback.text) : null,
|
|
147
|
+
fallback.facts?.length ? fallback.facts.map(([label, value]) => `<b>${escape(label)}</b>: ${escape(value)}`).join('\n') : null
|
|
148
|
+
].filter(Boolean).join('\n\n')
|
|
149
|
+
|
|
150
|
+
/** 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. */
|
|
151
|
+
export function choiceOf (query) {
|
|
152
|
+
if (!query.message?.chat) return null
|
|
153
|
+
return {
|
|
154
|
+
conversation: String(query.message.chat.id),
|
|
155
|
+
note: query.message.message_id,
|
|
156
|
+
data: query.data ?? null,
|
|
157
|
+
inputs: null,
|
|
158
|
+
person: personOf(query.from),
|
|
159
|
+
where: whereOf(query.message.chat)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
135
163
|
/** Whether the bot's own `@username` is among what Telegram marked as mentions. */
|
|
136
164
|
const mentions = (message, me) => {
|
|
137
165
|
const source = message.text ?? message.caption ?? ''
|
|
@@ -157,14 +185,13 @@ export function normalize (update, bot, me) {
|
|
|
157
185
|
const addressed = !shared || Boolean(command && (command[2] || VERBS.has(command[1].toLowerCase()))) ||
|
|
158
186
|
mentions(message, me) || message.reply_to_message?.from?.id === me.id
|
|
159
187
|
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
188
|
return {
|
|
162
189
|
id: `${bot}:${update.update_id}`,
|
|
163
190
|
conversation: String(message.chat.id),
|
|
164
191
|
message: message.message_id,
|
|
165
|
-
person:
|
|
192
|
+
person: personOf(message.from),
|
|
166
193
|
spore: null,
|
|
167
|
-
where:
|
|
194
|
+
where: whereOf(message.chat),
|
|
168
195
|
shared,
|
|
169
196
|
text,
|
|
170
197
|
command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
|
|
@@ -187,11 +214,12 @@ export function createAdapter ({ telegram, bot, me }) {
|
|
|
187
214
|
},
|
|
188
215
|
|
|
189
216
|
/**
|
|
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
|
|
217
|
+
* One long poll after another until `signal` aborts. An update is handed to `onMessage`, or a
|
|
218
|
+
* press to `onChoice`, and awaited before the offset moves past it, since moving it is what
|
|
219
|
+
* confirms the update to Telegram: a message is confirmed only once the spore has it, and a
|
|
220
|
+
* press once it is written down.
|
|
193
221
|
*/
|
|
194
|
-
async listen ({ onMessage, signal, log, sleep }) {
|
|
222
|
+
async listen ({ onMessage, onChoice, signal, log, sleep }) {
|
|
195
223
|
let offset
|
|
196
224
|
let backoff = 1000
|
|
197
225
|
while (!signal.aborted) {
|
|
@@ -211,8 +239,16 @@ export function createAdapter ({ telegram, bot, me }) {
|
|
|
211
239
|
continue
|
|
212
240
|
}
|
|
213
241
|
for (const update of updates) {
|
|
214
|
-
const
|
|
215
|
-
if (
|
|
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)
|
|
251
|
+
}
|
|
216
252
|
offset = update.update_id + 1
|
|
217
253
|
}
|
|
218
254
|
}
|
|
@@ -244,6 +280,30 @@ export function createAdapter ({ telegram, bot, me }) {
|
|
|
244
280
|
|
|
245
281
|
react: (conversation, message, emoji) => telegram.react(Number(conversation), message, emoji),
|
|
246
282
|
|
|
247
|
-
typing: (conversation) => telegram.typing(Number(conversation))
|
|
283
|
+
typing: (conversation) => telegram.typing(Number(conversation)),
|
|
284
|
+
|
|
285
|
+
checkBody: (native) => {
|
|
286
|
+
const fits = Object.keys(native).every(key => key === 'html') && typeof native.html === 'string' && native.html.trim() !== '' && native.html.length <= 4096
|
|
287
|
+
return fits ? null : BODY
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
/** The question, each choice a button on its own row, coloured where it has a style. */
|
|
291
|
+
ask: async (conversation, shown, choices) => {
|
|
292
|
+
const text = htmlOf(shown)
|
|
293
|
+
const keyboard = choices.map(choice => [{ text: choice.label, callback_data: choice.data, ...(choice.style ? { style: STYLES[choice.style] } : {}) }])
|
|
294
|
+
const message = await telegram.ask(Number(conversation), text, keyboard)
|
|
295
|
+
return { note: message.message_id, shown: { text, parse_mode: 'HTML', reply_markup: { inline_keyboard: keyboard } } }
|
|
296
|
+
},
|
|
297
|
+
|
|
298
|
+
/** The question closed: replaced by `native` or `fallback`, or kept as `shown` with how it ended below. */
|
|
299
|
+
settle: async (conversation, note, replacement) => {
|
|
300
|
+
const text = replacement.shown ? `${replacement.shown.text}\n\n<b>${escape(replacement.outcome)}</b>` : htmlOf(replacement)
|
|
301
|
+
try {
|
|
302
|
+
await telegram.close(Number(conversation), Number(note), text)
|
|
303
|
+
} catch (err) {
|
|
304
|
+
if (!NOT_MODIFIED.test(err.detail ?? '')) throw err
|
|
305
|
+
}
|
|
306
|
+
return { text, parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } }
|
|
307
|
+
}
|
|
248
308
|
}
|
|
249
309
|
}
|
package/src/api.js
CHANGED
|
@@ -49,7 +49,7 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
|
|
|
49
49
|
return {
|
|
50
50
|
/** `offset` confirms every update before it to Telegram. */
|
|
51
51
|
updates: (offset, { seconds = 30, signal } = {}) =>
|
|
52
|
-
call('getUpdates', { offset, timeout: seconds, allowed_updates: ['message'] }, { timeoutMs: (seconds + 15) * 1000, signal }),
|
|
52
|
+
call('getUpdates', { offset, timeout: seconds, allowed_updates: ['message', 'callback_query'] }, { timeoutMs: (seconds + 15) * 1000, signal }),
|
|
53
53
|
|
|
54
54
|
say: (chat, text, { answering = null, html = false } = {}) => call('sendMessage', {
|
|
55
55
|
chat_id: chat,
|
|
@@ -62,6 +62,28 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
|
|
|
62
62
|
revise: (chat, message, text) =>
|
|
63
63
|
call('editMessageText', { chat_id: chat, message_id: message, text, link_preview_options: { is_disabled: true } }),
|
|
64
64
|
|
|
65
|
+
/** A question in Telegram's HTML with `keyboard`, rows of inline buttons, under it. */
|
|
66
|
+
ask: (chat, html, keyboard) => call('sendMessage', {
|
|
67
|
+
chat_id: chat,
|
|
68
|
+
text: html,
|
|
69
|
+
parse_mode: 'HTML',
|
|
70
|
+
link_preview_options: { is_disabled: true },
|
|
71
|
+
reply_markup: { inline_keyboard: keyboard }
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
/** A question's text replaced, and its buttons gone. */
|
|
75
|
+
close: (chat, message, html) => call('editMessageText', {
|
|
76
|
+
chat_id: chat,
|
|
77
|
+
message_id: message,
|
|
78
|
+
text: html,
|
|
79
|
+
parse_mode: 'HTML',
|
|
80
|
+
link_preview_options: { is_disabled: true },
|
|
81
|
+
reply_markup: { inline_keyboard: [] }
|
|
82
|
+
}),
|
|
83
|
+
|
|
84
|
+
/** A press answered, with `text` as the short notice Telegram shows, or none. */
|
|
85
|
+
answer: (query, text) => call('answerCallbackQuery', { callback_query_id: query, ...(text ? { text } : {}) }),
|
|
86
|
+
|
|
65
87
|
remove: (chat, message) => call('deleteMessage', { chat_id: chat, message_id: message }),
|
|
66
88
|
|
|
67
89
|
/** `emoji` on the message as the bot's one reaction, or none when it is null. */
|
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('')
|