@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 +3 -1
- package/package.json +6 -3
- package/src/adapter.js +11 -1
- package/src/api.js +2 -1
- package/src/format.js +76 -0
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`.
|
|
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.
|
|
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.
|
|
34
|
+
"@rhizogrip/spore": "^1.16.0"
|
|
32
35
|
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"test": "vitest run"
|
|
35
38
|
},
|
|
36
39
|
"spore": {
|
|
37
|
-
"works": "^1.
|
|
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 } = {}) =>
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
13
|
+
const attribute = (text) => escape(text).replace(/"/g, '"')
|
|
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)))
|