@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 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.8.2. Изменения: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md).
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 (`++underline++`) и делится на части по 4000 символов; если 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
- Ключи `markdown` (отрисовка таблиц), `blockStreaming` (используйте `streamMode` или `streaming.mode: "block"`) и `blockStreamingCoalesce` (ядро читает `streaming.block.coalesce`) схема принимает для совместимости с общим форматом конфигурации каналов, но **в MAX они ни на что не влияют**: ни плагин, ни ядро OpenClaw не читают их для этого канала. Они оставлены в схеме, чтобы существующие конфиги по-прежнему проходили проверку.
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.8.2. Changes: [CHANGELOG.md](https://github.com/aspalagin/openclaw-max/blob/main/CHANGELOG.md) (in Russian).
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 (`++underline++`), split at 4000 characters; if MAX rejects the markup, resent once as plain text |
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: `markdown` (table rendering), `blockStreaming` (use `streamMode` or `streaming.mode: "block"`), `blockStreamingCoalesce` (core reads `streaming.block.coalesce`). They stay in the schema so existing configs keep validating.
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 (`++подчёркивание++`). Если MAX отверг разметку, сообщение уходит обычным текстом.
12
+ - **Текст** — отправка и получение; исходящий markdown переводится в диалект MAX: `# заголовок` (уровни `##`…`######` тоже идут как `#`), `^^выделение^^` (и `==…==`), `++подчёркивание++`, `> цитата`, линии `───`; таблицы — по умолчанию узкие моноширинным блоком, широкие пунктами (`markdown.tables` задаёт один режим). Если MAX отверг разметку, сообщение уходит обычным текстом.
13
13
  - **Медиа** — фото, видео, аудио, файлы, стикеры, контакты, геолокация; несколько вложений — альбомами до 12.
14
14
  - **Голосовые** — входящие распознаёт ядро (`tools.media.audio`) или берётся транскрипт MAX; голосовой ответ уходит аудиовложением MAX.
15
15
  - **Пересылки и цитаты** — агент видит пересланные сообщения и цитату сообщения, на которое ответили.
@@ -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,
@@ -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: stickers, location, contact, pin,
3
- * button types, presentation, voice, inline files, silent sends, local file
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, text, mediaUrl, accountId, replyToId } = ctx;
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
- /** No effect in MAX: neither the plugin nor core reads it for this channel. */
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(),
@@ -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, payload.text ?? '', {
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 (payload.text)
89
- visible.texts.push(payload.text);
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 (payload.text) {
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(payload.text, chunkLimit, chunkMode);
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;
@@ -1,30 +1,187 @@
1
1
  /**
2
2
  * Конвертер форматирования OpenClaw/стандартный markdown → MAX markdown.
3
3
  *
4
- * Отличия MAX markdown от стандартного:
5
- * - Подчёркивание: MAX не поддерживает markdown-синтаксис подчёркивания, но
6
- * рендерит HTML-тег <u>…</u> → конвертируем в ++…++.
7
- * - `**bold**` и `__bold__` MAX рендерит как жирный сам — НЕ трогаем.
8
- * - Упоминание: [Имя](max://user/user_id) — синтаксис MAX, оставляем как есть.
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
- * Конвертация выполняется только ВНЕ inline-кода, fenced-блоков и URL, чтобы
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
- /** Заменяет только <u>…</u> → ++…++; markdown-подчёркивание MAX не поддерживает. */
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 text
184
+ return convertBlocks(withTables)
28
185
  .split(CODE_OR_URL)
29
186
  .map((segment, index) => (index % 2 === 0 ? convertSegment(segment) : segment))
30
187
  .join('');
@@ -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
- const trimmed = text.trimEnd();
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) {
@@ -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.8.2",
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 (no effect)",
802
- "help": "Accepted for compatibility with the common channel config; has no effect in MAX.",
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 (no effect)",
1026
- "help": "Accepted for compatibility with the common channel config; has no effect in MAX.",
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.8.2",
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: stickers, location, contact, pin,
3
- * button types, presentation, voice, inline files, silent sends, local file
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.',
@@ -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. */
@@ -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 = payload === rawPayload ? text : (payload.text ?? '');
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, text, mediaUrl, accountId, replyToId } = ctx;
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
@@ -84,7 +84,11 @@ export const MaxAccountSchemaBase = z
84
84
  .object({
85
85
  name: z.string().optional(),
86
86
  enabled: z.boolean().optional(),
87
- /** No effect in MAX: neither the plugin nor core reads it for this channel. */
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, payload.text ?? '', {
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 (payload.text) visible.texts.push(payload.text);
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 (payload.text) {
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(payload.text, chunkLimit, chunkMode);
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
- * Отличия MAX markdown от стандартного:
5
- * - Подчёркивание: MAX не поддерживает markdown-синтаксис подчёркивания, но
6
- * рендерит HTML-тег <u>…</u> → конвертируем в ++…++.
7
- * - `**bold**` и `__bold__` MAX рендерит как жирный сам — НЕ трогаем.
8
- * - Упоминание: [Имя](max://user/user_id) — синтаксис MAX, оставляем как есть.
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
- * Конвертация выполняется только ВНЕ inline-кода, fenced-блоков и URL, чтобы
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
- /** Заменяет только <u>…</u> → ++…++; markdown-подчёркивание MAX не поддерживает. */
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(text: string): string {
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 text
212
+ return convertBlocks(withTables)
30
213
  .split(CODE_OR_URL)
31
214
  .map((segment, index) => (index % 2 === 0 ? convertSegment(segment) : segment))
32
215
  .join('');
@@ -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
- const trimmed = text.trimEnd();
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;