@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 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.9.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.23.0"
34
+ "@rhizogrip/spore": "^1.24.0"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "vitest run"
38
38
  },
39
39
  "spore": {
40
- "works": "^1.23.0",
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: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username, groups: [] },
192
+ person: personOf(message.from),
166
193
  spore: null,
167
- where: shared ? `the Telegram group "${message.chat.title}"` : 'a private chat on Telegram',
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` 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.
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 message = normalize(update, bot, me)
215
- if (message) await onMessage(message)
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, '&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('')