@aspalagin/openclaw-max 0.8.2 → 0.9.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 +38 -3
- package/README_EN.md +38 -3
- package/SKILL.md +1 -1
- package/dist/src/accounts.js +1 -0
- package/dist/src/actions.js +8 -5
- package/dist/src/channel-agent-prompt.js +4 -3
- package/dist/src/channel-directory.js +5 -0
- package/dist/src/channel-outbound.js +14 -3
- package/dist/src/config-schema.js +5 -1
- package/dist/src/deliver.js +11 -5
- package/dist/src/format.js +170 -13
- package/dist/src/stream-draft.js +6 -1
- package/openclaw.plugin.json +18 -6
- package/package.json +1 -1
- package/src/accounts.ts +4 -0
- package/src/actions.ts +12 -6
- package/src/channel-agent-prompt.ts +4 -3
- package/src/channel-directory.ts +5 -0
- package/src/channel-outbound.ts +19 -3
- package/src/config-schema.ts +5 -1
- package/src/deliver.ts +10 -4
- package/src/format.ts +196 -13
- package/src/stream-draft.ts +6 -1
package/README.md
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
|
|
7
7
|
Плагин нужен тем, кто пользуется OpenClaw и хочет, чтобы ассистент был доступен в MAX. Понадобятся токен бота MAX (ботов создают организации на [business.max.ru](https://business.max.ru/self)) и собственный gateway OpenClaw.
|
|
8
8
|
|
|
9
|
-
Версия 0.
|
|
9
|
+
Версия 0.9.0. Изменения: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md).
|
|
10
10
|
|
|
11
11
|
## Содержание
|
|
12
12
|
|
|
13
13
|
- [Поддерживаемые типы сообщений](#поддерживаемые-типы-сообщений)
|
|
14
|
+
- [Форматирование исходящих](#форматирование-исходящих)
|
|
14
15
|
- [Требования и совместимость](#требования-и-совместимость)
|
|
15
16
|
- [Установка](#установка)
|
|
16
17
|
- [Быстрый старт](#быстрый-старт)
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
|
|
39
40
|
| Тип | Входящие (MAX → агент) | Исходящие (агент → MAX) |
|
|
40
41
|
|---|---|---|
|
|
41
|
-
| Текст | Да; упоминания распознаются по разметке MAX, `@username` и шаблонам упоминаний ядра | Да; markdown переводится в диалект MAX (
|
|
42
|
+
| Текст | Да; упоминания распознаются по разметке MAX, `@username` и шаблонам упоминаний ядра | Да; markdown переводится в диалект MAX (заголовки, выделение, подчёркивание, линии, таблицы — см. [Форматирование исходящих](#форматирование-исходящих)) и делится на части по 4000 символов; если MAX отклоняет разметку, текст один раз отправляется заново без неё |
|
|
42
43
|
| Изображение | Скачивается и передаётся агенту как медиа | Да; несколько изображений и видео уходят альбомами до 12 штук; публичная https-ссылка на изображение передаётся в MAX ссылкой |
|
|
43
44
|
| Видео | Скачивается (лимит размера — `mediaMaxMb`) | Да |
|
|
44
45
|
| Аудио и голосовые | Скачиваются; если MAX прислал расшифровку, используется она, иначе запись распознаёт ядро OpenClaw | Да; `asVoice` / `audioAsVoice` отправляет вложение MAX `audio` |
|
|
@@ -58,6 +59,39 @@
|
|
|
58
59
|
|
|
59
60
|
Реакции и опросы не поддерживаются.
|
|
60
61
|
|
|
62
|
+
## Форматирование исходящих
|
|
63
|
+
|
|
64
|
+
Агент пишет обычный markdown, плагин переводит его в диалект MAX. Код, блоки ``` и ~~~ и ссылки не меняются. Что MAX разбирает сам, проверено живой отправкой 10.10.2026 (разметка из `GET /messages`):
|
|
65
|
+
|
|
66
|
+
| В ответе агента | В MAX |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `**жирный**`, `*курсив*`, `~~зачёркнутый~~`, `` `код` ``, блоки ```, ссылки, упоминания | как есть |
|
|
69
|
+
| `# Заголовок` | заголовок |
|
|
70
|
+
| `## …` — `###### …` | `# …`: в MAX один уровень заголовка, остальные он показывает простым текстом |
|
|
71
|
+
| `^^текст^^`, `==текст==` | выделение (красным); `==` переводится в `^^` |
|
|
72
|
+
| `<u>текст</u>`, `++текст++` | подчёркивание |
|
|
73
|
+
| `> цитата` | блок цитаты: MAX разбирает её сам (на телефоне проверено 10.10.2026; в `markup` из Bot API цитата не видна) |
|
|
74
|
+
| `---`, `***`, `___`, `* * *` | линия `───`: MAX сам линий не рисует |
|
|
75
|
+
| таблица | узкая — моноширинным блоком, широкая — пунктами; см. ниже |
|
|
76
|
+
|
|
77
|
+
Таблиц в MAX нет, а блок кода на телефоне не прокручивается: длинная строка переносится, и таблица разваливается. Поэтому по умолчанию (ключ `markdown.tables` не задан) плагин решает за каждую таблицу отдельно:
|
|
78
|
+
|
|
79
|
+
- узкая — строка моноширинного блока не длиннее 36 символов (кириллица и латиница — один символ, эмодзи и иероглифы — два): уходит моноширинным блоком с выровненными столбцами. Столько влезает в блок кода на обычном телефоне;
|
|
80
|
+
- широкая — пунктами: на каждую строку таблицы первая ячейка жирным, остальные `• Столбец: значение`. Читается на любом экране. Таблица со ссылками тоже уходит пунктами: в моноширинном блоке от ссылки остаётся только текст.
|
|
81
|
+
|
|
82
|
+
Штатный ключ OpenClaw `markdown.tables` (в `channels.max` или в `accounts.<id>`) задаёт один режим для всех таблиц, без оглядки на ширину:
|
|
83
|
+
|
|
84
|
+
- `bullets` — всегда пунктами, как у Signal и WhatsApp;
|
|
85
|
+
- `code` — всегда моноширинным блоком, даже широкая таблица (на телефоне она переносится);
|
|
86
|
+
- `block` — то же, что `code`;
|
|
87
|
+
- `off` — как есть, сырым текстом с `|`.
|
|
88
|
+
|
|
89
|
+
```json5
|
|
90
|
+
{ channels: { max: { markdown: { tables: "code" } } } }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Ответ форматируется до того, как делится на части по 4000 символов: части меряются по отправляемому тексту, а блок таблицы, который помещается в одно сообщение, не разрывается (более длинный блок делится с закрытием и повторным открытием ```). Черновик стриминга, его правки, доставка ядра (cron, `openclaw message send`) и действия `send`, `edit`, `sendAttachment` форматируются так же, как обычный ответ. Если MAX отклонит разметку, текст, как и раньше, один раз уходит без неё.
|
|
94
|
+
|
|
61
95
|
## Требования и совместимость
|
|
62
96
|
|
|
63
97
|
- OpenClaw ≥ 2026.9.6 (`peerDependencies.openclaw`, `openclaw.compat.minGatewayVersion`). Более ранние версии не работают — это проверено на 2026.9.3–2026.9.5 проверкой типов и набором тестов. В 2026.9.5 нет `createLivePreviewLifecycle` в `openclaw/plugin-sdk/channel-outbound`, поэтому модуль плагина не загружается (на этой функции построен статус хода); в 2026.9.3 и 2026.9.4 вдобавок нет проверки отправителя при ответе на вопросы агента кнопками (`authorize` в `resolveOption`), защищённого хука индикатора набора для heartbeat и формата пояснения к плану. Чтобы пользоваться плагином, обновите OpenClaw.
|
|
@@ -168,6 +202,7 @@ openclaw plugins install npm:@aspalagin/openclaw-max
|
|
|
168
202
|
| `streamMode` | `off` \| `partial` \| `block` | `off` | `partial` — одно сообщение-черновик правится, пока ответ стримится; `block` — блочные ответы ядра |
|
|
169
203
|
| `streaming` | настройка стриминга ядра | выключено | `streaming.mode` (`off`, `partial`, `block`, `progress`) сильнее `streamMode`; `streaming.progress.*` настраивает [статус хода](#статус-хода) |
|
|
170
204
|
| `textChunkLimit` | целое | `4000` | Символов в одном исходящем сообщении; значение аккаунта в приоритете; всё, что больше лимита MAX в 4000, урезается до него |
|
|
205
|
+
| `markdown.tables` | `bullets` \| `code` \| `block` \| `off` | по ширине | Как отправлять таблицы из ответов агента. Не задан — каждая по ширине: до 36 символов моноширинным блоком, шире пунктами. Заданный режим действует на все таблицы: пунктами, моноширинным блоком (`block` — то же, что `code`) или как есть; см. [Форматирование исходящих](#форматирование-исходящих) |
|
|
171
206
|
| `responsePrefix` | строка | — | Префикс ответов агента, его применяет ядро OpenClaw (`"auto"` — имя агента) |
|
|
172
207
|
| `historyLimit` | целое ≥ 0 | умолчание ядра | Читает ядро OpenClaw: сколько последних ходов групповой сессии встроенный рантайм агента держит в промпте (нативные CLI-рантаймы ведут историю сами) |
|
|
173
208
|
| `dmHistoryLimit`, `dms.<userId>.historyLimit` | целое ≥ 0 | без ограничения | Читает ядро OpenClaw: то же для личных диалогов с сессией на канал (`session.dmScope`); значение для пользователя в приоритете |
|
|
@@ -190,7 +225,7 @@ openclaw plugins install npm:@aspalagin/openclaw-max
|
|
|
190
225
|
| `systemPrompt` | — | Дополнительный системный промпт для ходов в этой группе |
|
|
191
226
|
| `skills` | все | Навыки, которые может загружать ход в этой группе; `[]` — никакие |
|
|
192
227
|
|
|
193
|
-
Ключи `
|
|
228
|
+
Ключи `blockStreaming` (используйте `streamMode` или `streaming.mode: "block"`) и `blockStreamingCoalesce` (ядро читает `streaming.block.coalesce`) схема принимает для совместимости с общим форматом конфигурации каналов, но **в MAX они ни на что не влияют**: ни плагин, ни ядро OpenClaw не читают их для этого канала. Они оставлены в схеме, чтобы существующие конфиги по-прежнему проходили проверку.
|
|
194
229
|
|
|
195
230
|
## Доступ и политики
|
|
196
231
|
|
package/README_EN.md
CHANGED
|
@@ -6,11 +6,12 @@ A channel plugin that connects an [OpenClaw](https://openclaw.ai) assistant to t
|
|
|
6
6
|
|
|
7
7
|
It is for OpenClaw users who need their assistant reachable in MAX. You need a MAX bot token (bots are created by organisations on [business.max.ru](https://business.max.ru/self)) and an OpenClaw gateway you run yourself.
|
|
8
8
|
|
|
9
|
-
Version 0.
|
|
9
|
+
Version 0.9.0. Changes: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md) (in Russian).
|
|
10
10
|
|
|
11
11
|
## Contents
|
|
12
12
|
|
|
13
13
|
- [Supported message types](#supported-message-types)
|
|
14
|
+
- [Outgoing formatting](#outgoing-formatting)
|
|
14
15
|
- [Requirements and compatibility](#requirements-and-compatibility)
|
|
15
16
|
- [Installation](#installation)
|
|
16
17
|
- [Quick start](#quick-start)
|
|
@@ -38,7 +39,7 @@ Version 0.8.2. Changes: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max
|
|
|
38
39
|
|
|
39
40
|
| Type | Incoming (MAX → agent) | Outgoing (agent → MAX) |
|
|
40
41
|
|---|---|---|
|
|
41
|
-
| Text | Yes; mentions detected from MAX markup, `@username` and core mention patterns | Yes; markdown converted to the MAX dialect (
|
|
42
|
+
| Text | Yes; mentions detected from MAX markup, `@username` and core mention patterns | Yes; markdown converted to the MAX dialect (headings, highlight, underline, rules, tables — see [Outgoing formatting](#outgoing-formatting)), split at 4000 characters; if MAX rejects the markup, resent once as plain text |
|
|
42
43
|
| Image | Downloaded and passed to the agent as media | Yes; several images and videos go as albums of up to 12; a public https image URL is passed to MAX as a link |
|
|
43
44
|
| Video | Downloaded (size limit `mediaMaxMb`) | Yes |
|
|
44
45
|
| Audio and voice | Downloaded; the MAX transcript is used when present, otherwise OpenClaw core transcribes | Yes; `asVoice` / `audioAsVoice` sends a MAX `audio` attachment |
|
|
@@ -58,6 +59,39 @@ Version 0.8.2. Changes: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max
|
|
|
58
59
|
|
|
59
60
|
Reactions and polls are not supported.
|
|
60
61
|
|
|
62
|
+
## Outgoing formatting
|
|
63
|
+
|
|
64
|
+
The agent writes ordinary Markdown and the plugin converts it to the MAX dialect. Code, ``` and ~~~ blocks and links stay as written. What MAX parses itself was checked by live sends on 2026-10-10 (markup read back with `GET /messages`):
|
|
65
|
+
|
|
66
|
+
| In the agent reply | In MAX |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `**bold**`, `*italic*`, `~~strike~~`, `` `code` ``, ``` blocks, links, mentions | as is |
|
|
69
|
+
| `# Heading` | heading |
|
|
70
|
+
| `## …` to `###### …` | `# …`: MAX has one heading level and shows the others as plain text |
|
|
71
|
+
| `^^text^^`, `==text==` | highlight (red); `==` becomes `^^` |
|
|
72
|
+
| `<u>text</u>`, `++text++` | underline |
|
|
73
|
+
| `> quote` | a quote block: MAX parses it itself (checked on a phone on 2026-10-10; the Bot API `markup` does not show it) |
|
|
74
|
+
| `---`, `***`, `___`, `* * *` | a `───` line: MAX draws no rules itself |
|
|
75
|
+
| table | a narrow one as a monospace block, a wide one as bullets; see below |
|
|
76
|
+
|
|
77
|
+
MAX has no tables, and a code block does not scroll on a phone: a long line wraps and the table falls apart. So by default (`markdown.tables` not set) the plugin decides for each table on its own:
|
|
78
|
+
|
|
79
|
+
- a narrow one — a monospace line of at most 36 columns (Cyrillic and Latin letters take one, emoji and CJK two) — goes as a monospace block with aligned columns. That much fits into a code block on an ordinary phone;
|
|
80
|
+
- a wide one goes as bullets: per table row, the first cell in bold and the others as `• Column: value`. Reads on any screen. A table with links goes as bullets too: a monospace block keeps only the link text.
|
|
81
|
+
|
|
82
|
+
The standard OpenClaw key `markdown.tables` (in `channels.max` or `accounts.<id>`) sets one mode for every table, whatever its width:
|
|
83
|
+
|
|
84
|
+
- `bullets` — always bullets, as for Signal and WhatsApp;
|
|
85
|
+
- `code` — always a monospace block, a wide table too (it wraps on a phone);
|
|
86
|
+
- `block` — same as `code`;
|
|
87
|
+
- `off` — as written, raw text with `|`.
|
|
88
|
+
|
|
89
|
+
```json5
|
|
90
|
+
{ channels: { max: { markdown: { tables: "code" } } } }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A reply is formatted before it is split into 4000-character parts: parts are measured as sent, and a table block that fits into one message is never split (a longer block is split with its ``` fence closed and reopened). The stream draft and its edits, core delivery (cron, `openclaw message send`) and the `send`, `edit` and `sendAttachment` actions are formatted like an ordinary reply. If MAX rejects the markup, the text is resent once without it, as before.
|
|
94
|
+
|
|
61
95
|
## Requirements and compatibility
|
|
62
96
|
|
|
63
97
|
- OpenClaw ≥ 2026.9.6 (`peerDependencies.openclaw`, `openclaw.compat.minGatewayVersion`). Older releases do not work, checked against 2026.9.3–2026.9.5 by typecheck and the test suite: 2026.9.5 lacks `createLivePreviewLifecycle` in `openclaw/plugin-sdk/channel-outbound`, so the plugin module fails to load (turn status is built on it); 2026.9.3 and 2026.9.4 also lack the sender check for answering agent questions with buttons (`authorize` in `resolveOption`), the guarded heartbeat typing hook and the plan-explanation format. Upgrade OpenClaw to use this plugin.
|
|
@@ -168,6 +202,7 @@ All options live under `channels.max`; the same keys (except `accounts` and `com
|
|
|
168
202
|
| `streamMode` | `off` \| `partial` \| `block` | `off` | `partial` — one draft message edited while the answer streams; `block` — core block replies |
|
|
169
203
|
| `streaming` | core streaming config | off | `streaming.mode` (`off`, `partial`, `block`, `progress`) wins over `streamMode`; `streaming.progress.*` configures [turn status](#turn-status) |
|
|
170
204
|
| `textChunkLimit` | integer | `4000` | Characters per outgoing message, account value first; values above MAX's 4000 are capped |
|
|
205
|
+
| `markdown.tables` | `bullets` \| `code` \| `block` \| `off` | by width | How tables in agent replies are sent. Not set: each by its width, up to 36 columns as a monospace block, wider as bullets. A set mode applies to every table: bullets, a monospace block (`block` is the same as `code`) or as written; see [Outgoing formatting](#outgoing-formatting) |
|
|
171
206
|
| `responsePrefix` | string | — | Prefix of agent replies, applied by OpenClaw core (`"auto"` — agent name) |
|
|
172
207
|
| `historyLimit` | integer ≥ 0 | core default | Read by OpenClaw core: recent turns of a group session the embedded agent runtime keeps in the prompt (native CLI runtimes keep their own history) |
|
|
173
208
|
| `dmHistoryLimit`, `dms.<userId>.historyLimit` | integer ≥ 0 | no limit | Read by OpenClaw core: the same for private dialogs with a per-channel session (`session.dmScope`), per user first |
|
|
@@ -190,7 +225,7 @@ Per-group settings (`groups.<chatId>` or `groups["*"]`):
|
|
|
190
225
|
| `systemPrompt` | — | Extra system prompt for turns in this group |
|
|
191
226
|
| `skills` | all | Skills a turn in this group may load; `[]` — none |
|
|
192
227
|
|
|
193
|
-
Accepted by the schema for compatibility with the common channel config shape but **without effect in MAX** — neither the plugin nor OpenClaw core reads them for this channel: `
|
|
228
|
+
Accepted by the schema for compatibility with the common channel config shape but **without effect in MAX** — neither the plugin nor OpenClaw core reads them for this channel: `blockStreaming` (use `streamMode` or `streaming.mode: "block"`), `blockStreamingCoalesce` (core reads `streaming.block.coalesce`). They stay in the schema so existing configs keep validating.
|
|
194
229
|
|
|
195
230
|
## Access and policies
|
|
196
231
|
|
package/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ description: Канал-плагин для подключения OpenClaw к
|
|
|
9
9
|
|
|
10
10
|
## Что делает
|
|
11
11
|
|
|
12
|
-
- **Текст** — отправка и получение; исходящий markdown переводится в диалект MAX (
|
|
12
|
+
- **Текст** — отправка и получение; исходящий markdown переводится в диалект MAX: `# заголовок` (уровни `##`…`######` тоже идут как `#`), `^^выделение^^` (и `==…==`), `++подчёркивание++`, `> цитата`, линии `───`; таблицы — по умолчанию узкие моноширинным блоком, широкие пунктами (`markdown.tables` задаёт один режим). Если MAX отверг разметку, сообщение уходит обычным текстом.
|
|
13
13
|
- **Медиа** — фото, видео, аудио, файлы, стикеры, контакты, геолокация; несколько вложений — альбомами до 12.
|
|
14
14
|
- **Голосовые** — входящие распознаёт ядро (`tools.media.audio`) или берётся транскрипт MAX; голосовой ответ уходит аудиовложением MAX.
|
|
15
15
|
- **Пересылки и цитаты** — агент видит пересланные сообщения и цитату сообщения, на которое ответили.
|
package/dist/src/accounts.js
CHANGED
|
@@ -187,6 +187,7 @@ export function readMaxAccount(params) {
|
|
|
187
187
|
botToken: section.botToken,
|
|
188
188
|
tokenFile: section.tokenFile,
|
|
189
189
|
name: section.name,
|
|
190
|
+
markdown: section.markdown,
|
|
190
191
|
dmPolicy: section.dmPolicy,
|
|
191
192
|
allowFrom: section.allowFrom,
|
|
192
193
|
groups: section.groups,
|
package/dist/src/actions.js
CHANGED
|
@@ -8,6 +8,7 @@ import { readStringParam } from 'openclaw/plugin-sdk/param-readers';
|
|
|
8
8
|
import { jsonResult } from 'openclaw/plugin-sdk/tool-results';
|
|
9
9
|
import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
|
|
10
10
|
import { assertMaxActionInScope } from './action-scope.js';
|
|
11
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
11
12
|
import { sanitizeMaxFileName } from './media-temp.js';
|
|
12
13
|
import { materializeMaxPresentation, readMaxDeliveryPin, resolveMaxTextChunkLimit, } from './presentation.js';
|
|
13
14
|
import { getMaxRuntime } from './runtime.js';
|
|
@@ -273,6 +274,8 @@ export const maxMessageActions = {
|
|
|
273
274
|
const sendFlags = resolveMaxSendFlags(account.config, {
|
|
274
275
|
silent: typeof params.silent === 'boolean' ? params.silent : undefined,
|
|
275
276
|
});
|
|
277
|
+
// MAX dialect and tables of the account (markdown.tables), before chunking.
|
|
278
|
+
const formatText = (text) => toMaxMarkdown(text, { tableMode: resolveMaxTableMode(account.config) });
|
|
276
279
|
// Strip provider prefix from target (e.g. "max:123456789" → "123456789")
|
|
277
280
|
const stripPrefix = (val) => {
|
|
278
281
|
if (!val)
|
|
@@ -287,10 +290,10 @@ export const maxMessageActions = {
|
|
|
287
290
|
});
|
|
288
291
|
const presentation = normalizeMessagePresentation(params.presentation);
|
|
289
292
|
const pin = readMaxDeliveryPin(params.delivery, params.pin);
|
|
290
|
-
const content = readStringParam(params, 'message', {
|
|
293
|
+
const content = formatText(readStringParam(params, 'message', {
|
|
291
294
|
required: !presentation,
|
|
292
295
|
allowEmpty: true,
|
|
293
|
-
}) ?? '';
|
|
296
|
+
}) ?? '');
|
|
294
297
|
// Pin the sent message when delivery.pin (or pin=true) was requested.
|
|
295
298
|
// Optional pin failures degrade; a required one fails the action.
|
|
296
299
|
// Dialogs cannot pin: that is reported as pinned:false with pinSkipped.
|
|
@@ -333,7 +336,7 @@ export const maxMessageActions = {
|
|
|
333
336
|
if (presentation) {
|
|
334
337
|
const rendered = await materializeMaxPresentation({ text: content, presentation });
|
|
335
338
|
const renderedButtons = readMaxChannelButtons(rendered.channelData);
|
|
336
|
-
const text = rendered.text ?? '';
|
|
339
|
+
const text = formatText(rendered.text ?? '');
|
|
337
340
|
const chunkLimit = resolveMaxTextChunkLimit(cfg, account.accountId);
|
|
338
341
|
const chunks = text.length > chunkLimit
|
|
339
342
|
? getMaxRuntime().channel.text.chunkMarkdownText(text, chunkLimit)
|
|
@@ -431,7 +434,7 @@ export const maxMessageActions = {
|
|
|
431
434
|
required: true,
|
|
432
435
|
allowEmpty: true,
|
|
433
436
|
});
|
|
434
|
-
await editMaxMessage(messageId, text, {
|
|
437
|
+
await editMaxMessage(messageId, formatText(text), {
|
|
435
438
|
token: account.token,
|
|
436
439
|
format: 'markdown',
|
|
437
440
|
});
|
|
@@ -495,7 +498,7 @@ export const maxMessageActions = {
|
|
|
495
498
|
const to = stripPrefix(readTargetParam(params));
|
|
496
499
|
const replyTo = readStringParam(params, 'replyTo');
|
|
497
500
|
await assertMaxActionInScope(ctx, account, { to, replyTo });
|
|
498
|
-
const caption = readStringParam(params, 'message') ?? readStringParam(params, 'caption') ?? '';
|
|
501
|
+
const caption = formatText(readStringParam(params, 'message') ?? readStringParam(params, 'caption') ?? '');
|
|
499
502
|
const attachType = readStringParam(params, 'type') ?? readStringParam(params, 'attachmentType') ?? '';
|
|
500
503
|
const mediaMaxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
|
501
504
|
const mediaSources = readMediaSources(params, mediaMaxBytes);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent prompt hints of the MAX channel:
|
|
3
|
-
* button types, presentation, voice, inline files, silent sends,
|
|
4
|
-
* roots and action scope for the message tool.
|
|
2
|
+
* Agent prompt hints of the MAX channel: text formatting, stickers, location,
|
|
3
|
+
* contact, pin, button types, presentation, voice, inline files, silent sends,
|
|
4
|
+
* local file roots and action scope for the message tool.
|
|
5
5
|
*/
|
|
6
6
|
/** message tool hints for MAX-specific actions. */
|
|
7
7
|
export const maxAgentPromptAdapter = {
|
|
@@ -10,6 +10,7 @@ export const maxAgentPromptAdapter = {
|
|
|
10
10
|
// Codes are hex IDs derived from listmax.ru external_id: parseInt(extId).toString(16)
|
|
11
11
|
const emojiMap = '😂:109550b5 😊:109971b5 😍:10931eb5 🥰:10931eb5 😢:109330b5 😭:109330b5 😡:10941fb5 😱:109302b5 🤔:109308b5 👍:109368b5 👎:109323b5 ❤️:10931eb5 🔥:b4867ebb 💪:c1254bbb 🎉:10933cb5 😘:10931eb5 🤗:109b94b5 😎:10931db5 🙄:c14211bb 😴:10936eb5 😤:10941fb5 🤮:6b8bb 🤯:109302b5 😳:109302b5 🥳:10933cb5 💀:b4863ebb 🙈:b4850cbb 😏:11e4c60bb 😅:109550b5 🤣:109550b5 😋:109d2db5 😜:455b5 🤷:10d5cf5bb 😫:10936eb5 😩:10997db5 🥺:109356b5 😌:14aae3bb 😒:109323b5 🤪:455b5 😇:11e4dedbb 🙏:50cb5 💔:10997db5 👀:b48534bb ✨:11e43b2bb 😈:10941fb5 🤝:109368b5 🤦:502b5 😬:5dab4b5 🤩:5dabfb5 😶:2ae2b5';
|
|
12
12
|
return [
|
|
13
|
+
'- MAX formatting (replies and `send`): Markdown with **bold**, *italic*, ~~strike~~, ++underline++, ^^highlight^^ (red), `code`, ``` blocks, [links](https://example.com), `# Heading` (one level: `##`…`######` are sent as `#`) and `> quote`. MAX has no tables: by default a narrow Markdown table (up to 36 monospace columns) is sent as a monospace block and a wider one as bullets (bold first cell, a bullet per other cell); channels.max.markdown.tables fixes one mode. Keep tables to a few short columns, use lists for wide data.',
|
|
13
14
|
'- MAX stickers: use `message(action="sticker", target="CHAT_ID", stickerId="CODE")`. Pick a sticker code matching the mood from the emoji map below. Each entry is emoji:hexCode.',
|
|
14
15
|
`- Sticker emoji map: ${emojiMap}`,
|
|
15
16
|
'- MAX location: use `message(action="sendAttachment", target="CHAT_ID", type="location", latitude="55.75", longitude="37.62")` to send a native map pin.',
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { resolveMaxAccount } from './accounts.js';
|
|
6
6
|
import { MaxApi } from './api.js';
|
|
7
|
+
import { MAX_DECLARED_TABLE_MODE } from './format.js';
|
|
7
8
|
import { loadMaxAccountState } from './state.js';
|
|
8
9
|
/** `user:<id>` addresses a user (DM); a bare number is a chat id. */
|
|
9
10
|
const MAX_TARGET_PATTERN = /^(?:user:\d+|-?\d+)$/;
|
|
@@ -25,6 +26,10 @@ export const maxMessagingAdapter = {
|
|
|
25
26
|
looksLikeId: (raw) => MAX_TARGET_PATTERN.test(stripMaxPrefix(raw)),
|
|
26
27
|
hint: '<chatId|user:userId>',
|
|
27
28
|
},
|
|
29
|
+
// What core sees for MAX without markdown.tables: keeps the group prompt's
|
|
30
|
+
// "Avoid Markdown tables" and is the safe mode for core's resolver. The
|
|
31
|
+
// plugin itself picks code or bullets per table by width (format.ts).
|
|
32
|
+
defaultMarkdownTableMode: MAX_DECLARED_TABLE_MODE,
|
|
28
33
|
};
|
|
29
34
|
/** MAX has no member/chat listing for bots: peers come from allowFrom, groups from the chat registry. */
|
|
30
35
|
export const maxDirectoryAdapter = {
|
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { getAgentScopedMediaLocalRoots } from 'openclaw/plugin-sdk/media-local-roots';
|
|
6
6
|
import { resolveMaxAccount } from './accounts.js';
|
|
7
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
7
8
|
import { materializeMaxPresentation, MAX_PRESENTATION_CAPABILITIES, MAX_TEXT_LIMIT, renderMaxPresentation, resolveMaxTextChunkLimit, } from './presentation.js';
|
|
8
9
|
import { getMaxRuntime, loadMaxConfig } from './runtime.js';
|
|
9
10
|
import { pinMaxMessage, readMaxChannelButtons, resolveMaxSendFlags, sendMaxMediaGroup, sendMaxMediaMessage, sendMaxMessage, } from './send.js';
|
|
11
|
+
/** MAX dialect and tables of the account (markdown.tables); see format.ts. */
|
|
12
|
+
function formatMaxText(text, account) {
|
|
13
|
+
return toMaxMarkdown(text, { tableMode: resolveMaxTableMode(account.config) });
|
|
14
|
+
}
|
|
10
15
|
/**
|
|
11
16
|
* Local media access for an outbound send: the roots and host reader core
|
|
12
17
|
* passed with the delivery; without them the default media roots (no agent
|
|
@@ -29,6 +34,11 @@ export const maxOutboundAdapter = {
|
|
|
29
34
|
chunker: (text, limit) => getMaxRuntime().channel.text.chunkMarkdownText(text, Math.min(limit, MAX_TEXT_LIMIT)),
|
|
30
35
|
chunkerMode: 'markdown',
|
|
31
36
|
textChunkLimit: 4000,
|
|
37
|
+
// Before core chunks the text: tables become bullets or one code block, so
|
|
38
|
+
// the chunker measures the text as sent and never splits a raw table.
|
|
39
|
+
normalizePayload: ({ payload, cfg, accountId }) => payload.text
|
|
40
|
+
? { ...payload, text: formatMaxText(payload.text, resolveMaxAccount({ cfg, accountId })) }
|
|
41
|
+
: payload,
|
|
32
42
|
// Presentation (docs/plugins/message-presentation.md): core adapts to these
|
|
33
43
|
// capabilities, renders through renderPresentation and pins through
|
|
34
44
|
// pinDeliveredMessage after the first delivered message.
|
|
@@ -59,7 +69,7 @@ export const maxOutboundAdapter = {
|
|
|
59
69
|
const payload = rawPayload.presentation
|
|
60
70
|
? await materializeMaxPresentation(rawPayload)
|
|
61
71
|
: rawPayload;
|
|
62
|
-
const effectiveText = payload === rawPayload ? text : (payload.text ?? '');
|
|
72
|
+
const effectiveText = formatMaxText(payload === rawPayload ? text : (payload.text ?? ''), account);
|
|
63
73
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
64
74
|
const sendFlags = resolveMaxSendFlags(account.config, {
|
|
65
75
|
silent: ctx.silent,
|
|
@@ -118,7 +128,7 @@ export const maxOutboundAdapter = {
|
|
|
118
128
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
119
129
|
if (!account.token)
|
|
120
130
|
throw new Error('MAX bot token not configured');
|
|
121
|
-
const result = await sendMaxMessage(to, text, {
|
|
131
|
+
const result = await sendMaxMessage(to, formatMaxText(text, account), {
|
|
122
132
|
token: account.token,
|
|
123
133
|
replyToMessageId: replyToId ?? undefined,
|
|
124
134
|
format: 'markdown',
|
|
@@ -130,12 +140,13 @@ export const maxOutboundAdapter = {
|
|
|
130
140
|
};
|
|
131
141
|
},
|
|
132
142
|
sendMedia: async (ctx) => {
|
|
133
|
-
const { to,
|
|
143
|
+
const { to, mediaUrl, accountId, replyToId } = ctx;
|
|
134
144
|
const cfg = await loadMaxConfig();
|
|
135
145
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
136
146
|
if (!account.token)
|
|
137
147
|
throw new Error('MAX bot token not configured');
|
|
138
148
|
const sendFlags = resolveMaxSendFlags(account.config, { silent: ctx.silent });
|
|
149
|
+
const text = formatMaxText(ctx.text, account);
|
|
139
150
|
if (!mediaUrl) {
|
|
140
151
|
// No media, send as text
|
|
141
152
|
const result = await sendMaxMessage(to, text, {
|
|
@@ -68,7 +68,11 @@ export const MaxAccountSchemaBase = z
|
|
|
68
68
|
.object({
|
|
69
69
|
name: z.string().optional(),
|
|
70
70
|
enabled: z.boolean().optional(),
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* markdown.tables: how agent tables reach MAX, which has no native tables —
|
|
73
|
+
* not set: by width (narrow as a monospace block, wide as bullets); bullets,
|
|
74
|
+
* code (monospace block), block (same as code), off (raw) for every table.
|
|
75
|
+
*/
|
|
72
76
|
markdown: MarkdownConfigSchema.optional(),
|
|
73
77
|
/** Plain token or a core SecretRef { source, provider, id } (also webhookSecret, httpProxy) */
|
|
74
78
|
botToken: buildOptionalSecretInputSchema(),
|
package/dist/src/deliver.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createChannelPartialDeliveryError } from 'openclaw/plugin-sdk/channel-i
|
|
|
8
8
|
import { PlatformMessageNotDispatchedError } from 'openclaw/plugin-sdk/error-runtime';
|
|
9
9
|
import { LocalMediaAccessError } from 'openclaw/plugin-sdk/web-media';
|
|
10
10
|
import { MaxApiError } from './api.js';
|
|
11
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
11
12
|
import { readMaxDeliveryPin, resolveMaxTextChunkLimit } from './presentation.js';
|
|
12
13
|
import { getMaxRuntime } from './runtime.js';
|
|
13
14
|
import { answerMaxCallback, pinMaxMessage, readMaxChannelButtons, resolveMaxSendFlags, sendMaxMediaGroup, sendMaxMessage, } from './send.js';
|
|
@@ -49,6 +50,11 @@ function logSendFailure(account, log, what, err) {
|
|
|
49
50
|
export async function deliverMaxReply(params) {
|
|
50
51
|
const { payload, account, chatId, config, log, statusSink } = params;
|
|
51
52
|
const core = getMaxRuntime();
|
|
53
|
+
// MAX dialect and tables (markdown.tables) before chunking: a table becomes
|
|
54
|
+
// bullets or one code block, and the chunks are measured as sent.
|
|
55
|
+
const replyText = payload.text
|
|
56
|
+
? toMaxMarkdown(payload.text, { tableMode: resolveMaxTableMode(account.config) })
|
|
57
|
+
: payload.text;
|
|
52
58
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
53
59
|
// channelData.max (notify, silent, disableLinkPreview) beats the account defaults.
|
|
54
60
|
const sendOptions = resolveMaxSendFlags(account.config, { channelData: payload.channelData });
|
|
@@ -79,14 +85,14 @@ export async function deliverMaxReply(params) {
|
|
|
79
85
|
if (params.callbackState)
|
|
80
86
|
params.callbackState.answered = true;
|
|
81
87
|
try {
|
|
82
|
-
await answerMaxCallback(params.callbackId,
|
|
88
|
+
await answerMaxCallback(params.callbackId, replyText ?? '', {
|
|
83
89
|
token: account.token,
|
|
84
90
|
format: 'markdown',
|
|
85
91
|
buttons,
|
|
86
92
|
});
|
|
87
93
|
visible.callbackAnswered = true;
|
|
88
|
-
if (
|
|
89
|
-
visible.texts.push(
|
|
94
|
+
if (replyText)
|
|
95
|
+
visible.texts.push(replyText);
|
|
90
96
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
91
97
|
}
|
|
92
98
|
catch (err) {
|
|
@@ -122,10 +128,10 @@ export async function deliverMaxReply(params) {
|
|
|
122
128
|
if (answersCallback) {
|
|
123
129
|
// The text went with the callback answer.
|
|
124
130
|
}
|
|
125
|
-
else if (
|
|
131
|
+
else if (replyText) {
|
|
126
132
|
const chunkLimit = resolveMaxTextChunkLimit(config, account.accountId);
|
|
127
133
|
const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
|
|
128
|
-
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
|
134
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(replyText, chunkLimit, chunkMode);
|
|
129
135
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
130
136
|
const chunk = chunks[index];
|
|
131
137
|
const chunkButtons = index === chunks.length - 1 ? buttons : undefined;
|
package/dist/src/format.js
CHANGED
|
@@ -1,30 +1,187 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Конвертер форматирования OpenClaw/стандартный markdown → MAX markdown.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - bold, italic, strikethrough, code, ссылки — совпадают со стандартом.
|
|
4
|
+
* Что MAX разбирает сам (живая проба 10.10.2026, body.markup из GET /messages):
|
|
5
|
+
* `**`/`__` жирный, `*`/`_` курсив, `~~` зачёркнутый, `++` подчёркнутый,
|
|
6
|
+
* `^^` выделение, `код` и ```блоки```, ссылки, упоминания
|
|
7
|
+
* [Имя](max://user/user_id), `# заголовок` (один уровень; setext `===` тоже),
|
|
8
|
+
* `> цитата` и экранирование `\`. Это НЕ трогаем.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Что конвертируем (MAX показывает сырым или теряет):
|
|
11
|
+
* - pipe-таблицы — штатным convertMarkdownTables ядра (меняет только диапазоны
|
|
12
|
+
* таблиц). Без `markdown.tables` (умолчание) каждая таблица по своей ширине:
|
|
13
|
+
* узкая — моноширинным блоком, широкая — пунктами. Явный режим — как в ядре:
|
|
14
|
+
* bullets — пункт на ячейку, code — моноширинный блок, block → code
|
|
15
|
+
* (нативных таблиц в MAX нет), off — как есть;
|
|
16
|
+
* - `##`…`######` → `#`: уровни 2–6 MAX снимает без разметки;
|
|
17
|
+
* - горизонтальные линии `---`, `***`, `___` → `───`: MAX оставляет их сырыми,
|
|
18
|
+
* `* * *` превращает во вложенные списки, а `---` под строкой текста
|
|
19
|
+
* съедает вместе с её форматом (setext-заголовок второго уровня);
|
|
20
|
+
* - `==текст==` → `^^текст^^`;
|
|
21
|
+
* - `<u>…</u>` → `++…++`: подчёркивание тегом MAX в markdown не понимает.
|
|
22
|
+
*
|
|
23
|
+
* Заголовки и линии меняются только вне fenced-блоков, инлайн-замены — вне
|
|
24
|
+
* inline-кода, fenced-блоков и URL. Повторный вызов ничего не меняет, поэтому
|
|
25
|
+
* текст можно подготовить до разбиения на части, а send.ts пройдёт по каждой
|
|
26
|
+
* части ещё раз.
|
|
27
|
+
*/
|
|
28
|
+
import { convertMarkdownTables, markdownToIRWithMeta } from 'openclaw/plugin-sdk/text-chunking';
|
|
29
|
+
/**
|
|
30
|
+
* Умолчание, которое видит ядро (messaging.defaultMarkdownTableMode). Режима
|
|
31
|
+
* «по ширине» в MarkdownTableMode ядра нет, а ядро по этому значению решает
|
|
32
|
+
* две вещи: добавлять ли в групповой промпт «Avoid Markdown tables» (да для
|
|
33
|
+
* всего, кроме block/off) и что вернёт resolveMarkdownTableMode для MAX без
|
|
34
|
+
* конфига. bullets: подсказка остаётся — нативных таблиц в MAX нет, широкая
|
|
35
|
+
* таблица в группе станет длинным списком; а если кто-то сконвертирует текст
|
|
36
|
+
* для MAX по этому ответу, пункты не развалятся на телефоне. block солгал бы
|
|
37
|
+
* ядру о нативных таблицах, off — о сырых.
|
|
38
|
+
*/
|
|
39
|
+
export const MAX_DECLARED_TABLE_MODE = 'bullets';
|
|
40
|
+
/**
|
|
41
|
+
* Самая широкая таблица, которая без markdown.tables уходит моноширинным
|
|
42
|
+
* блоком: ширина строки блока в колонках. Блок кода в MAX не прокручивается,
|
|
43
|
+
* длинная строка переносится и таблица разваливается; на обычном телефоне
|
|
44
|
+
* в блок влезает 34–38 моноширинных символов (демо 10.10.2026). Шире — пункты.
|
|
45
|
+
*/
|
|
46
|
+
export const MAX_NARROW_TABLE_WIDTH = 36;
|
|
47
|
+
/**
|
|
48
|
+
* Режим таблиц аккаунта: accounts.<id> наследует channels.max.markdown
|
|
49
|
+
* (accounts.ts). Не задан — auto; block → code: нативных таблиц в MAX нет.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveMaxTableMode(config) {
|
|
52
|
+
const mode = config?.markdown?.tables;
|
|
53
|
+
if (!mode)
|
|
54
|
+
return 'auto';
|
|
55
|
+
return mode === 'block' ? 'code' : mode;
|
|
56
|
+
}
|
|
57
|
+
const graphemes = new Intl.Segmenter();
|
|
58
|
+
const WIDE_GRAPHEME = /\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F|[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]/u;
|
|
59
|
+
const ZERO_WIDTH_GRAPHEME = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
|
|
60
|
+
/** Ширина в моноширинных колонках: эмодзи и CJK — 2, метки и невидимые — 0, прочее — 1. */
|
|
61
|
+
export function displayWidth(text) {
|
|
62
|
+
let width = 0;
|
|
63
|
+
for (const { segment } of graphemes.segment(text)) {
|
|
64
|
+
if (WIDE_GRAPHEME.test(segment))
|
|
65
|
+
width += 2;
|
|
66
|
+
else if (!ZERO_WIDTH_GRAPHEME.test(segment))
|
|
67
|
+
width += 1;
|
|
68
|
+
}
|
|
69
|
+
return width;
|
|
70
|
+
}
|
|
71
|
+
/** Ширина строки моноширинного блока таблицы, как его строит ядро: `| ячейка | … |`, колонка не уже 3. */
|
|
72
|
+
export function codeTableWidth(headers, rows) {
|
|
73
|
+
const widths = [];
|
|
74
|
+
for (const row of [headers, ...rows]) {
|
|
75
|
+
row.forEach((cell, column) => {
|
|
76
|
+
widths[column] = Math.max(widths[column] ?? 3, displayWidth(cell));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return widths.reduce((sum, width) => sum + width + 3, 1);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Режимы таблиц текста в auto: узкая — code, широкая — bullets. Таблица со
|
|
83
|
+
* ссылками — тоже bullets: в моноширинном блоке ядро оставляет от ссылки только
|
|
84
|
+
* текст, адрес теряется.
|
|
13
85
|
*/
|
|
86
|
+
function autoTableModes(markdown) {
|
|
87
|
+
if (!markdown.includes('|'))
|
|
88
|
+
return new Set();
|
|
89
|
+
const { tables } = markdownToIRWithMeta(markdown, {
|
|
90
|
+
linkify: false,
|
|
91
|
+
autolink: false,
|
|
92
|
+
tableMode: 'block',
|
|
93
|
+
});
|
|
94
|
+
return new Set(tables.map((table) => codeTableWidth(table.headers, table.rows) <= MAX_NARROW_TABLE_WIDTH &&
|
|
95
|
+
![table.headerCells, ...table.rowCells].some((row) => row.some((cell) => cell.links.length))
|
|
96
|
+
? 'code'
|
|
97
|
+
: 'bullets'));
|
|
98
|
+
}
|
|
14
99
|
const CODE_OR_URL = /(```[\s\S]*?```|`[^`]*`|https?:\/\/\S+)/g;
|
|
15
|
-
/**
|
|
100
|
+
/** Открывающая/закрывающая ограда блока кода, в том числе в списке или цитате. */
|
|
101
|
+
const FENCE = /^\s*(?:>\s*)*(`{3,}|~{3,})/;
|
|
102
|
+
const SUBHEADING = /^ {0,3}#{2,6}(?=[ \t])/;
|
|
103
|
+
const THEMATIC_BREAK = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
104
|
+
const HORIZONTAL_RULE = '───';
|
|
105
|
+
const HIGHLIGHT = /(?<![\p{L}\p{N}_=])==(?=[^\s=])([^=\n]*?[^\s=])==(?![\p{L}\p{N}_=])/gu;
|
|
106
|
+
/** Открытая ограда ```/~~~ блока после строки `line`; undefined — вне блока. */
|
|
107
|
+
function fenceAfter(line, fence) {
|
|
108
|
+
const match = FENCE.exec(line);
|
|
109
|
+
if (!fence)
|
|
110
|
+
return match?.[1];
|
|
111
|
+
const closes = match &&
|
|
112
|
+
match[1][0] === fence[0] &&
|
|
113
|
+
match[1].length >= fence.length &&
|
|
114
|
+
!line.slice(match[0].length).trim();
|
|
115
|
+
return closes ? undefined : fence;
|
|
116
|
+
}
|
|
117
|
+
/** Заголовки уровней 2–6 и горизонтальные линии; строки внутри ```/~~~ блоков не трогаются. */
|
|
118
|
+
function convertBlocks(text) {
|
|
119
|
+
let fence;
|
|
120
|
+
return text
|
|
121
|
+
.split('\n')
|
|
122
|
+
.map((line) => {
|
|
123
|
+
const inFence = fence !== undefined;
|
|
124
|
+
fence = fenceAfter(line, fence);
|
|
125
|
+
if (inFence || fence)
|
|
126
|
+
return line;
|
|
127
|
+
if (THEMATIC_BREAK.test(line))
|
|
128
|
+
return HORIZONTAL_RULE;
|
|
129
|
+
return line.replace(SUBHEADING, '#');
|
|
130
|
+
})
|
|
131
|
+
.join('\n');
|
|
132
|
+
}
|
|
133
|
+
/** Куски текста до пустой строки вне ```/~~~ блоков включительно; join('') возвращает текст. */
|
|
134
|
+
function splitAtBlankLines(text) {
|
|
135
|
+
const blocks = [];
|
|
136
|
+
let block = '';
|
|
137
|
+
let fence;
|
|
138
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
139
|
+
block += line;
|
|
140
|
+
fence = fenceAfter(line, fence);
|
|
141
|
+
if (!fence && !line.trim()) {
|
|
142
|
+
blocks.push(block);
|
|
143
|
+
block = '';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return block ? [...blocks, block] : blocks;
|
|
147
|
+
}
|
|
148
|
+
/** auto: каждая таблица по ширине — узкая моноширинным блоком, широкая пунктами. */
|
|
149
|
+
function convertTablesAuto(text) {
|
|
150
|
+
const modes = autoTableModes(text);
|
|
151
|
+
if (modes.size < 2) {
|
|
152
|
+
const [mode] = modes;
|
|
153
|
+
return mode ? convertMarkdownTables(text, mode) : text;
|
|
154
|
+
}
|
|
155
|
+
// convertMarkdownTables задаёт один режим на весь текст, поэтому разные
|
|
156
|
+
// таблицы конвертируются по кускам между пустыми строками: пустая строка
|
|
157
|
+
// таблицу завершает. Таблицу, которую кусок без контекста не распознал
|
|
158
|
+
// (глубоко вложенный список), добирает последний проход пунктами — готовые
|
|
159
|
+
// блоки и пункты он не трогает.
|
|
160
|
+
const converted = splitAtBlankLines(text)
|
|
161
|
+
.map((block) => convertMarkdownTables(block, autoTableModes(block).has('bullets') ? 'bullets' : 'code'))
|
|
162
|
+
.join('');
|
|
163
|
+
return convertMarkdownTables(converted, 'bullets');
|
|
164
|
+
}
|
|
165
|
+
/** Инлайн-замены сегмента вне кода и URL: <u>…</u> → ++…++, ==…== → ^^…^^. */
|
|
16
166
|
function convertSegment(text) {
|
|
17
|
-
return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++');
|
|
167
|
+
return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++').replace(HIGHLIGHT, '^^$1^^');
|
|
18
168
|
}
|
|
19
169
|
/**
|
|
20
170
|
* Конвертирует OpenClaw/стандартный markdown в MAX markdown, не затрагивая
|
|
21
|
-
* содержимое inline-кода, fenced-блоков и URL.
|
|
171
|
+
* содержимое inline-кода, fenced-блоков и URL. Таблицы конвертируются, только
|
|
172
|
+
* если передан `tableMode` (режим аккаунта, resolveMaxTableMode).
|
|
22
173
|
*/
|
|
23
|
-
export function toMaxMarkdown(text) {
|
|
174
|
+
export function toMaxMarkdown(text, options = {}) {
|
|
24
175
|
if (!text)
|
|
25
176
|
return text;
|
|
177
|
+
const { tableMode } = options;
|
|
178
|
+
const withTables = tableMode === 'auto'
|
|
179
|
+
? convertTablesAuto(text)
|
|
180
|
+
: tableMode && tableMode !== 'off'
|
|
181
|
+
? convertMarkdownTables(text, tableMode === 'block' ? 'code' : tableMode)
|
|
182
|
+
: text;
|
|
26
183
|
// split с захватывающей группой: нечётные индексы — код/URL (не трогаем)
|
|
27
|
-
return
|
|
184
|
+
return convertBlocks(withTables)
|
|
28
185
|
.split(CODE_OR_URL)
|
|
29
186
|
.map((segment, index) => (index % 2 === 0 ? convertSegment(segment) : segment))
|
|
30
187
|
.join('');
|
package/dist/src/stream-draft.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* message: a longer final answer is split by the reply delivery, which puts
|
|
6
6
|
* the first chunk into the draft and sends the rest as new messages.
|
|
7
7
|
*/
|
|
8
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
8
9
|
import { deleteMaxMessage, editMaxMessage, resolveMaxSendFlags, sendMaxMessage, } from './send.js';
|
|
9
10
|
const DRAFT_THROTTLE_MS = 1200;
|
|
10
11
|
const DRAFT_MAX_CHARS = 4000;
|
|
@@ -32,7 +33,11 @@ export function createMaxDraftStream(params) {
|
|
|
32
33
|
const draftUpdate = async (text) => {
|
|
33
34
|
if (draftStopped || !text)
|
|
34
35
|
return;
|
|
35
|
-
|
|
36
|
+
// Formatted like the final answer (deliver.ts), so the final edit compares
|
|
37
|
+
// and replaces like with like.
|
|
38
|
+
const trimmed = toMaxMarkdown(text, {
|
|
39
|
+
tableMode: resolveMaxTableMode(account.config),
|
|
40
|
+
}).trimEnd();
|
|
36
41
|
if (!trimmed || trimmed === draftLastText)
|
|
37
42
|
return;
|
|
38
43
|
if (trimmed.length > DRAFT_MAX_CHARS) {
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-max",
|
|
3
3
|
"name": "MAX",
|
|
4
4
|
"description": "MAX messenger (max.ru) channel for OpenClaw: private and group chats over webhook or long polling; media, voice messages with transcription and voice replies; inline keyboards and command menus; message edits and pins; several bot accounts",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.9.0",
|
|
6
6
|
"categories": [
|
|
7
7
|
"channels"
|
|
8
8
|
],
|
|
@@ -69,7 +69,19 @@
|
|
|
69
69
|
"type": "boolean"
|
|
70
70
|
},
|
|
71
71
|
"markdown": {
|
|
72
|
-
"type": "object"
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"tables": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"enum": [
|
|
77
|
+
"off",
|
|
78
|
+
"bullets",
|
|
79
|
+
"code",
|
|
80
|
+
"block"
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
"additionalProperties": false
|
|
73
85
|
},
|
|
74
86
|
"botToken": {
|
|
75
87
|
"anyOf": [
|
|
@@ -798,8 +810,8 @@
|
|
|
798
810
|
"help": "false at channel level disables all accounts; in an account, disables that account."
|
|
799
811
|
},
|
|
800
812
|
"markdown": {
|
|
801
|
-
"label": "Markdown
|
|
802
|
-
"help": "
|
|
813
|
+
"label": "Markdown tables",
|
|
814
|
+
"help": "markdown.tables: how tables in agent replies are sent (MAX has no native tables). Not set: by width, a table up to 36 monospace columns as a monospace block, a wider one as bullets. bullets: one bullet per cell under the bold row label; code: monospace block; block: same as code; off: raw Markdown.",
|
|
803
815
|
"advanced": true
|
|
804
816
|
},
|
|
805
817
|
"botToken": {
|
|
@@ -1022,8 +1034,8 @@
|
|
|
1022
1034
|
"help": "false disables this account; a channel switched off switches off every account."
|
|
1023
1035
|
},
|
|
1024
1036
|
"accounts.*.markdown": {
|
|
1025
|
-
"label": "Markdown
|
|
1026
|
-
"help": "
|
|
1037
|
+
"label": "Markdown tables",
|
|
1038
|
+
"help": "markdown.tables for this bot (off, bullets, code, block); inherits the channel value.",
|
|
1027
1039
|
"advanced": true
|
|
1028
1040
|
},
|
|
1029
1041
|
"accounts.*.botToken": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspalagin/openclaw-max",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "MAX messenger (max.ru) channel for OpenClaw: private and group chats over webhook or long polling; media, voice messages with transcription and voice replies; inline keyboards and command menus; message edits and pins; several bot accounts",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
package/src/accounts.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from 'openclaw/plugin-sdk/core
|
|
|
10
10
|
import { resolveSecretInputString } from 'openclaw/plugin-sdk/secret-input';
|
|
11
11
|
import { canResolveEnvSecretRefInReadOnlyPath } from 'openclaw/plugin-sdk/secret-ref-readonly';
|
|
12
12
|
|
|
13
|
+
import type { MaxMarkdownTableMode } from './format.js';
|
|
13
14
|
import {
|
|
14
15
|
bindMaxNetwork,
|
|
15
16
|
type MaxNetwork,
|
|
@@ -23,6 +24,8 @@ export interface MaxAccountConfig {
|
|
|
23
24
|
botToken?: string;
|
|
24
25
|
tokenFile?: string;
|
|
25
26
|
name?: string;
|
|
27
|
+
/** markdown.tables: tables of agent replies (format.ts; default bullets) */
|
|
28
|
+
markdown?: { tables?: MaxMarkdownTableMode };
|
|
26
29
|
dmPolicy?: string;
|
|
27
30
|
allowFrom?: Array<string | number>;
|
|
28
31
|
groups?: Record<
|
|
@@ -313,6 +316,7 @@ export function readMaxAccount(params: {
|
|
|
313
316
|
botToken: section.botToken as string | undefined,
|
|
314
317
|
tokenFile: section.tokenFile as string | undefined,
|
|
315
318
|
name: section.name as string | undefined,
|
|
319
|
+
markdown: section.markdown as MaxAccountConfig['markdown'],
|
|
316
320
|
dmPolicy: section.dmPolicy as string | undefined,
|
|
317
321
|
allowFrom: section.allowFrom as Array<string | number> | undefined,
|
|
318
322
|
groups: section.groups as MaxAccountConfig['groups'],
|
package/src/actions.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { jsonResult } from 'openclaw/plugin-sdk/tool-results';
|
|
|
15
15
|
|
|
16
16
|
import { listMaxAccountIds, resolveMaxAccount } from './accounts.js';
|
|
17
17
|
import { assertMaxActionInScope } from './action-scope.js';
|
|
18
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
18
19
|
import { sanitizeMaxFileName } from './media-temp.js';
|
|
19
20
|
import {
|
|
20
21
|
materializeMaxPresentation,
|
|
@@ -345,6 +346,9 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
|
|
|
345
346
|
const sendFlags = resolveMaxSendFlags(account.config, {
|
|
346
347
|
silent: typeof params.silent === 'boolean' ? params.silent : undefined,
|
|
347
348
|
});
|
|
349
|
+
// MAX dialect and tables of the account (markdown.tables), before chunking.
|
|
350
|
+
const formatText = (text: string) =>
|
|
351
|
+
toMaxMarkdown(text, { tableMode: resolveMaxTableMode(account.config) });
|
|
348
352
|
|
|
349
353
|
// Strip provider prefix from target (e.g. "max:123456789" → "123456789")
|
|
350
354
|
const stripPrefix = (val: string | undefined): string | undefined => {
|
|
@@ -360,11 +364,12 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
|
|
|
360
364
|
});
|
|
361
365
|
const presentation = normalizeMessagePresentation(params.presentation);
|
|
362
366
|
const pin = readMaxDeliveryPin(params.delivery, params.pin);
|
|
363
|
-
const content =
|
|
367
|
+
const content = formatText(
|
|
364
368
|
readStringParam(params, 'message', {
|
|
365
369
|
required: !presentation,
|
|
366
370
|
allowEmpty: true,
|
|
367
|
-
}) ?? ''
|
|
371
|
+
}) ?? '',
|
|
372
|
+
);
|
|
368
373
|
|
|
369
374
|
// Pin the sent message when delivery.pin (or pin=true) was requested.
|
|
370
375
|
// Optional pin failures degrade; a required one fails the action.
|
|
@@ -410,7 +415,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
|
|
|
410
415
|
if (presentation) {
|
|
411
416
|
const rendered = await materializeMaxPresentation({ text: content, presentation });
|
|
412
417
|
const renderedButtons = readMaxChannelButtons(rendered.channelData);
|
|
413
|
-
const text = rendered.text ?? '';
|
|
418
|
+
const text = formatText(rendered.text ?? '');
|
|
414
419
|
const chunkLimit = resolveMaxTextChunkLimit(cfg, account.accountId);
|
|
415
420
|
const chunks =
|
|
416
421
|
text.length > chunkLimit
|
|
@@ -518,7 +523,7 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
|
|
|
518
523
|
required: true,
|
|
519
524
|
allowEmpty: true,
|
|
520
525
|
});
|
|
521
|
-
await editMaxMessage(messageId, text, {
|
|
526
|
+
await editMaxMessage(messageId, formatText(text), {
|
|
522
527
|
token: account.token,
|
|
523
528
|
format: 'markdown',
|
|
524
529
|
});
|
|
@@ -588,8 +593,9 @@ export const maxMessageActions: ChannelMessageActionAdapter = {
|
|
|
588
593
|
const to = stripPrefix(readTargetParam(params))!;
|
|
589
594
|
const replyTo = readStringParam(params, 'replyTo');
|
|
590
595
|
await assertMaxActionInScope(ctx, account, { to, replyTo });
|
|
591
|
-
const caption =
|
|
592
|
-
readStringParam(params, 'message') ?? readStringParam(params, 'caption') ?? ''
|
|
596
|
+
const caption = formatText(
|
|
597
|
+
readStringParam(params, 'message') ?? readStringParam(params, 'caption') ?? '',
|
|
598
|
+
);
|
|
593
599
|
const attachType =
|
|
594
600
|
readStringParam(params, 'type') ?? readStringParam(params, 'attachmentType') ?? '';
|
|
595
601
|
const mediaMaxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Agent prompt hints of the MAX channel:
|
|
3
|
-
* button types, presentation, voice, inline files, silent sends,
|
|
4
|
-
* roots and action scope for the message tool.
|
|
2
|
+
* Agent prompt hints of the MAX channel: text formatting, stickers, location,
|
|
3
|
+
* contact, pin, button types, presentation, voice, inline files, silent sends,
|
|
4
|
+
* local file roots and action scope for the message tool.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
|
|
@@ -19,6 +19,7 @@ export const maxAgentPromptAdapter: NonNullable<MaxChannelPlugin['agentPrompt']>
|
|
|
19
19
|
'😂:109550b5 😊:109971b5 😍:10931eb5 🥰:10931eb5 😢:109330b5 😭:109330b5 😡:10941fb5 😱:109302b5 🤔:109308b5 👍:109368b5 👎:109323b5 ❤️:10931eb5 🔥:b4867ebb 💪:c1254bbb 🎉:10933cb5 😘:10931eb5 🤗:109b94b5 😎:10931db5 🙄:c14211bb 😴:10936eb5 😤:10941fb5 🤮:6b8bb 🤯:109302b5 😳:109302b5 🥳:10933cb5 💀:b4863ebb 🙈:b4850cbb 😏:11e4c60bb 😅:109550b5 🤣:109550b5 😋:109d2db5 😜:455b5 🤷:10d5cf5bb 😫:10936eb5 😩:10997db5 🥺:109356b5 😌:14aae3bb 😒:109323b5 🤪:455b5 😇:11e4dedbb 🙏:50cb5 💔:10997db5 👀:b48534bb ✨:11e43b2bb 😈:10941fb5 🤝:109368b5 🤦:502b5 😬:5dab4b5 🤩:5dabfb5 😶:2ae2b5';
|
|
20
20
|
|
|
21
21
|
return [
|
|
22
|
+
'- MAX formatting (replies and `send`): Markdown with **bold**, *italic*, ~~strike~~, ++underline++, ^^highlight^^ (red), `code`, ``` blocks, [links](https://example.com), `# Heading` (one level: `##`…`######` are sent as `#`) and `> quote`. MAX has no tables: by default a narrow Markdown table (up to 36 monospace columns) is sent as a monospace block and a wider one as bullets (bold first cell, a bullet per other cell); channels.max.markdown.tables fixes one mode. Keep tables to a few short columns, use lists for wide data.',
|
|
22
23
|
'- MAX stickers: use `message(action="sticker", target="CHAT_ID", stickerId="CODE")`. Pick a sticker code matching the mood from the emoji map below. Each entry is emoji:hexCode.',
|
|
23
24
|
`- Sticker emoji map: ${emojiMap}`,
|
|
24
25
|
'- MAX location: use `message(action="sendAttachment", target="CHAT_ID", type="location", latitude="55.75", longitude="37.62")` to send a native map pin.',
|
package/src/channel-directory.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { ChannelPlugin } from 'openclaw/plugin-sdk/channel-core';
|
|
|
7
7
|
|
|
8
8
|
import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
9
9
|
import { MaxApi } from './api.js';
|
|
10
|
+
import { MAX_DECLARED_TABLE_MODE } from './format.js';
|
|
10
11
|
import { loadMaxAccountState } from './state.js';
|
|
11
12
|
|
|
12
13
|
type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
|
|
@@ -33,6 +34,10 @@ export const maxMessagingAdapter: NonNullable<MaxChannelPlugin['messaging']> = {
|
|
|
33
34
|
looksLikeId: (raw) => MAX_TARGET_PATTERN.test(stripMaxPrefix(raw)),
|
|
34
35
|
hint: '<chatId|user:userId>',
|
|
35
36
|
},
|
|
37
|
+
// What core sees for MAX without markdown.tables: keeps the group prompt's
|
|
38
|
+
// "Avoid Markdown tables" and is the safe mode for core's resolver. The
|
|
39
|
+
// plugin itself picks code or bullets per table by width (format.ts).
|
|
40
|
+
defaultMarkdownTableMode: MAX_DECLARED_TABLE_MODE,
|
|
36
41
|
};
|
|
37
42
|
|
|
38
43
|
/** MAX has no member/chat listing for bots: peers come from allowFrom, groups from the chat registry. */
|
package/src/channel-outbound.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
|
8
8
|
import { getAgentScopedMediaLocalRoots } from 'openclaw/plugin-sdk/media-local-roots';
|
|
9
9
|
|
|
10
10
|
import { type ResolvedMaxAccount, resolveMaxAccount } from './accounts.js';
|
|
11
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
11
12
|
import {
|
|
12
13
|
materializeMaxPresentation,
|
|
13
14
|
MAX_PRESENTATION_CAPABILITIES,
|
|
@@ -28,6 +29,11 @@ import {
|
|
|
28
29
|
|
|
29
30
|
type MaxChannelPlugin = ChannelPlugin<ResolvedMaxAccount>;
|
|
30
31
|
|
|
32
|
+
/** MAX dialect and tables of the account (markdown.tables); see format.ts. */
|
|
33
|
+
function formatMaxText(text: string, account: ResolvedMaxAccount): string {
|
|
34
|
+
return toMaxMarkdown(text, { tableMode: resolveMaxTableMode(account.config) });
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
/**
|
|
32
38
|
* Local media access for an outbound send: the roots and host reader core
|
|
33
39
|
* passed with the delivery; without them the default media roots (no agent
|
|
@@ -55,6 +61,12 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
55
61
|
getMaxRuntime().channel.text.chunkMarkdownText(text, Math.min(limit, MAX_TEXT_LIMIT)),
|
|
56
62
|
chunkerMode: 'markdown',
|
|
57
63
|
textChunkLimit: 4000,
|
|
64
|
+
// Before core chunks the text: tables become bullets or one code block, so
|
|
65
|
+
// the chunker measures the text as sent and never splits a raw table.
|
|
66
|
+
normalizePayload: ({ payload, cfg, accountId }) =>
|
|
67
|
+
payload.text
|
|
68
|
+
? { ...payload, text: formatMaxText(payload.text, resolveMaxAccount({ cfg, accountId })) }
|
|
69
|
+
: payload,
|
|
58
70
|
|
|
59
71
|
// Presentation (docs/plugins/message-presentation.md): core adapts to these
|
|
60
72
|
// capabilities, renders through renderPresentation and pins through
|
|
@@ -86,7 +98,10 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
86
98
|
const payload = rawPayload.presentation
|
|
87
99
|
? await materializeMaxPresentation(rawPayload)
|
|
88
100
|
: rawPayload;
|
|
89
|
-
const effectiveText =
|
|
101
|
+
const effectiveText = formatMaxText(
|
|
102
|
+
payload === rawPayload ? text : (payload.text ?? ''),
|
|
103
|
+
account,
|
|
104
|
+
);
|
|
90
105
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
91
106
|
const sendFlags = resolveMaxSendFlags(account.config, {
|
|
92
107
|
silent: ctx.silent,
|
|
@@ -149,7 +164,7 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
149
164
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
150
165
|
if (!account.token) throw new Error('MAX bot token not configured');
|
|
151
166
|
|
|
152
|
-
const result = await sendMaxMessage(to, text, {
|
|
167
|
+
const result = await sendMaxMessage(to, formatMaxText(text, account), {
|
|
153
168
|
token: account.token,
|
|
154
169
|
replyToMessageId: replyToId ?? undefined,
|
|
155
170
|
format: 'markdown',
|
|
@@ -163,11 +178,12 @@ export const maxOutboundAdapter: NonNullable<MaxChannelPlugin['outbound']> = {
|
|
|
163
178
|
},
|
|
164
179
|
|
|
165
180
|
sendMedia: async (ctx) => {
|
|
166
|
-
const { to,
|
|
181
|
+
const { to, mediaUrl, accountId, replyToId } = ctx;
|
|
167
182
|
const cfg = await loadMaxConfig();
|
|
168
183
|
const account = resolveMaxAccount({ cfg, accountId });
|
|
169
184
|
if (!account.token) throw new Error('MAX bot token not configured');
|
|
170
185
|
const sendFlags = resolveMaxSendFlags(account.config, { silent: ctx.silent });
|
|
186
|
+
const text = formatMaxText(ctx.text, account);
|
|
171
187
|
|
|
172
188
|
if (!mediaUrl) {
|
|
173
189
|
// No media, send as text
|
package/src/config-schema.ts
CHANGED
|
@@ -84,7 +84,11 @@ export const MaxAccountSchemaBase = z
|
|
|
84
84
|
.object({
|
|
85
85
|
name: z.string().optional(),
|
|
86
86
|
enabled: z.boolean().optional(),
|
|
87
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* markdown.tables: how agent tables reach MAX, which has no native tables —
|
|
89
|
+
* not set: by width (narrow as a monospace block, wide as bullets); bullets,
|
|
90
|
+
* code (monospace block), block (same as code), off (raw) for every table.
|
|
91
|
+
*/
|
|
88
92
|
markdown: MarkdownConfigSchema.optional(),
|
|
89
93
|
/** Plain token or a core SecretRef { source, provider, id } (also webhookSecret, httpProxy) */
|
|
90
94
|
botToken: buildOptionalSecretInputSchema(),
|
package/src/deliver.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { LocalMediaAccessError } from 'openclaw/plugin-sdk/web-media';
|
|
|
13
13
|
|
|
14
14
|
import type { ResolvedMaxAccount } from './accounts.js';
|
|
15
15
|
import { MaxApiError } from './api.js';
|
|
16
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
16
17
|
import { readMaxDeliveryPin, resolveMaxTextChunkLimit } from './presentation.js';
|
|
17
18
|
import { getMaxRuntime } from './runtime.js';
|
|
18
19
|
import {
|
|
@@ -109,6 +110,11 @@ export async function deliverMaxReply(params: {
|
|
|
109
110
|
}): Promise<void> {
|
|
110
111
|
const { payload, account, chatId, config, log, statusSink } = params;
|
|
111
112
|
const core = getMaxRuntime();
|
|
113
|
+
// MAX dialect and tables (markdown.tables) before chunking: a table becomes
|
|
114
|
+
// bullets or one code block, and the chunks are measured as sent.
|
|
115
|
+
const replyText = payload.text
|
|
116
|
+
? toMaxMarkdown(payload.text, { tableMode: resolveMaxTableMode(account.config) })
|
|
117
|
+
: payload.text;
|
|
112
118
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
113
119
|
// channelData.max (notify, silent, disableLinkPreview) beats the account defaults.
|
|
114
120
|
const sendOptions = resolveMaxSendFlags(account.config, { channelData: payload.channelData });
|
|
@@ -142,13 +148,13 @@ export async function deliverMaxReply(params: {
|
|
|
142
148
|
if (answersCallback) {
|
|
143
149
|
if (params.callbackState) params.callbackState.answered = true;
|
|
144
150
|
try {
|
|
145
|
-
await answerMaxCallback(params.callbackId as string,
|
|
151
|
+
await answerMaxCallback(params.callbackId as string, replyText ?? '', {
|
|
146
152
|
token: account.token,
|
|
147
153
|
format: 'markdown',
|
|
148
154
|
buttons,
|
|
149
155
|
});
|
|
150
156
|
visible.callbackAnswered = true;
|
|
151
|
-
if (
|
|
157
|
+
if (replyText) visible.texts.push(replyText);
|
|
152
158
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
153
159
|
} catch (err: unknown) {
|
|
154
160
|
// Like a failed text chunk: the media still goes, then reject.
|
|
@@ -182,10 +188,10 @@ export async function deliverMaxReply(params: {
|
|
|
182
188
|
|
|
183
189
|
if (answersCallback) {
|
|
184
190
|
// The text went with the callback answer.
|
|
185
|
-
} else if (
|
|
191
|
+
} else if (replyText) {
|
|
186
192
|
const chunkLimit = resolveMaxTextChunkLimit(config, account.accountId);
|
|
187
193
|
const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
|
|
188
|
-
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
|
194
|
+
const chunks = core.channel.text.chunkMarkdownTextWithMode(replyText, chunkLimit, chunkMode);
|
|
189
195
|
|
|
190
196
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
191
197
|
const chunk = chunks[index];
|
package/src/format.ts
CHANGED
|
@@ -1,32 +1,215 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Конвертер форматирования OpenClaw/стандартный markdown → MAX markdown.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - bold, italic, strikethrough, code, ссылки — совпадают со стандартом.
|
|
4
|
+
* Что MAX разбирает сам (живая проба 10.10.2026, body.markup из GET /messages):
|
|
5
|
+
* `**`/`__` жирный, `*`/`_` курсив, `~~` зачёркнутый, `++` подчёркнутый,
|
|
6
|
+
* `^^` выделение, `код` и ```блоки```, ссылки, упоминания
|
|
7
|
+
* [Имя](max://user/user_id), `# заголовок` (один уровень; setext `===` тоже),
|
|
8
|
+
* `> цитата` и экранирование `\`. Это НЕ трогаем.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Что конвертируем (MAX показывает сырым или теряет):
|
|
11
|
+
* - pipe-таблицы — штатным convertMarkdownTables ядра (меняет только диапазоны
|
|
12
|
+
* таблиц). Без `markdown.tables` (умолчание) каждая таблица по своей ширине:
|
|
13
|
+
* узкая — моноширинным блоком, широкая — пунктами. Явный режим — как в ядре:
|
|
14
|
+
* bullets — пункт на ячейку, code — моноширинный блок, block → code
|
|
15
|
+
* (нативных таблиц в MAX нет), off — как есть;
|
|
16
|
+
* - `##`…`######` → `#`: уровни 2–6 MAX снимает без разметки;
|
|
17
|
+
* - горизонтальные линии `---`, `***`, `___` → `───`: MAX оставляет их сырыми,
|
|
18
|
+
* `* * *` превращает во вложенные списки, а `---` под строкой текста
|
|
19
|
+
* съедает вместе с её форматом (setext-заголовок второго уровня);
|
|
20
|
+
* - `==текст==` → `^^текст^^`;
|
|
21
|
+
* - `<u>…</u>` → `++…++`: подчёркивание тегом MAX в markdown не понимает.
|
|
22
|
+
*
|
|
23
|
+
* Заголовки и линии меняются только вне fenced-блоков, инлайн-замены — вне
|
|
24
|
+
* inline-кода, fenced-блоков и URL. Повторный вызов ничего не меняет, поэтому
|
|
25
|
+
* текст можно подготовить до разбиения на части, а send.ts пройдёт по каждой
|
|
26
|
+
* части ещё раз.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { convertMarkdownTables, markdownToIRWithMeta } from 'openclaw/plugin-sdk/text-chunking';
|
|
30
|
+
|
|
31
|
+
/** channels.max.markdown.tables (и accounts.<id>.markdown.tables). */
|
|
32
|
+
export type MaxMarkdownTableMode = 'off' | 'bullets' | 'code' | 'block';
|
|
33
|
+
|
|
34
|
+
/** Режим таблиц для toMaxMarkdown: auto — markdown.tables не задан, режим по ширине таблицы. */
|
|
35
|
+
export type MaxTableMode = Exclude<MaxMarkdownTableMode, 'block'> | 'auto';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Умолчание, которое видит ядро (messaging.defaultMarkdownTableMode). Режима
|
|
39
|
+
* «по ширине» в MarkdownTableMode ядра нет, а ядро по этому значению решает
|
|
40
|
+
* две вещи: добавлять ли в групповой промпт «Avoid Markdown tables» (да для
|
|
41
|
+
* всего, кроме block/off) и что вернёт resolveMarkdownTableMode для MAX без
|
|
42
|
+
* конфига. bullets: подсказка остаётся — нативных таблиц в MAX нет, широкая
|
|
43
|
+
* таблица в группе станет длинным списком; а если кто-то сконвертирует текст
|
|
44
|
+
* для MAX по этому ответу, пункты не развалятся на телефоне. block солгал бы
|
|
45
|
+
* ядру о нативных таблицах, off — о сырых.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_DECLARED_TABLE_MODE: MaxMarkdownTableMode = 'bullets';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Самая широкая таблица, которая без markdown.tables уходит моноширинным
|
|
51
|
+
* блоком: ширина строки блока в колонках. Блок кода в MAX не прокручивается,
|
|
52
|
+
* длинная строка переносится и таблица разваливается; на обычном телефоне
|
|
53
|
+
* в блок влезает 34–38 моноширинных символов (демо 10.10.2026). Шире — пункты.
|
|
13
54
|
*/
|
|
55
|
+
export const MAX_NARROW_TABLE_WIDTH = 36;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Режим таблиц аккаунта: accounts.<id> наследует channels.max.markdown
|
|
59
|
+
* (accounts.ts). Не задан — auto; block → code: нативных таблиц в MAX нет.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveMaxTableMode(config?: {
|
|
62
|
+
markdown?: { tables?: MaxMarkdownTableMode };
|
|
63
|
+
}): MaxTableMode {
|
|
64
|
+
const mode = config?.markdown?.tables;
|
|
65
|
+
if (!mode) return 'auto';
|
|
66
|
+
return mode === 'block' ? 'code' : mode;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const graphemes = new Intl.Segmenter();
|
|
70
|
+
const WIDE_GRAPHEME =
|
|
71
|
+
/\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F|[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]/u;
|
|
72
|
+
const ZERO_WIDTH_GRAPHEME = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
|
|
73
|
+
|
|
74
|
+
/** Ширина в моноширинных колонках: эмодзи и CJK — 2, метки и невидимые — 0, прочее — 1. */
|
|
75
|
+
export function displayWidth(text: string): number {
|
|
76
|
+
let width = 0;
|
|
77
|
+
for (const { segment } of graphemes.segment(text)) {
|
|
78
|
+
if (WIDE_GRAPHEME.test(segment)) width += 2;
|
|
79
|
+
else if (!ZERO_WIDTH_GRAPHEME.test(segment)) width += 1;
|
|
80
|
+
}
|
|
81
|
+
return width;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ширина строки моноширинного блока таблицы, как его строит ядро: `| ячейка | … |`, колонка не уже 3. */
|
|
85
|
+
export function codeTableWidth(headers: string[], rows: string[][]): number {
|
|
86
|
+
const widths: number[] = [];
|
|
87
|
+
for (const row of [headers, ...rows]) {
|
|
88
|
+
row.forEach((cell, column) => {
|
|
89
|
+
widths[column] = Math.max(widths[column] ?? 3, displayWidth(cell));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return widths.reduce((sum, width) => sum + width + 3, 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Режимы таблиц текста в auto: узкая — code, широкая — bullets. Таблица со
|
|
97
|
+
* ссылками — тоже bullets: в моноширинном блоке ядро оставляет от ссылки только
|
|
98
|
+
* текст, адрес теряется.
|
|
99
|
+
*/
|
|
100
|
+
function autoTableModes(markdown: string): Set<'code' | 'bullets'> {
|
|
101
|
+
if (!markdown.includes('|')) return new Set();
|
|
102
|
+
const { tables } = markdownToIRWithMeta(markdown, {
|
|
103
|
+
linkify: false,
|
|
104
|
+
autolink: false,
|
|
105
|
+
tableMode: 'block',
|
|
106
|
+
});
|
|
107
|
+
return new Set(
|
|
108
|
+
tables.map((table) =>
|
|
109
|
+
codeTableWidth(table.headers, table.rows) <= MAX_NARROW_TABLE_WIDTH &&
|
|
110
|
+
![table.headerCells, ...table.rowCells].some((row) => row.some((cell) => cell.links.length))
|
|
111
|
+
? 'code'
|
|
112
|
+
: 'bullets',
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
14
116
|
|
|
15
117
|
const CODE_OR_URL = /(```[\s\S]*?```|`[^`]*`|https?:\/\/\S+)/g;
|
|
16
118
|
|
|
17
|
-
/**
|
|
119
|
+
/** Открывающая/закрывающая ограда блока кода, в том числе в списке или цитате. */
|
|
120
|
+
const FENCE = /^\s*(?:>\s*)*(`{3,}|~{3,})/;
|
|
121
|
+
const SUBHEADING = /^ {0,3}#{2,6}(?=[ \t])/;
|
|
122
|
+
const THEMATIC_BREAK = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
123
|
+
const HORIZONTAL_RULE = '───';
|
|
124
|
+
const HIGHLIGHT = /(?<![\p{L}\p{N}_=])==(?=[^\s=])([^=\n]*?[^\s=])==(?![\p{L}\p{N}_=])/gu;
|
|
125
|
+
|
|
126
|
+
/** Открытая ограда ```/~~~ блока после строки `line`; undefined — вне блока. */
|
|
127
|
+
function fenceAfter(line: string, fence: string | undefined): string | undefined {
|
|
128
|
+
const match = FENCE.exec(line);
|
|
129
|
+
if (!fence) return match?.[1];
|
|
130
|
+
const closes =
|
|
131
|
+
match &&
|
|
132
|
+
match[1][0] === fence[0] &&
|
|
133
|
+
match[1].length >= fence.length &&
|
|
134
|
+
!line.slice(match[0].length).trim();
|
|
135
|
+
return closes ? undefined : fence;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Заголовки уровней 2–6 и горизонтальные линии; строки внутри ```/~~~ блоков не трогаются. */
|
|
139
|
+
function convertBlocks(text: string): string {
|
|
140
|
+
let fence: string | undefined;
|
|
141
|
+
return text
|
|
142
|
+
.split('\n')
|
|
143
|
+
.map((line) => {
|
|
144
|
+
const inFence = fence !== undefined;
|
|
145
|
+
fence = fenceAfter(line, fence);
|
|
146
|
+
if (inFence || fence) return line;
|
|
147
|
+
if (THEMATIC_BREAK.test(line)) return HORIZONTAL_RULE;
|
|
148
|
+
return line.replace(SUBHEADING, '#');
|
|
149
|
+
})
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Куски текста до пустой строки вне ```/~~~ блоков включительно; join('') возвращает текст. */
|
|
154
|
+
function splitAtBlankLines(text: string): string[] {
|
|
155
|
+
const blocks: string[] = [];
|
|
156
|
+
let block = '';
|
|
157
|
+
let fence: string | undefined;
|
|
158
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
159
|
+
block += line;
|
|
160
|
+
fence = fenceAfter(line, fence);
|
|
161
|
+
if (!fence && !line.trim()) {
|
|
162
|
+
blocks.push(block);
|
|
163
|
+
block = '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return block ? [...blocks, block] : blocks;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** auto: каждая таблица по ширине — узкая моноширинным блоком, широкая пунктами. */
|
|
170
|
+
function convertTablesAuto(text: string): string {
|
|
171
|
+
const modes = autoTableModes(text);
|
|
172
|
+
if (modes.size < 2) {
|
|
173
|
+
const [mode] = modes;
|
|
174
|
+
return mode ? convertMarkdownTables(text, mode) : text;
|
|
175
|
+
}
|
|
176
|
+
// convertMarkdownTables задаёт один режим на весь текст, поэтому разные
|
|
177
|
+
// таблицы конвертируются по кускам между пустыми строками: пустая строка
|
|
178
|
+
// таблицу завершает. Таблицу, которую кусок без контекста не распознал
|
|
179
|
+
// (глубоко вложенный список), добирает последний проход пунктами — готовые
|
|
180
|
+
// блоки и пункты он не трогает.
|
|
181
|
+
const converted = splitAtBlankLines(text)
|
|
182
|
+
.map((block) =>
|
|
183
|
+
convertMarkdownTables(block, autoTableModes(block).has('bullets') ? 'bullets' : 'code'),
|
|
184
|
+
)
|
|
185
|
+
.join('');
|
|
186
|
+
return convertMarkdownTables(converted, 'bullets');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Инлайн-замены сегмента вне кода и URL: <u>…</u> → ++…++, ==…== → ^^…^^. */
|
|
18
190
|
function convertSegment(text: string): string {
|
|
19
|
-
return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++');
|
|
191
|
+
return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++').replace(HIGHLIGHT, '^^$1^^');
|
|
20
192
|
}
|
|
21
193
|
|
|
22
194
|
/**
|
|
23
195
|
* Конвертирует OpenClaw/стандартный markdown в MAX markdown, не затрагивая
|
|
24
|
-
* содержимое inline-кода, fenced-блоков и URL.
|
|
196
|
+
* содержимое inline-кода, fenced-блоков и URL. Таблицы конвертируются, только
|
|
197
|
+
* если передан `tableMode` (режим аккаунта, resolveMaxTableMode).
|
|
25
198
|
*/
|
|
26
|
-
export function toMaxMarkdown(
|
|
199
|
+
export function toMaxMarkdown(
|
|
200
|
+
text: string,
|
|
201
|
+
options: { tableMode?: MaxMarkdownTableMode | 'auto' } = {},
|
|
202
|
+
): string {
|
|
27
203
|
if (!text) return text;
|
|
204
|
+
const { tableMode } = options;
|
|
205
|
+
const withTables =
|
|
206
|
+
tableMode === 'auto'
|
|
207
|
+
? convertTablesAuto(text)
|
|
208
|
+
: tableMode && tableMode !== 'off'
|
|
209
|
+
? convertMarkdownTables(text, tableMode === 'block' ? 'code' : tableMode)
|
|
210
|
+
: text;
|
|
28
211
|
// split с захватывающей группой: нечётные индексы — код/URL (не трогаем)
|
|
29
|
-
return
|
|
212
|
+
return convertBlocks(withTables)
|
|
30
213
|
.split(CODE_OR_URL)
|
|
31
214
|
.map((segment, index) => (index % 2 === 0 ? convertSegment(segment) : segment))
|
|
32
215
|
.join('');
|
package/src/stream-draft.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
|
|
10
10
|
|
|
11
11
|
import type { ResolvedMaxAccount } from './accounts.js';
|
|
12
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
12
13
|
import {
|
|
13
14
|
deleteMaxMessage,
|
|
14
15
|
editMaxMessage,
|
|
@@ -78,7 +79,11 @@ export function createMaxDraftStream(params: {
|
|
|
78
79
|
|
|
79
80
|
const draftUpdate = async (text: string) => {
|
|
80
81
|
if (draftStopped || !text) return;
|
|
81
|
-
|
|
82
|
+
// Formatted like the final answer (deliver.ts), so the final edit compares
|
|
83
|
+
// and replaces like with like.
|
|
84
|
+
const trimmed = toMaxMarkdown(text, {
|
|
85
|
+
tableMode: resolveMaxTableMode(account.config),
|
|
86
|
+
}).trimEnd();
|
|
82
87
|
if (!trimmed || trimmed === draftLastText) return;
|
|
83
88
|
if (trimmed.length > DRAFT_MAX_CHARS) {
|
|
84
89
|
draftStopped = true;
|