@rhizogrip/spore-plugin-telegram 0.4.0 → 0.6.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
@@ -49,7 +49,9 @@ Write to the bot and the message goes to the spore the chat is mapped to, which
49
49
  wrote it and where — your name and username, the private chat or the group. The bot posts
50
50
  `working…` and shows which tools run as they run. When the turn ends, the answer arrives as a new
51
51
  message replying to yours, so it notifies you however long the turn took, and `working…` is
52
- removed; one Telegram will no longer let the bot remove, past 48 hours, says `answered below`. How
52
+ removed; one Telegram will no longer let the bot remove, past 48 hours, says `answered below`. The
53
+ answer's Markdown is shown as Telegram formats it — bold, italic, code, code blocks, links, quotes;
54
+ a heading as a bold line, a table in a monospace block — and as plain text if Telegram refuses it. How
53
55
  the turn ended is added only when it makes the answer partial — a wrap or a stop. A longer answer
54
56
  than Telegram's 4096 continues in further messages. What the turn left in `data/out/` follows as
55
57
  documents, up to the 50 a turn hands back, then a line counting the rest; a file over Telegram's
@@ -109,6 +111,18 @@ It is then shown `reading data/report.md`, `running git log --oneline -5`,
109
111
  `source forgejo --path …` — the first line, cut short. Choose it for a chat you would let
110
112
  read the commands; the others keep the kinds.
111
113
 
114
+ A chat can be shown a turn more quietly, which is what a group gets unless its entry says
115
+ otherwise: `"show": "react"` puts a 🫡 on your message when the spore takes it and shows Telegram's
116
+ `typing…` while the turn runs, then takes the 🫡 off when the answer arrives — or puts a 💔 there
117
+ when the turn ended without one. `"show": "working"` is the `working…` message, what a private
118
+ chat gets; `"show": "both"` is both. The bot's two emoji are its own to choose, from the ones
119
+ Telegram lets a bot react with (✅ and ❌ are not among them; the log lists them when one is refused):
120
+
121
+ ```json
122
+ { "token": "${OPS_BOT_TOKEN}", "react": { "taken": "👀", "failed": "🤷" },
123
+ "chats": { "-1001234567": { "spore": "triage", "show": "react" } } }
124
+ ```
125
+
112
126
  Two chats may point at one spore and then share its conversation. A reply goes to the
113
127
  chat that asked; a turn nobody in a chat started goes to the spore's default.
114
128
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizogrip/spore-plugin-telegram",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -27,14 +27,17 @@
27
27
  "README.md",
28
28
  "NOTICE"
29
29
  ],
30
+ "dependencies": {
31
+ "marked": "^18.0.11"
32
+ },
30
33
  "peerDependencies": {
31
- "@rhizogrip/spore": "^1.15.0"
34
+ "@rhizogrip/spore": "^1.18.0"
32
35
  },
33
36
  "scripts": {
34
37
  "test": "vitest run"
35
38
  },
