@rhizogrip/spore-plugin-telegram 0.4.0 → 0.5.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
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.5.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.16.0"
32
35
  },
33
36
  "scripts": {
34
37
  "test": "vitest run"
35
38
  },
36
39
  "spore": {
37
- "works": "^1.15.0",
40
+ "works": "^1.16.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,8 +6,11 @@
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
 
13
16
  const said = (err) => [err.message, err.detail].filter(Boolean).join(' — ')
@@ -124,7 +127,14 @@ export function createAdapter ({ telegram, bot }) {
124
127
  }
125
128
  },
126
129
 
127
- post: async (conversation, text, { answering = null } = {}) => (await telegram.say(Number(conversation), text, { answering })).message_id,
130
+ post: async (conversation, text, { answering = null, markdown = false } = {}) => {
131
+ try {
132
+ return (await telegram.say(Number(conversation), markdown ? toHtml(text) : text, { answering, html: markdown })).message_id
133
+ } catch (err) {
134
+ if (!markdown || !CANNOT_PARSE.test(err.detail ?? '')) throw err
135
+ throw new SporeError('Telegram refused the formatting', { type: 'TELEGRAM_FORMAT_REFUSED', detail: err.detail, context: { formatRefused: true } })
136
+ }
137
+ },
128
138
 
129
139
  edit: async (conversation, note, text) => {
130
140
  try {
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
 
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)))