@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 +15 -1
- package/package.json +6 -3
- package/src/adapter.js +40 -11
- package/src/api.js +8 -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
|
|
@@ -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.
|
|
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.
|
|
34
|
+
"@rhizogrip/spore": "^1.18.0"
|
|
32
35
|
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"test": "vitest run"
|
|
35
38
|
},
|
|
36
39
|
"spore": {
|
|
37
|
-
"works": "^1.
|
|
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
|
|
19
|
-
* chat shown what each call does — its command or path — rather than
|
|
20
|
-
* is
|
|
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 } = {}) =>
|
|
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, '&').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)))
|