36
39
  "spore": {
37
- "works": "^1.15.0",
40
+ "works": "^1.18.0",
38
41
  "services": {
39
42
  "telegram": {
40
43
  "what": "a bot of yours in Telegram chats, each chat reaching its spore",
package/src/adapter.js CHANGED
@@ -6,18 +6,34 @@
6
6
 
7
7
  import { SporeError } from '@rhizogrip/spore'
8
8
 
9
+ import { toHtml } from './format.js'
10
+
9
11
  const COMMAND = /^\/([a-z]+)(@\w+)?(?:\s+(.+))?$/is
10
12
  const NOT_MODIFIED = /message is not modified/
13
+ const CANNOT_PARSE = /can't parse entities/i
11
14
  const SHAPE = '{ "token": "${TRIAGE_BOT_TOKEN}", "chats": { "<chat id>": "<spore>" } }' // eslint-disable-line no-template-curly-in-string
12
15
 
16
+ /** What a bot may react with: `ReactionTypeEmoji` in the Bot API. */
17
+ const REACTIONS = [
18
+ '❤', '👍', '👎', '🔥', '🥰', '👏', '😁', '🤔', '🤯', '😱',
19
+ '🤬', '😢', '🎉', '🤩', '🤮', '💩', '🙏', '👌', '🕊', '🤡',
20
+ '🥱', '🥴', '😍', '🐳', '❤‍🔥', '🌚', '🌭', '💯', '🤣', '⚡',
21
+ '🍌', '🏆', '💔', '🤨', '😐', '🍓', '🍾', '💋', '🖕', '😈',
22
+ '😴', '😭', '🤓', '👻', '👨‍💻', '👀', '🎃', '🙈', '😇', '😨',
23
+ '🤝', '✍', '🤗', '🫡', '🎅', '🎄', '☃', '💅', '🤪', '🗿',
24
+ '🆒', '💘', '🙉', '🦄', '😘', '💊', '🙊', '😎', '👾', '🤷‍♂',
25
+ '🤷', '🤷‍♀', '😡'
26
+ ]
27
+
13
28
  const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
14
29
 
15
30
  const invalid = (path, problem, detail) => new SporeError(`${path} ${problem}`, { type: 'TELEGRAM_BOT_INVALID', detail })
16
31
 
17
32
  /**
18
- * `{ "<chat id>": "<spore>" }`, or `{ "<chat id>": { "spore": "<spore>", "details": true } }` for a
19
- * chat shown what each call does — its command or path — rather than only what kind of call it
20
- * is: a Telegram message leaves the machine, so that is a choice made per chat.
33
+ * `{ "<chat id>": "<spore>" }`, or `{ "<chat id>": { "spore": "<spore>", "details": true, "show":
34
+ * "react" } }`: `details` for a chat shown what each call does — its command or path — rather than
35
+ * only what kind of call it is, since a Telegram message leaves the machine; `show` for how a turn
36
+ * shows itself there.
21
37
  */
22
38
  function routes (raw, path) {
23
39
  if (typeof raw.chats !== 'object' || raw.chats === null || Array.isArray(raw.chats)) {
@@ -26,11 +42,11 @@ function routes (raw, path) {
26
42
  const table = new Map()
27
43
  for (const [chat, entry] of Object.entries(raw.chats)) {
28
44
  const spore = typeof entry === 'string' ? entry : entry?.spore
29
- const extra = typeof entry === 'object' && entry !== null ? Object.keys(entry).filter(key => !['spore', 'details'].includes(key)) : []
45
+ const extra = typeof entry === 'object' && entry !== null ? Object.keys(entry).filter(key => !['spore', 'details', 'show'].includes(key)) : []
30
46
  if (typeof spore !== 'string' || !spore || extra.length || (typeof entry === 'object' && entry.details !== undefined && typeof entry.details !== 'boolean')) {
31
- throw invalid(path, `maps ${chat} to something that is not a spore`, 'each chat is a chat id against the name of a spore it may reach, or { "spore": "<name>", "details": true }')
47
+ throw invalid(path, `maps ${chat} to something that is not a spore`, 'each chat is a chat id against the name of a spore it may reach, or { "spore": "<name>", "details": true, "show": "react" }')
32
48
  }
33
- table.set(chat, { spores: [spore], details: typeof entry === 'object' && entry.details === true })
49
+ table.set(chat, { spores: [spore], details: typeof entry === 'object' && entry.details === true, show: typeof entry === 'object' ? entry.show ?? null : null })
34
50
  }
35
51
  return table
36
52
  }
@@ -41,7 +57,7 @@ export const TELEGRAM = {
41
57
  kind: 'telegram',
42
58
  label: 'Telegram',
43
59
  noun: 'chat',
44
- fields: ['token', 'chats'],
60
+ fields: ['token', 'chats', 'react'],
45
61
  credentials: ['token'],
46
62
  stateFields: [],
47
63
  sourceVariables: [],
@@ -49,8 +65,9 @@ export const TELEGRAM = {
49
65
  example: SHAPE,
50
66
  routes,
51
67
  usage: { help: HELP, extend: '/extend wall 30m, /extend cost 1.50 or /extend rounds 20' },
52
- limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500 },
53
- can: { upload: true, remove: true },
68
+ limits: { message: 4096, file: 50 * 1024 * 1024, editEveryMs: 2500, typingEveryMs: 4000 },
69
+ can: { upload: true, remove: true, react: true, typing: true },
70
+ reactions: REACTIONS,
54
71
  hint (err, { file, bot }) {
55
72
  if (err.type === 'TELEGRAM_NO_BOT') {
56
73
  const variable = `${bot.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_BOT_TOKEN`
@@ -78,6 +95,7 @@ export function normalize (update, bot) {
78
95
  message: message.message_id,
79
96
  person: { id: String(message.from.id), name: `${[first, last].filter(Boolean).join(' ')}${username ? ` (@${username})` : ''}`, username },
80
97
  where: message.chat.type === 'private' ? 'a private chat on Telegram' : `the Telegram group "${message.chat.title}"`,
98
+ shared: message.chat.type !== 'private',
81
99
  text,
82
100
  command: command ? { verb: command[1].toLowerCase(), args: command[3] ?? '' } : null,
83
101
  addresses: null,
@@ -124,7 +142,14 @@ export function createAdapter ({ telegram, bot }) {
124
142
  }
125
143
  },
126
144
 
127
- post: async (conversation, text, { answering = null } = {}) => (await telegram.say(Number(conversation), text, { answering })).message_id,
145
+ post: async (conversation, text, { answering = null, markdown = false } = {}) => {
146
+ try {
147
+ return (await telegram.say(Number(conversation), markdown ? toHtml(text) : text, { answering, html: markdown })).message_id
148
+ } catch (err) {
149
+ if (!markdown || !CANNOT_PARSE.test(err.detail ?? '')) throw err
150
+ throw new SporeError('Telegram refused the formatting', { type: 'TELEGRAM_FORMAT_REFUSED', detail: err.detail, context: { formatRefused: true } })
151
+ }
152
+ },
128
153
 
129
154
  edit: async (conversation, note, text) => {
130
155
  try {
@@ -136,6 +161,10 @@ export function createAdapter ({ telegram, bot }) {
136
161
 
137
162
  remove: (conversation, note) => telegram.remove(Number(conversation), note),
138
163
 
139
- upload: (conversation, file, caption) => telegram.document(Number(conversation), file, caption)
164
+ upload: (conversation, file, caption) => telegram.document(Number(conversation), file, caption),
165
+
166
+ react: (conversation, message, emoji) => telegram.react(Number(conversation), message, emoji),
167
+
168
+ typing: (conversation) => telegram.typing(Number(conversation))
140
169
  }
141
170
  }
package/src/api.js CHANGED
@@ -51,10 +51,11 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
51
51
  updates: (offset, { seconds = 30, signal } = {}) =>
52
52
  call('getUpdates', { offset, timeout: seconds, allowed_updates: ['message'] }, { timeoutMs: (seconds + 15) * 1000, signal }),
53
53
 
54
- say: (chat, text, { answering = null } = {}) => call('sendMessage', {
54
+ say: (chat, text, { answering = null, html = false } = {}) => call('sendMessage', {
55
55
  chat_id: chat,
56
56
  text,
57
57
  link_preview_options: { is_disabled: true },
58
+ ...(html ? { parse_mode: 'HTML' } : {}),
58
59
  ...(answering === null ? {} : { reply_parameters: { message_id: answering, allow_sending_without_reply: true } })
59
60
  }),
60
61
 
@@ -63,6 +64,12 @@ export function createTelegram (token, { fetchImpl = fetch, base = BASE } = {})
63
64
 
64
65
  remove: (chat, message) => call('deleteMessage', { chat_id: chat, message_id: message }),
65
66
 
67
+ /** `emoji` on the message as the bot's one reaction, or none when it is null. */
68
+ react: (chat, message, emoji) =>
69
+ call('setMessageReaction', { chat_id: chat, message_id: message, reaction: emoji === null ? [] : [{ type: 'emoji', emoji }] }),
70
+
71
+ typing: (chat) => call('sendChatAction', { chat_id: chat, action: 'typing' }),
72
+
66
73
  document: (chat, { path, mime }, caption) => {
67
74
  const form = new FormData()
68
75
  form.append('chat_id', String(chat))
package/src/format.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Markdown, as a model writes it, in the HTML Telegram renders. Bold, italic, strikethrough, code,
3
+ * code blocks with their language, links and quotes are Telegram's own; a heading becomes a bold
4
+ * line, a list keeps its bullets as text, a table becomes a monospace block. Telegram refuses a
5
+ * message holding any tag outside its small set, so everything else is escaped and shown as written.
6
+ */
7
+
8
+ import { Lexer } from 'marked'
9
+
10
+ const LINKABLE = /^(https?:|mailto:|tg:)/i
11
+
12
+ const escape = (text) => String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
13
+ const attribute = (text) => escape(text).replace(/"/g, '&quot;')
14
+
15
+ const plain = (tokens) => tokens.map(token => (token.tokens ? plain(token.tokens) : token.text ?? '')).join('')
16
+
17
+ function inline (tokens) {
18
+ return tokens.map((token) => {
19
+ if (token.type === 'strong') return `<b>${inline(token.tokens)}</b>`
20
+ if (token.type === 'em') return `<i>${inline(token.tokens)}</i>`
21
+ if (token.type === 'del') return `<s>${inline(token.tokens)}</s>`
22
+ if (token.type === 'codespan') return `<code>${escape(token.text)}</code>`
23
+ if (token.type === 'br') return '\n'
24
+ if (token.type === 'link') return LINKABLE.test(token.href) ? `<a href="${attribute(token.href)}">${inline(token.tokens)}</a>` : inline(token.tokens)
25
+ if (token.type === 'image') return LINKABLE.test(token.href) ? `<a href="${attribute(token.href)}">${escape(token.text || token.href)}</a>` : escape(token.text)
26
+ if (token.type === 'text') return token.tokens ? inline(token.tokens) : escape(token.text)
27
+ if (token.type === 'escape') return escape(token.text)
28
+ if (token.type === 'checkbox') return token.checked ? '☑ ' : '☐ '
29
+ return escape(token.raw)
30
+ }).join('')
31
+ }
32
+
33
+ function list (token, depth) {
34
+ const indent = ' '.repeat(depth)
35
+ return token.items.map((item, i) => {
36
+ const mark = token.ordered ? `${Number(token.start) + i}.` : '•'
37
+ const box = item.tokens.filter(child => child.type === 'checkbox').map(child => inline([child])).join('')
38
+ const parts = item.tokens.filter(child => child.type !== 'checkbox').map(child => (child.type === 'list' ? list(child, depth + 1) : block([child], depth + 1)))
39
+ return `${indent}${mark} ${box}${parts.join('\n')}`
40
+ }).join('\n')
41
+ }
42
+
43
+ function table (token) {
44
+ const rows = [token.header, ...token.rows].map(row => row.map(cell => plain(cell.tokens)))
45
+ const widths = rows[0].map((_, column) => Math.max(...rows.map(row => row[column].length)))
46
+ const line = (row) => row.map((cell, column) => cell.padEnd(widths[column])).join(' | ').trimEnd()
47
+ return `<pre>${escape([line(rows[0]), widths.map(width => '-'.repeat(width)).join('-+-'), ...rows.slice(1).map(line)].join('\n'))}</pre>`
48
+ }
49
+
50
+ function block (tokens, depth = 0) {
51
+ const out = []
52
+ for (const token of tokens) {
53
+ if (token.type === 'space') continue
54
+ if (token.type === 'code') {
55
+ const language = token.lang?.split(/\s/)[0]
56
+ out.push(`<pre><code${language ? ` class="language-${attribute(language)}"` : ''}>${escape(token.text)}</code></pre>`)
57
+ } else if (token.type === 'heading') {
58
+ out.push(`<b>${inline(token.tokens)}</b>`)
59
+ } else if (token.type === 'paragraph' || token.type === 'text') {
60
+ out.push(inline(token.tokens ?? [token]))
61
+ } else if (token.type === 'blockquote') {
62
+ out.push(`<blockquote>${block(token.tokens, depth)}</blockquote>`)
63
+ } else if (token.type === 'list') {
64
+ out.push(list(token, depth))
65
+ } else if (token.type === 'table') {
66
+ out.push(table(token))
67
+ } else if (token.type === 'hr') {
68
+ out.push('──────────')
69
+ } else {
70
+ out.push(escape(token.raw.trimEnd()))
71
+ }
72
+ }
73
+ return out.join('\n\n')
74
+ }
75
+
76
+ export const toHtml = (markdown) => block(Lexer.lex(String(markdown)))