@rhizogrip/spore-plugin-telegram 0.8.1 → 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
@@ -121,6 +132,15 @@ It is then shown `reading data/report.md`, `running git log --oneline -5`,
121
132
  `source forgejo --path …` — the first line, cut short. Choose it for a chat you would let
122
133
  read the commands; the others keep the kinds. A group takes `"details": true` the same way.
123
134
 
135
+ A bot that serves people who should see their answers and not the machinery — your customers, say —
136
+ takes `"brief": true` beside `people` and `groups`: each chat is then told its spore's state and its
137
+ answers, and nothing of how it runs. `/status` says `working`, `idle`, `paused` or how many messages
138
+ wait — never the model, the spend, the turns or the limits; an answer comes without how the turn
139
+ ended, a turn with none says `no answer this time` and the log says why; a limit reached shows as
140
+ `wrapping up`; and `/extend` is not offered, since what a turn may spend is yours. A person or a
141
+ group takes `"brief"` as well, `false` to be told everything in a brief bot; `details` is refused
142
+ beside it.
143
+
124
144
  A chat can be shown a turn more quietly, which is what a group gets unless its entry says
125
145
  otherwise: `"show": "react"` puts a 👨‍💻 on your message when the spore takes it and shows Telegram's
126
146
  `typing…` while the turn runs, then takes the 👨‍💻 off when the answer arrives — or puts a 💔 there
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-telegram",
3
- "version": "0.8.1",
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.21.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.21.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/
@@ -27,10 +27,12 @@ const REACTIONS = [
27
27
  ]
28
28
 
29
29
  const FIELDS = ['token', 'people', 'groups', 'react']
30
- const PERSON_FIELDS = ['spore', 'details', 'show']
31
- const GROUP_FIELDS = ['spores', 'people', 'details', 'show', 'overhear']
30
+ const PERSON_FIELDS = ['spore', 'details', 'show', 'brief']
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
 
