@aspalagin/openclaw-max 0.8.1 → 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.0. Изменения: [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
 
@@ -490,3 +525,5 @@ CI также запускает `typecheck` и тесты на `openclaw@latest
490
525
  Разработка ведётся с помощью ИИ-агентов.
491
526
 
492
527
  **Лицензия:** MIT, см. [LICENSE](LICENSE).
528
+
529
+ **Товарный знак.** Плагин неофициальный. MAX — товарный знак правообладателя, ООО «МАХ»; иконка пакета — логотип из [брендбука MAX](https://go.max.ru/brandbook).
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.0. 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.0. 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.0. 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
 
@@ -490,3 +525,5 @@ Questions and proposals: [GitHub Issues](https://github.com/aspalagin/openclaw-m
490
525
  Development is carried out with the help of AI agents.
491
526
 
492
527
  **License:** MIT, see [LICENSE](LICENSE).
528
+
529
+ **Trademark.** This plugin is unofficial. MAX is a trademark of its rights holder, ООО «МАХ»; the package icon is the logo from the [MAX brand book](https://go.max.ru/brandbook).
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
  - **Пересылки и цитаты** — агент видит пересланные сообщения и цитату сообщения, на которое ответили.
package/assets/icon.png CHANGED
Binary file
@@ -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);
package/dist/src/api.js CHANGED
@@ -200,6 +200,40 @@ export class MaxApiError extends Error {
200
200
  }
201
201
  }
202
202
  }
203
+ /** A MAX error code as it may appear in the log: a short dotted identifier. */
204
+ const LOGGABLE_ERROR_CODE = /^[A-Za-z0-9][\w.-]{0,63}$/;
205
+ /** A localization key MAX puts in `message`, e.g. "Key: errors.process.attachment.movie.not.owner". */
206
+ const LOGGABLE_ERROR_KEY = /^Key:\s*(errors\.[\w.-]{1,100})$/;
207
+ /**
208
+ * The machine-readable part of a MAX error response for the log: `code`
209
+ * (e.g. "proto.payload") and, when `message` is a bare localization key, that
210
+ * key. Free-form `message` text is left out: MAX documents it only as a
211
+ * human-readable description and some messages quote the request (e.g. "Path
212
+ * /chats is not recognized"), so it may echo user data. Accepts the parsed
213
+ * body or the raw response text; an empty or non-JSON body yields "".
214
+ * @internal exported for testing.
215
+ */
216
+ export function describeMaxErrorBody(body) {
217
+ let parsed = body;
218
+ if (typeof body === 'string') {
219
+ try {
220
+ parsed = JSON.parse(body);
221
+ }
222
+ catch {
223
+ return '';
224
+ }
225
+ }
226
+ if (!parsed || typeof parsed !== 'object')
227
+ return '';
228
+ const { code, message } = parsed;
229
+ const parts = [];
230
+ if (typeof code === 'string' && LOGGABLE_ERROR_CODE.test(code))
231
+ parts.push(`code=${code}`);
232
+ const key = typeof message === 'string' ? LOGGABLE_ERROR_KEY.exec(message.trim())?.[1] : undefined;
233
+ if (key)
234
+ parts.push(`key=${key}`);
235
+ return parts.join(' ');
236
+ }
203
237
  /**
204
238
  * A request that hit the client-side deadline (our AbortController), as opposed
205
239
  * to a caller-supplied stop signal. Carries the stalled phase so the send path
@@ -325,9 +359,12 @@ export class MaxApi {
325
359
  }
326
360
  if (!res.ok) {
327
361
  // Never log the request body: it may carry the webhook secret (POST
328
- // /subscriptions) or private message text. Log only method+path+status.
329
- getMaxLogger().error(`[MAX API] ${method} ${path} → ${res.status}`);
330
- const error = new MaxApiError(`MAX API ${method} ${path} → ${res.status}`, res.status, json);
362
+ // /subscriptions) or private message text. Log only method+path+status
363
+ // and the MAX error code (describeMaxErrorBody).
364
+ const detail = describeMaxErrorBody(json);
365
+ const summary = `${method} ${path} → ${res.status}${detail ? ` (${detail})` : ''}`;
366
+ getMaxLogger().error(`[MAX API] ${summary}`);
367
+ const error = new MaxApiError(`MAX API ${summary}`, res.status, json);
331
368
  const retryAfter = res.headers?.get?.('retry-after');
332
369
  if (retryAfter) {
333
370
  const seconds = Number(retryAfter);
@@ -534,7 +571,9 @@ export class MaxApi {
534
571
  body: multipart.body,
535
572
  }, this.network().proxyUrl);
536
573
  if (!uploadRes.ok) {
537
- throw new MaxApiError(`MAX media upload failed: ${uploadRes.status}`, uploadRes.status, await uploadRes.text().catch(() => null));
574
+ const text = await uploadRes.text().catch(() => null);
575
+ const detail = describeMaxErrorBody(text);
576
+ throw new MaxApiError(`MAX media upload failed: ${uploadRes.status}${detail ? ` (${detail})` : ''}`, uploadRes.status, text);
538
577
  }
539
578
  // Step 3: Parse response to extract token
540
579
  // Image response: { photos: { "<id>": { token: "...", url: "..." } } }
@@ -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';
@@ -42,12 +43,18 @@ export function toMaxDeliveryError(err, visible) {
42
43
  return err;
43
44
  }
44
45
  function logSendFailure(account, log, what, err) {
45
- const body = err?.body;
46
- log?.error(`[${account.accountId}] MAX ${what} failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
46
+ // A MaxApiError message already carries the MAX error code; the response body
47
+ // stays out of the log, its free-form `message` may echo user data.
48
+ log?.error(`[${account.accountId}] MAX ${what} failed: ${String(err)}`);
47
49
  }
48
50
  export async function deliverMaxReply(params) {
49
51
  const { payload, account, chatId, config, log, statusSink } = params;
50
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;
51
58
  const buttons = readMaxChannelButtons(payload.channelData);
52
59
  // channelData.max (notify, silent, disableLinkPreview) beats the account defaults.
53
60
  const sendOptions = resolveMaxSendFlags(account.config, { channelData: payload.channelData });
@@ -78,14 +85,14 @@ export async function deliverMaxReply(params) {
78
85
  if (params.callbackState)
79
86
  params.callbackState.answered = true;
80
87
  try {
81
- await answerMaxCallback(params.callbackId, payload.text ?? '', {
88
+ await answerMaxCallback(params.callbackId, replyText ?? '', {
82
89
  token: account.token,
83
90
  format: 'markdown',
84
91
  buttons,
85
92
  });
86
93
  visible.callbackAnswered = true;
87
- if (payload.text)
88
- visible.texts.push(payload.text);
94
+ if (replyText)
95
+ visible.texts.push(replyText);
89
96
  statusSink?.({ lastOutboundAt: Date.now() });
90
97
  }
91
98
  catch (err) {
@@ -121,10 +128,10 @@ export async function deliverMaxReply(params) {
121
128
  if (answersCallback) {
122
129
  // The text went with the callback answer.
123
130
  }
124
- else if (payload.text) {
131
+ else if (replyText) {
125
132
  const chunkLimit = resolveMaxTextChunkLimit(config, account.accountId);
126
133
  const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
127
- const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
134
+ const chunks = core.channel.text.chunkMarkdownTextWithMode(replyText, chunkLimit, chunkMode);
128
135
  for (let index = 0; index < chunks.length; index += 1) {
129
136
  const chunk = chunks[index];
130
137
  const chunkButtons = index === chunks.length - 1 ? buttons : undefined;