@aspalagin/openclaw-max 0.8.2 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -3
- package/README_EN.md +38 -3
- package/SKILL.md +1 -1
- package/dist/src/accounts.js +1 -0
- package/dist/src/actions.js +8 -5
- package/dist/src/channel-agent-prompt.js +4 -3
- package/dist/src/channel-directory.js +5 -0
- package/dist/src/channel-outbound.js +14 -3
- package/dist/src/config-schema.js +5 -1
- package/dist/src/deliver.js +11 -5
- package/dist/src/format.js +170 -13
- package/dist/src/json-store-compat.js +26 -0
- package/dist/src/state.js +2 -1
- package/dist/src/stream-draft.js +6 -1
- package/dist/src/update-journal.js +2 -1
- package/openclaw.plugin.json +18 -6
- package/package.json +1 -1
- package/src/accounts.ts +4 -0
- package/src/actions.ts +12 -6
- package/src/channel-agent-prompt.ts +4 -3
- package/src/channel-directory.ts +5 -0
- package/src/channel-outbound.ts +19 -3
- package/src/config-schema.ts +5 -1
- package/src/deliver.ts +10 -4
- package/src/format.ts +196 -13
- package/src/json-store-compat.ts +28 -0
- package/src/state.ts +3 -1
- package/src/stream-draft.ts +6 -1
- package/src/update-journal.ts +3 -1
|
@@ -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 {
|
|
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, '_');
|
package/dist/src/stream-draft.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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
|
|
802
|
-
"help": "
|
|
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
|
|
1026
|
-
"help": "
|
|
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.
|
|
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:
|
|
3
|
-
* button types, presentation, voice, inline files, silent sends,
|
|
4
|
-
* roots and action scope for the message tool.
|
|
2
|
+
* Agent prompt hints of the MAX channel: text formatting, stickers, location,
|
|
3
|
+
* contact, pin, button types, presentation, voice, inline files, silent sends,
|
|
4
|
+
* local file roots and action scope for the message tool.
|
|
5
5
|
*/
|
|
6
6
|
|
|
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.',
|
package/src/channel-directory.ts
CHANGED
|
@@ -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. */
|
package/src/channel-outbound.ts
CHANGED
|
@@ -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 =
|
|
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,
|
|
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
|
package/src/config-schema.ts
CHANGED
|
@@ -84,7 +84,11 @@ export const MaxAccountSchemaBase = z
|
|
|
84
84
|
.object({
|
|
85
85
|
name: z.string().optional(),
|
|
86
86
|
enabled: z.boolean().optional(),
|
|
87
|
-
/**
|
|
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,
|
|
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 (
|
|
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 (
|
|
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(
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - bold, italic, strikethrough, code, ссылки — совпадают со стандартом.
|
|
4
|
+
* Что MAX разбирает сам (живая проба 10.10.2026, body.markup из GET /messages):
|
|
5
|
+
* `**`/`__` жирный, `*`/`_` курсив, `~~` зачёркнутый, `++` подчёркнутый,
|
|
6
|
+
* `^^` выделение, `код` и ```блоки```, ссылки, упоминания
|
|
7
|
+
* [Имя](max://user/user_id), `# заголовок` (один уровень; setext `===` тоже),
|
|
8
|
+
* `> цитата` и экранирование `\`. Это НЕ трогаем.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Что конвертируем (MAX показывает сырым или теряет):
|
|
11
|
+
* - pipe-таблицы — штатным convertMarkdownTables ядра (меняет только диапазоны
|
|
12
|
+
* таблиц). Без `markdown.tables` (умолчание) каждая таблица по своей ширине:
|
|
13
|
+
* узкая — моноширинным блоком, широкая — пунктами. Явный режим — как в ядре:
|
|
14
|
+
* bullets — пункт на ячейку, code — моноширинный блок, block → code
|
|
15
|
+
* (нативных таблиц в MAX нет), off — как есть;
|
|
16
|
+
* - `##`…`######` → `#`: уровни 2–6 MAX снимает без разметки;
|
|
17
|
+
* - горизонтальные линии `---`, `***`, `___` → `───`: MAX оставляет их сырыми,
|
|
18
|
+
* `* * *` превращает во вложенные списки, а `---` под строкой текста
|
|
19
|
+
* съедает вместе с её форматом (setext-заголовок второго уровня);
|
|
20
|
+
* - `==текст==` → `^^текст^^`;
|
|
21
|
+
* - `<u>…</u>` → `++…++`: подчёркивание тегом MAX в markdown не понимает.
|
|
22
|
+
*
|
|
23
|
+
* Заголовки и линии меняются только вне fenced-блоков, инлайн-замены — вне
|
|
24
|
+
* inline-кода, fenced-блоков и URL. Повторный вызов ничего не меняет, поэтому
|
|
25
|
+
* текст можно подготовить до разбиения на части, а send.ts пройдёт по каждой
|
|
26
|
+
* части ещё раз.
|
|
27
|
+
*/
|
|
28
|
+
|
|
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
|
-
/**
|
|
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(
|
|
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
|
|
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
|
+
}
|