@aspalagin/openclaw-max 0.8.2 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ import { promises as fs } from 'node:fs';
2
+ /**
3
+ * Local replacement for `readJsonFileWithFallback` from `openclaw/plugin-sdk/json-store`.
4
+ * OpenClaw 2026.10.5 dropped that export (only `loadJsonFile`, `saveJsonFile` and
5
+ * `writeJsonFileAtomically` remain), and a missing named export fails ESM linking for
6
+ * the whole plugin. Semantics match the 2026.9.x helper: a missing file yields the
7
+ * fallback with `exists: false`; an unreadable or invalid file yields the fallback with
8
+ * `exists: true`, so callers can warn and start empty.
9
+ */
10
+ export async function readJsonFileWithFallback(filePath, fallback) {
11
+ let raw;
12
+ try {
13
+ raw = await fs.readFile(filePath, 'utf8');
14
+ }
15
+ catch (err) {
16
+ const code = err?.code;
17
+ return { value: fallback, exists: code !== 'ENOENT' };
18
+ }
19
+ try {
20
+ const parsed = JSON.parse(raw);
21
+ return parsed == null ? { value: fallback, exists: true } : { value: parsed, exists: true };
22
+ }
23
+ catch {
24
+ return { value: fallback, exists: true };
25
+ }
26
+ }
package/dist/src/state.js CHANGED
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import * as fs from 'node:fs/promises';
11
11
  import * as path from 'node:path';
12
- import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
12
+ import { writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
13
+ import { readJsonFileWithFallback } from './json-store-compat.js';
13
14
  import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
14
15
  export function resolveMaxStatePath(accountId) {
15
16
  const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, '_');
@@ -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) {
@@ -18,7 +18,8 @@
18
18
  */
19
19
  import * as fs from 'node:fs/promises';
20
20
  import * as path from 'node:path';
21
- import { readJsonFileWithFallback, writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
21
+ import { writeJsonFileAtomically } from 'openclaw/plugin-sdk/json-store';
22
+ import { readJsonFileWithFallback } from './json-store-compat.js';
22
23
  import { resolveStateDir } from 'openclaw/plugin-sdk/state-paths';
23
24
  /** How long a handled update's key rejects a redelivery (MAX retries within 8 h). */
24
25
  export const MAX_JOURNAL_COMPLETED_TTL_MS = 24 * 60 * 60 * 1000;
@@ -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.2",
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.2",
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('');
@@ -0,0 +1,28 @@
1
+ import { promises as fs } from 'node:fs';
2
+
3
+ /**
4
+ * Local replacement for `readJsonFileWithFallback` from `openclaw/plugin-sdk/json-store`.
5
+ * OpenClaw 2026.10.5 dropped that export (only `loadJsonFile`, `saveJsonFile` and
6
+ * `writeJsonFileAtomically` remain), and a missing named export fails ESM linking for
7
+ * the whole plugin. Semantics match the 2026.9.x helper: a missing file yields the
8
+ * fallback with `exists: false`; an unreadable or invalid file yields the fallback with
9
+ * `exists: true`, so callers can warn and start empty.
10
+ */
11
+ export async function readJsonFileWithFallback<T>(
12
+ filePath: string,
13
+ fallback: T,
14
+ ): Promise<{ value: T; exists: boolean }> {
15
+ let raw: string;
16
+ try {
17
+ raw = await fs.readFile(filePath, 'utf8');
18
+ } catch (err) {
19
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
20
+ return { value: fallback, exists: code !== 'ENOENT' };
21
+ }
22
+ try {
23
+ const parsed = JSON.parse(raw) as T | null;
24
+ return parsed == null ? { value: fallback, exists: true } : { value: parsed, exists: true };
25
+ } catch {
26
+ return { value: fallback, exists: true };
27
+ }
28
+ }