@aspalagin/openclaw-max 0.8.2 → 0.9.2
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/json-store-compat.js +26 -0
- package/dist/src/state.js +2 -1
- package/dist/src/stream-draft.js +6 -1
- package/dist/src/update-journal.js +2 -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/json-store-compat.ts +28 -0
- package/src/state.ts +3 -1
- package/src/stream-draft.ts +6 -1
- package/src/update-journal.ts +3 -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.2. Изменения: [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.2. 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('');
|