@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.
@@ -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.1",
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.1",
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;
package/src/api.ts CHANGED
@@ -320,6 +320,39 @@ export class MaxApiError extends Error {
320
320
  }
321
321
  }
322
322
 
323
+ /** A MAX error code as it may appear in the log: a short dotted identifier. */
324
+ const LOGGABLE_ERROR_CODE = /^[A-Za-z0-9][\w.-]{0,63}$/;
325
+ /** A localization key MAX puts in `message`, e.g. "Key: errors.process.attachment.movie.not.owner". */
326
+ const LOGGABLE_ERROR_KEY = /^Key:\s*(errors\.[\w.-]{1,100})$/;
327
+
328
+ /**
329
+ * The machine-readable part of a MAX error response for the log: `code`
330
+ * (e.g. "proto.payload") and, when `message` is a bare localization key, that
331
+ * key. Free-form `message` text is left out: MAX documents it only as a
332
+ * human-readable description and some messages quote the request (e.g. "Path
333
+ * /chats is not recognized"), so it may echo user data. Accepts the parsed
334
+ * body or the raw response text; an empty or non-JSON body yields "".
335
+ * @internal exported for testing.
336
+ */
337
+ export function describeMaxErrorBody(body: unknown): string {
338
+ let parsed = body;
339
+ if (typeof body === 'string') {
340
+ try {
341
+ parsed = JSON.parse(body);
342
+ } catch {
343
+ return '';
344
+ }
345
+ }
346
+ if (!parsed || typeof parsed !== 'object') return '';
347
+ const { code, message } = parsed as { code?: unknown; message?: unknown };
348
+ const parts: string[] = [];
349
+ if (typeof code === 'string' && LOGGABLE_ERROR_CODE.test(code)) parts.push(`code=${code}`);
350
+ const key =
351
+ typeof message === 'string' ? LOGGABLE_ERROR_KEY.exec(message.trim())?.[1] : undefined;
352
+ if (key) parts.push(`key=${key}`);
353
+ return parts.join(' ');
354
+ }
355
+
323
356
  /**
324
357
  * A request that hit the client-side deadline (our AbortController), as opposed
325
358
  * to a caller-supplied stop signal. Carries the stalled phase so the send path
@@ -462,13 +495,12 @@ export class MaxApi {
462
495
 
463
496
  if (!res.ok) {
464
497
  // Never log the request body: it may carry the webhook secret (POST
465
- // /subscriptions) or private message text. Log only method+path+status.
466
- getMaxLogger().error(`[MAX API] ${method} ${path} → ${res.status}`);
467
- const error = new MaxApiError(
468
- `MAX API ${method} ${path} → ${res.status}`,
469
- res.status,
470
- json,
471
- );
498
+ // /subscriptions) or private message text. Log only method+path+status
499
+ // and the MAX error code (describeMaxErrorBody).
500
+ const detail = describeMaxErrorBody(json);
501
+ const summary = `${method} ${path} → ${res.status}${detail ? ` (${detail})` : ''}`;
502
+ getMaxLogger().error(`[MAX API] ${summary}`);
503
+ const error = new MaxApiError(`MAX API ${summary}`, res.status, json);
472
504
  const retryAfter = res.headers?.get?.('retry-after');
473
505
  if (retryAfter) {
474
506
  const seconds = Number(retryAfter);
@@ -769,10 +801,12 @@ export class MaxApi {
769
801
  );
770
802
 
771
803
  if (!uploadRes.ok) {
804
+ const text = await uploadRes.text().catch(() => null);
805
+ const detail = describeMaxErrorBody(text);
772
806
  throw new MaxApiError(
773
- `MAX media upload failed: ${uploadRes.status}`,
807
+ `MAX media upload failed: ${uploadRes.status}${detail ? ` (${detail})` : ''}`,
774
808
  uploadRes.status,
775
- await uploadRes.text().catch(() => null),
809
+ text,
776
810
  );
777
811
  }
778
812
 
@@ -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 {
@@ -72,10 +73,9 @@ function logSendFailure(
72
73
  what: string,
73
74
  err: unknown,
74
75
  ): void {
75
- const body = (err as { body?: unknown })?.body;
76
- log?.error(
77
- `[${account.accountId}] MAX ${what} failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`,
78
- );
76
+ // A MaxApiError message already carries the MAX error code; the response body
77
+ // stays out of the log, its free-form `message` may echo user data.
78
+ log?.error(`[${account.accountId}] MAX ${what} failed: ${String(err)}`);
79
79
  }
80
80
 
81
81
  export async function deliverMaxReply(params: {
@@ -110,6 +110,11 @@ export async function deliverMaxReply(params: {
110
110
  }): Promise<void> {
111
111
  const { payload, account, chatId, config, log, statusSink } = params;
112
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;
113
118
  const buttons = readMaxChannelButtons(payload.channelData);
114
119
  // channelData.max (notify, silent, disableLinkPreview) beats the account defaults.
115
120
  const sendOptions = resolveMaxSendFlags(account.config, { channelData: payload.channelData });
@@ -143,13 +148,13 @@ export async function deliverMaxReply(params: {
143
148
  if (answersCallback) {
144
149
  if (params.callbackState) params.callbackState.answered = true;
145
150
  try {
146
- await answerMaxCallback(params.callbackId as string, payload.text ?? '', {
151
+ await answerMaxCallback(params.callbackId as string, replyText ?? '', {
147
152
  token: account.token,
148
153
  format: 'markdown',
149
154
  buttons,
150
155
  });
151
156
  visible.callbackAnswered = true;
152
- if (payload.text) visible.texts.push(payload.text);
157
+ if (replyText) visible.texts.push(replyText);
153
158
  statusSink?.({ lastOutboundAt: Date.now() });
154
159
  } catch (err: unknown) {
155
160
  // Like a failed text chunk: the media still goes, then reject.
@@ -183,10 +188,10 @@ export async function deliverMaxReply(params: {
183
188
 
184
189
  if (answersCallback) {
185
190
  // The text went with the callback answer.
186
- } else if (payload.text) {
191
+ } else if (replyText) {
187
192
  const chunkLimit = resolveMaxTextChunkLimit(config, account.accountId);
188
193
  const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
189
- const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
194
+ const chunks = core.channel.text.chunkMarkdownTextWithMode(replyText, chunkLimit, chunkMode);
190
195
 
191
196
  for (let index = 0; index < chunks.length; index += 1) {
192
197
  const chunk = chunks[index];