@@ -43,6 +45,8 @@ const strays = (path, kind, id, entry, takes) => {
43
45
  throw invalid(path, `gives ${kind} ${id} ${extra.join(', ')}, which a ${kind} does not take`, `a ${kind} takes ${takes.join(', ')}${bots.length ? `; ${bots.join(', ')} is the bot's, beside people and groups` : ''}`)
44
46
  }
45
47
 
48
+ const briefOf = (entry) => typeof entry.brief === 'boolean' ? entry.brief : null
49
+
46
50
  const flags = (path, kind, id, entry, names) => {
47
51
  for (const name of names) {
48
52
  if (entry[name] !== undefined && typeof entry[name] !== 'boolean') throw invalid(path, `has a ${kind} ${id} whose ${name} is not true or false`)
@@ -50,13 +54,14 @@ const flags = (path, kind, id, entry, names) => {
50
54
  }
51
55
 
52
56
  /**
53
- * The private chats, `people: { "<person id>": "<spore>" }` or `{ "spore", "details", "show" }`,
54
- * and the groups, `groups: { "<group id>": { "spores": ["<spore>"], "people": [...], "details",
55
- * "show", "overhear" } }`. `details` shows a chat what each call does — its command or path — rather
56
- * than only what kind of call it is, since a Telegram message leaves the machine; `show` how a turn
57
- * shows itself there. A group names who may address its spore — user ids, or `*` for everyone in it
58
- * — and has one spore: in a group a bot speaks as itself, so a second spore is a second bot.
59
- * `overhear` gives the spore what they say there that is not to it.
57
+ * The private chats, `people: { "<person id>": "<spore>" }` or `{ "spore", "details", "show",
58
+ * "brief" }`, and the groups, `groups: { "<group id>": { "spores": ["<spore>"], "people": [...],
59
+ * "details", "show", "overhear", "brief" } }`. `details` shows a chat what each call does — its
60
+ * command or path — rather than only what kind of call it is, since a Telegram message leaves the
61
+ * machine; `show` how a turn shows itself there; `brief` the bot's own, for this chat. A group names
62
+ * who may address its spore — user ids, or `*` for everyone in it — and has one spore: in a group a
63
+ * bot speaks as itself, so a second spore is a second bot. `overhear` gives the spore what they say
64
+ * there that is not to it.
60
65
  */
61
66
  function routes (raw, path) {
62
67
  if (raw.people === undefined && raw.groups === undefined) {
@@ -73,28 +78,29 @@ function routes (raw, path) {
73
78
  const shaped = typeof entry === 'object' && entry !== null && !Array.isArray(entry)
74
79
  if (shaped) {
75
80
  strays(path, 'person', person, entry, PERSON_FIELDS)
76
- flags(path, 'person', person, entry, ['details'])
81
+ flags(path, 'person', person, entry, ['details', 'brief'])
77
82
  }
78
83
  const spore = shaped ? entry.spore : entry
79
84
  if (typeof spore !== 'string' || !spore) throw invalid(path, `maps person ${person} to something that is not a spore`, 'each person is their id against the name of the spore they reach, or { "spore": "<name>", "details": true, "show": "react" }')
80
- table.set(person, { spores: [spore], details: shaped && entry.details === true, show: shaped ? entry.show ?? null : null, people: null, overhear: false })
85
+ table.set(person, { spores: [spore], details: shaped && entry.details === true, show: shaped ? entry.show ?? null : null, brief: shaped ? briefOf(entry) : null, people: null, overhear: false })
81
86
  }
82
87
  for (const [group, entry] of entries('groups')) {
83
88
  if (!GROUP.test(group)) throw invalid(path, `names "${group}" among its groups, which is not a group's id`, 'a group\'s id is negative (-100… for a supergroup); a person\'s goes under people')
84
89
  if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw invalid(path, `has a group ${group} that is not { "spores": [...], "people": [...] }`)
85
90
  strays(path, 'group', group, entry, GROUP_FIELDS)
86
- flags(path, 'group', group, entry, ['details', 'overhear'])
91
+ flags(path, 'group', group, entry, ['details', 'overhear', 'brief'])
87
92
  if (!Array.isArray(entry.spores) || !entry.spores.length || !entry.spores.every(spore => typeof spore === 'string' && spore)) throw invalid(path, `has a group ${group} with no spores`)
88
93
  if (entry.spores.length > 1) throw invalid(path, `gives group ${group} ${entry.spores.length} spores`, 'a bot speaks in a group as itself, so a group has one spore — a second spore there is a second bot')
89
94
  if (!Array.isArray(entry.people) || !entry.people.every(who => who === '*' || (typeof who === 'string' && PERSON.test(who)))) {
90
95
  throw invalid(path, `has a group ${group} whose people are not user ids or *`, 'people is who may address its spore — ["<user id>", …], or ["*"] for everyone in the group, written out')
91
96
  }
92
- table.set(group, { spores: entry.spores, details: entry.details === true, show: entry.show ?? null, people: entry.people, overhear: entry.overhear === true })
97
+ table.set(group, { spores: entry.spores, details: entry.details === true, show: entry.show ?? null, brief: briefOf(entry), people: entry.people, overhear: entry.overhear === true })
93
98
  }
94
99
  return table
95
100
  }
96
101
 
97
- const HELP = 'Anything you write to me in private goes to the spore; in a group, mention me or reply to me. /status says what it is doing; /stop ends the turn it is running and sets aside what you sent that it had not read; /wrap [10m] asks it to wrap up; /extend wall 30m gives it more, as far as its spore.cmk allows.'
102
+ const HELP = 'Anything you write to me in private goes to the spore; in a group, mention me or reply to me. /status says what it is doing; /stop ends the turn it is running and sets aside what you sent that it had not read; /wrap [10m] asks it to wrap up'
103
+ const EXTEND = '/extend wall 30m gives it more, as far as its spore.cmk allows'
98
104
 
99
105
  export const TELEGRAM = {
100
106
  kind: 'telegram',
@@ -107,9 +113,9 @@ export const TELEGRAM = {
107
113
  refusals: ['TELEGRAM_CONFLICT'],
108
114
  example: SHAPE,
109
115
  routes,
110
- usage: { help: HELP, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
116
+ usage: { help: `${HELP}; ${EXTEND}.`, briefHelp: `${HELP}.`, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
111
117
  limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500, typingEveryMs: 4000 },
112
- 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 },
113
119
  showShared: 'react',
114
120
  reactions: REACTIONS,
115
121
  hint (err, { file, bot }) {
@@ -128,6 +134,32 @@ export const TELEGRAM = {
128
134
 
129
135
  const FILES = ['photo', 'document', 'video', 'audio', 'voice', 'video_note', 'animation', 'sticker']
130
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
+
131
163
  /** Whether the bot's own `@username` is among what Telegram marked as mentions. */
132
164
  const mentions = (message, me) => {
133
165
  const source = message.text ?? message.caption ?? ''
@@ -153,14 +185,13 @@ export function normalize (update, bot, me) {
153
185
  const addressed = !shared || Boolean(command && (command[2] || VERBS.has(command[1].toLowerCase()))) ||
154
186
  mentions(message, me) || message.reply_to_message?.from?.id === me.id
155
187
  const text = command ? written : written?.replace(new RegExp(`@${me.username}\\b[,:]?\\s*`, 'gi'), '').trim() || null
156
- const { first_name: first, last_name: last, username } = message.from
157
188
  return {
158
189
  id: `${bot}:${update.update_id}`,
159
190
  conversation: String(message.chat.id),
160
191
  message: message.message_id,
161
- person: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username, groups: [] },
192
+ person: personOf(message.from),
162
193
  spore: null,
163
- where: shared ? `the Telegram group "${message.chat.title}"` : 'a private chat on Telegram',
194
+ where: whereOf(message.chat),
164
195
  shared,
165
196
  text,
166
197
  command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
@@ -183,11 +214,12 @@ export function createAdapter ({ telegram, bot, me }) {
183
214
  },
184
215
 
185
216
  /**
186
- * One long poll after another until `signal` aborts. An update is handed to `onMessage` and
187
- * awaited before the offset moves past it, since moving it is what confirms the update to
188
- * 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.
189
221
  */
190
- async listen ({ onMessage, signal, log, sleep }) {
222
+ async listen ({ onMessage, onChoice, signal, log, sleep }) {
191
223
  let offset
192
224
  let backoff = 1000
193
225
  while (!signal.aborted) {
@@ -207,8 +239,16 @@ export function createAdapter ({ telegram, bot, me }) {
207
239
  continue
208
240
  }
209
241
  for (const update of updates) {
210
- const message = normalize(update, bot, me)
211
- 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
+ }
212
252
  offset = update.update_id + 1
213
253
  }
214
254
  }
@@ -240,6 +280,30 @@ export function createAdapter ({ telegram, bot, me }) {
240
280
 
241
281
  react: (conversation, message, emoji) => telegram.react(Number(conversation), message, emoji),
242
282
 
243
- 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
+ }
244
308
  }
245
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('')