@aspalagin/openclaw-max 0.7.3 → 0.8.1
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/LICENSE +2 -0
- package/README.md +412 -344
- package/README_EN.md +492 -0
- package/SKILL.md +45 -37
- package/assets/icon.png +0 -0
- package/dist/secret-contract-api.js +6 -0
- package/dist/src/access-policy.js +123 -0
- package/dist/src/accounts.js +170 -23
- package/dist/src/action-scope.js +213 -0
- package/dist/src/actions.js +159 -31
- package/dist/src/api.js +99 -83
- package/dist/src/callbacks.js +27 -7
- package/dist/src/channel-agent-prompt.js +8 -19
- package/dist/src/channel-config.js +25 -3
- package/dist/src/channel-lifecycle.js +28 -5
- package/dist/src/channel-outbound.js +43 -9
- package/dist/src/channel-policy.js +19 -6
- package/dist/src/channel.js +16 -0
- package/dist/src/command-menu.js +199 -0
- package/dist/src/config-schema.js +106 -10
- package/dist/src/deliver.js +144 -48
- package/dist/src/dispatch.js +12 -11
- package/dist/src/gateway-drain.js +40 -0
- package/dist/src/group-mention.js +129 -0
- package/dist/src/inbound-attachments.js +162 -20
- package/dist/src/inbound.js +388 -134
- package/dist/src/media-temp.js +31 -20
- package/dist/src/network.js +216 -0
- package/dist/src/onboarding.js +4 -2
- package/dist/src/polling.js +180 -12
- package/dist/src/presentation.js +11 -0
- package/dist/src/progress-draft.js +168 -0
- package/dist/src/runtime.js +15 -0
- package/dist/src/secret-contract.js +149 -0
- package/dist/src/send.js +250 -46
- package/dist/src/stream-draft.js +83 -41
- package/dist/src/turn-adoption.js +24 -0
- package/dist/src/typing.js +55 -0
- package/dist/src/update-journal.js +267 -0
- package/dist/src/webhook-queue.js +151 -26
- package/dist/src/webhook-runner.js +61 -9
- package/dist/src/webhook.js +34 -15
- package/openclaw.plugin.json +983 -8
- package/package.json +17 -4
- package/src/access-policy.ts +154 -0
- package/src/accounts.ts +260 -25
- package/src/action-scope.ts +274 -0
- package/src/actions.ts +188 -35
- package/src/api.ts +158 -101
- package/src/callbacks.ts +26 -7
- package/src/channel-agent-prompt.ts +8 -19
- package/src/channel-config.ts +32 -3
- package/src/channel-lifecycle.ts +33 -6
- package/src/channel-outbound.ts +50 -7
- package/src/channel-policy.ts +27 -7
- package/src/channel.ts +16 -0
- package/src/command-menu.ts +277 -0
- package/src/config-schema.ts +111 -10
- package/src/deliver.ts +174 -55
- package/src/dispatch.ts +12 -11
- package/src/gateway-drain.ts +45 -0
- package/src/group-mention.ts +191 -0
- package/src/inbound-attachments.ts +173 -22
- package/src/inbound.ts +443 -130
- package/src/media-temp.ts +35 -23
- package/src/network.ts +233 -0
- package/src/onboarding.ts +4 -2
- package/src/polling.ts +221 -15
- package/src/presentation.ts +14 -1
- package/src/progress-draft.ts +226 -0
- package/src/runtime.ts +23 -0
- package/src/secret-contract.ts +183 -0
- package/src/send.ts +346 -59
- package/src/stream-draft.ts +102 -41
- package/src/turn-adoption.ts +33 -0
- package/src/types.ts +9 -3
- package/src/typing.ts +77 -0
- package/src/update-journal.ts +302 -0
- package/src/webhook-queue.ts +205 -33
- package/src/webhook-runner.ts +65 -8
- package/src/webhook.ts +39 -18
- package/scripts/check-cycles.mjs +0 -38
- package/scripts/test-api.mjs +0 -169
- package/scripts/test-send.mjs +0 -162
- package/scripts/update-schema.mjs +0 -48
package/dist/src/deliver.js
CHANGED
|
@@ -1,77 +1,163 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reply delivery for the inbound pipeline: callback answers, markdown chunks
|
|
3
|
-
* with the keyboard on the last one
|
|
3
|
+
* with the keyboard on the last one (the first chunk may fill the stream
|
|
4
|
+
* draft), media albums and delivery.pin. Failures are rejected per the SDK
|
|
5
|
+
* delivery contract instead of being swallowed.
|
|
4
6
|
*/
|
|
5
|
-
import {
|
|
7
|
+
import { createChannelPartialDeliveryError } from 'openclaw/plugin-sdk/channel-inbound';
|
|
8
|
+
import { PlatformMessageNotDispatchedError } from 'openclaw/plugin-sdk/error-runtime';
|
|
9
|
+
import { LocalMediaAccessError } from 'openclaw/plugin-sdk/web-media';
|
|
10
|
+
import { MaxApiError } from './api.js';
|
|
11
|
+
import { readMaxDeliveryPin, resolveMaxTextChunkLimit } from './presentation.js';
|
|
6
12
|
import { getMaxRuntime } from './runtime.js';
|
|
7
|
-
import { answerMaxCallback, pinMaxMessage, readMaxChannelButtons,
|
|
13
|
+
import { answerMaxCallback, pinMaxMessage, readMaxChannelButtons, resolveMaxSendFlags, sendMaxMediaGroup, sendMaxMessage, } from './send.js';
|
|
14
|
+
/**
|
|
15
|
+
* Delivery failure per the SDK contract: once anything became visible (a
|
|
16
|
+
* chunk, an album, the stream draft) the error keeps that subset
|
|
17
|
+
* (createChannelPartialDeliveryError); a send MAX refused outright (4xx)
|
|
18
|
+
* proves nothing was dispatched (PlatformMessageNotDispatchedError, retryable
|
|
19
|
+
* only for 429), as does a local file refused by the media access policy;
|
|
20
|
+
* anything else (5xx, timeout, network) may have been applied and is
|
|
21
|
+
* rethrown as is.
|
|
22
|
+
* @internal exported for testing.
|
|
23
|
+
*/
|
|
24
|
+
export function toMaxDeliveryError(err, visible) {
|
|
25
|
+
if (visible.messageIds.length > 0 || visible.callbackAnswered) {
|
|
26
|
+
const content = visible.texts.join('\n\n');
|
|
27
|
+
return createChannelPartialDeliveryError(err, {
|
|
28
|
+
visibleReplySent: true,
|
|
29
|
+
messageIds: [...visible.messageIds],
|
|
30
|
+
...(content ? { content } : {}),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (err instanceof MaxApiError && err.status >= 400 && err.status < 500) {
|
|
34
|
+
return new PlatformMessageNotDispatchedError(err.message, {
|
|
35
|
+
cause: err,
|
|
36
|
+
retryable: err.status === 429,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
if (err instanceof LocalMediaAccessError) {
|
|
40
|
+
return new PlatformMessageNotDispatchedError(err.message, { cause: err, retryable: false });
|
|
41
|
+
}
|
|
42
|
+
return err;
|
|
43
|
+
}
|
|
44
|
+
function logSendFailure(account, log, what, err) {
|
|
45
|
+
const body = err?.body;
|
|
46
|
+
log?.error(`[${account.accountId}] MAX ${what} failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
|
|
47
|
+
}
|
|
8
48
|
export async function deliverMaxReply(params) {
|
|
9
49
|
const { payload, account, chatId, config, log, statusSink } = params;
|
|
10
50
|
const core = getMaxRuntime();
|
|
11
51
|
const buttons = readMaxChannelButtons(payload.channelData);
|
|
12
|
-
|
|
13
|
-
|
|
52
|
+
// channelData.max (notify, silent, disableLinkPreview) beats the account defaults.
|
|
53
|
+
const sendOptions = resolveMaxSendFlags(account.config, { channelData: payload.channelData });
|
|
54
|
+
// Tool summaries (verbose mode) quote paths and file names MAX would preview
|
|
55
|
+
// as links (`notes.md` reads as a domain); an explicit disableLinkPreview wins.
|
|
56
|
+
if (params.kind === 'tool' && sendOptions.disableLinkPreview === undefined) {
|
|
57
|
+
sendOptions.disableLinkPreview = true;
|
|
58
|
+
}
|
|
59
|
+
// A draft send still in flight settles first, so it is replaced, not orphaned.
|
|
60
|
+
if (params.draft)
|
|
61
|
+
await params.draft.settle();
|
|
62
|
+
// Only the first text final replaces the draft: once it carries a final
|
|
63
|
+
// text, later final payloads of the turn go as new messages.
|
|
64
|
+
const draftMid = payload.text && !params.draft?.finalized ? (params.draft?.messageId ?? undefined) : undefined;
|
|
65
|
+
// Everything the recipient can already see; a visible stream draft counts
|
|
66
|
+
// until it is replaced or deleted.
|
|
67
|
+
const visible = { messageIds: draftMid ? [draftMid] : [], texts: [] };
|
|
68
|
+
let failure;
|
|
69
|
+
// A button press is answered with the text and keyboard (POST /answers);
|
|
70
|
+
// the reply's media still follows in the chat below.
|
|
71
|
+
const answersCallback = Boolean(params.callbackId &&
|
|
72
|
+
!params.callbackState?.answered &&
|
|
73
|
+
// A finalized draft carries the answer: the pressed message stays as is.
|
|
74
|
+
!params.draft?.finalized &&
|
|
75
|
+
!draftMid &&
|
|
76
|
+
(payload.text || buttons?.length));
|
|
77
|
+
if (answersCallback) {
|
|
78
|
+
if (params.callbackState)
|
|
79
|
+
params.callbackState.answered = true;
|
|
14
80
|
try {
|
|
15
81
|
await answerMaxCallback(params.callbackId, payload.text ?? '', {
|
|
16
82
|
token: account.token,
|
|
17
83
|
format: 'markdown',
|
|
18
84
|
buttons,
|
|
19
85
|
});
|
|
86
|
+
visible.callbackAnswered = true;
|
|
87
|
+
if (payload.text)
|
|
88
|
+
visible.texts.push(payload.text);
|
|
20
89
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
21
90
|
}
|
|
22
91
|
catch (err) {
|
|
23
|
-
|
|
24
|
-
|
|
92
|
+
// Like a failed text chunk: the media still goes, then reject.
|
|
93
|
+
logSendFailure(account, log, 'callback answer', err);
|
|
94
|
+
failure = { err };
|
|
25
95
|
}
|
|
26
|
-
return;
|
|
27
96
|
}
|
|
28
97
|
// delivery.pin: pin the first delivered message (first chunk).
|
|
29
98
|
let firstMessageId;
|
|
30
99
|
let firstChatType;
|
|
31
|
-
const noteDelivered = (messageId, chatType) => {
|
|
100
|
+
const noteDelivered = (messageId, chatType, text) => {
|
|
101
|
+
if (messageId && !visible.messageIds.includes(messageId))
|
|
102
|
+
visible.messageIds.push(messageId);
|
|
103
|
+
if (text)
|
|
104
|
+
visible.texts.push(text);
|
|
32
105
|
if (!firstMessageId && messageId) {
|
|
33
106
|
firstMessageId = messageId;
|
|
34
107
|
firstChatType = chatType;
|
|
35
108
|
}
|
|
36
109
|
};
|
|
37
|
-
|
|
38
|
-
const
|
|
110
|
+
const sendTextMessage = async (text, messageButtons) => {
|
|
111
|
+
const sent = await sendMaxMessage(chatId, text, {
|
|
112
|
+
token: account.token,
|
|
113
|
+
replyToMessageId: params.replyToId,
|
|
114
|
+
format: 'markdown',
|
|
115
|
+
buttons: messageButtons,
|
|
116
|
+
...sendOptions,
|
|
117
|
+
});
|
|
118
|
+
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type, text);
|
|
119
|
+
statusSink?.({ lastOutboundAt: Date.now() });
|
|
120
|
+
};
|
|
121
|
+
if (answersCallback) {
|
|
122
|
+
// The text went with the callback answer.
|
|
123
|
+
}
|
|
124
|
+
else if (payload.text) {
|
|
125
|
+
const chunkLimit = resolveMaxTextChunkLimit(config, account.accountId);
|
|
39
126
|
const chunkMode = core.channel.text.resolveChunkMode(config, 'max', account.accountId);
|
|
40
127
|
const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode);
|
|
41
128
|
for (let index = 0; index < chunks.length; index += 1) {
|
|
42
129
|
const chunk = chunks[index];
|
|
130
|
+
const chunkButtons = index === chunks.length - 1 ? buttons : undefined;
|
|
131
|
+
if (index === 0 && draftMid && params.draft) {
|
|
132
|
+
// The draft becomes the first chunk. If MAX refuses that edit, the
|
|
133
|
+
// stale partial draft is deleted and the answer goes as new messages.
|
|
134
|
+
if (await params.draft.finalize(chunk, chunkButtons)) {
|
|
135
|
+
noteDelivered(draftMid, undefined, chunk);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (await params.draft.discard()) {
|
|
139
|
+
visible.messageIds.splice(visible.messageIds.indexOf(draftMid), 1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
43
142
|
try {
|
|
44
|
-
|
|
45
|
-
token: account.token,
|
|
46
|
-
replyToMessageId: params.replyToId,
|
|
47
|
-
format: 'markdown',
|
|
48
|
-
buttons: index === chunks.length - 1 ? buttons : undefined,
|
|
49
|
-
...sendOptions,
|
|
50
|
-
});
|
|
51
|
-
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
|
|
52
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
143
|
+
await sendTextMessage(chunk, chunkButtons);
|
|
53
144
|
}
|
|
54
145
|
catch (err) {
|
|
55
|
-
|
|
56
|
-
|
|
146
|
+
// A later chunk after a gap would garble the answer: stop the text,
|
|
147
|
+
// still send the media (independent content), then reject.
|
|
148
|
+
logSendFailure(account, log, 'send', err);
|
|
149
|
+
failure = { err };
|
|
150
|
+
break;
|
|
57
151
|
}
|
|
58
152
|
}
|
|
59
153
|
}
|
|
60
154
|
else if (buttons?.length) {
|
|
61
155
|
try {
|
|
62
|
-
|
|
63
|
-
token: account.token,
|
|
64
|
-
replyToMessageId: params.replyToId,
|
|
65
|
-
format: 'markdown',
|
|
66
|
-
buttons,
|
|
67
|
-
...sendOptions,
|
|
68
|
-
});
|
|
69
|
-
noteDelivered(sent.messageId, sent.raw.message?.recipient?.chat_type);
|
|
70
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
156
|
+
await sendTextMessage('', buttons);
|
|
71
157
|
}
|
|
72
158
|
catch (err) {
|
|
73
|
-
|
|
74
|
-
|
|
159
|
+
logSendFailure(account, log, 'send', err);
|
|
160
|
+
failure = { err };
|
|
75
161
|
}
|
|
76
162
|
}
|
|
77
163
|
// Media URLs — upload and send
|
|
@@ -80,22 +166,41 @@ export async function deliverMaxReply(params) {
|
|
|
80
166
|
: payload.mediaUrl
|
|
81
167
|
? [payload.mediaUrl]
|
|
82
168
|
: [];
|
|
83
|
-
// Images/videos go as albums (up to 12 per message); https image
|
|
84
|
-
// sent by URL, other remote media is downloaded
|
|
169
|
+
// Images/videos go as albums (up to 12 per message); public https image
|
|
170
|
+
// links are sent by URL, other remote media is downloaded (SSRF-guarded)
|
|
171
|
+
// and uploaded, local files are read only under the agent's media roots.
|
|
172
|
+
// Voice replies: the text (if any) went above as its own message(s), the
|
|
173
|
+
// audio follows without a caption; the text is never taken from the
|
|
174
|
+
// spoken text, so it reaches the chat once.
|
|
175
|
+
const asVoice = payload.audioAsVoice === true;
|
|
85
176
|
if (mediaList.length) {
|
|
86
177
|
const sent = await sendMaxMediaGroup(chatId, '', mediaList, {
|
|
87
178
|
token: account.token,
|
|
88
179
|
replyToMessageId: params.replyToId,
|
|
89
180
|
mediaMaxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
|
|
181
|
+
localMedia: params.localMedia,
|
|
182
|
+
asVoice,
|
|
90
183
|
...sendOptions,
|
|
91
|
-
onError: (err, failed) =>
|
|
184
|
+
onError: (err, failed) => {
|
|
185
|
+
log?.error(`[${account.accountId}] MAX media send failed (${failed.length} item(s)): ${String(err)}`);
|
|
186
|
+
failure ??= { err };
|
|
187
|
+
},
|
|
92
188
|
});
|
|
93
189
|
for (const id of sent.messageIds)
|
|
94
190
|
noteDelivered(id);
|
|
95
191
|
if (sent.messageIds.length)
|
|
96
192
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
193
|
+
// A voice-only reply (no visible text) leaves a partial stream draft of
|
|
194
|
+
// the same answer behind: once the audio is delivered the draft goes; a
|
|
195
|
+
// draft that already carries a final text stays.
|
|
196
|
+
const draft = params.draft;
|
|
197
|
+
if (asVoice && !payload.text && sent.messageIds.length && draft?.messageId) {
|
|
198
|
+
if (!draft.finalized)
|
|
199
|
+
await draft.discard();
|
|
200
|
+
}
|
|
97
201
|
}
|
|
98
|
-
|
|
202
|
+
// The message a callback answer changed has no id here to pin.
|
|
203
|
+
const pin = answersCallback ? undefined : readMaxDeliveryPin(payload.delivery);
|
|
99
204
|
if (pin && firstMessageId) {
|
|
100
205
|
try {
|
|
101
206
|
const pinned = await pinMaxMessage(chatId, firstMessageId, {
|
|
@@ -112,15 +217,6 @@ export async function deliverMaxReply(params) {
|
|
|
112
217
|
log?.[pin.required ? 'error' : 'warn'](`[${account.accountId}] MAX pin of ${firstMessageId} failed: ${String(err)}`);
|
|
113
218
|
}
|
|
114
219
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
export function withoutMaxButtons(channelData) {
|
|
118
|
-
if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData))
|
|
119
|
-
return channelData;
|
|
120
|
-
const maxData = channelData.max;
|
|
121
|
-
if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData))
|
|
122
|
-
return channelData;
|
|
123
|
-
const rest = { ...maxData };
|
|
124
|
-
delete rest.buttons;
|
|
125
|
-
return { ...channelData, max: rest };
|
|
220
|
+
if (failure)
|
|
221
|
+
throw toMaxDeliveryError(failure.err, visible);
|
|
126
222
|
}
|
package/dist/src/dispatch.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* events to the chat registry.
|
|
5
5
|
*/
|
|
6
6
|
import { processCallback } from './callbacks.js';
|
|
7
|
-
import { processIncomingMessage } from './inbound.js';
|
|
7
|
+
import { describeMaxLogText, processIncomingMessage } from './inbound.js';
|
|
8
8
|
/** mark_seen vanished from current MAX docs; keep behind config (default on). */
|
|
9
9
|
function shouldMarkSeen(account) {
|
|
10
10
|
return account.config.markSeen !== false;
|
|
@@ -18,8 +18,8 @@ function sendReadReceipt(chatId, opts) {
|
|
|
18
18
|
log?.debug?.(`[${account.accountId}] mark_seen failed: ${String(err)}`);
|
|
19
19
|
});
|
|
20
20
|
}
|
|
21
|
-
// typing_on is
|
|
22
|
-
//
|
|
21
|
+
// typing_on is kept up by processIncomingMessage for the agent turn — only
|
|
22
|
+
// for messages that pass the group/DM gates.
|
|
23
23
|
}
|
|
24
24
|
/** Route one update by update_type (shared by the polling loop and the webhook queue). */
|
|
25
25
|
export async function dispatchUpdate(update, opts) {
|
|
@@ -59,22 +59,23 @@ export async function dispatchUpdate(update, opts) {
|
|
|
59
59
|
break;
|
|
60
60
|
}
|
|
61
61
|
case 'message_edited': {
|
|
62
|
-
|
|
62
|
+
// An edit always carries the edited body; a forward-only message
|
|
63
|
+
// (body null) cannot be edited.
|
|
64
|
+
if (!update.message?.body)
|
|
63
65
|
break;
|
|
64
66
|
// Skip edits from the bot itself
|
|
65
67
|
if (opts.botUserId && update.message.sender?.user_id === opts.botUserId)
|
|
66
68
|
break;
|
|
67
|
-
log?.debug?.(`[${account.accountId}] Message edited: ${update.message?.body?.mid}
|
|
69
|
+
log?.debug?.(`[${account.accountId}] Message edited: ${update.message?.body?.mid} ${describeMaxLogText(account, update.message?.body?.text)} hasBody=${!!update.message?.body}`);
|
|
68
70
|
statusSink?.({ lastInboundAt: Date.now() });
|
|
69
71
|
// Mark as read (typing starts once the message reaches the agent)
|
|
70
72
|
sendReadReceipt(update.message.recipient?.chat_id, opts);
|
|
71
73
|
// Process edited message through the same pipeline as new messages.
|
|
72
74
|
// Use a unique mid suffix to avoid OpenClaw dedup (same mid = skipped).
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
...
|
|
77
|
-
mid: `${originalMid}_edited_${update.timestamp}`,
|
|
75
|
+
const originalMid = update.message.body.mid;
|
|
76
|
+
const editedMessage = {
|
|
77
|
+
...update.message,
|
|
78
|
+
body: { ...update.message.body, mid: `${originalMid}_edited_${update.timestamp}` },
|
|
78
79
|
};
|
|
79
80
|
// MAX message_edited may not include text — fetch it from API if missing
|
|
80
81
|
if (!editedMessage.body.text?.trim() && originalMid) {
|
|
@@ -91,7 +92,7 @@ export async function dispatchUpdate(update, opts) {
|
|
|
91
92
|
if (fetchedMsg.body.attachments?.length) {
|
|
92
93
|
editedMessage.body.attachments = fetchedMsg.body.attachments;
|
|
93
94
|
}
|
|
94
|
-
log?.debug?.(`[${account.accountId}] Fetched edited text:
|
|
95
|
+
log?.debug?.(`[${account.accountId}] Fetched edited text: ${describeMaxLogText(account, fetchedMsg.body.text)}`);
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core's refusal of new turns while the gateway drains for a stop or restart.
|
|
3
|
+
*
|
|
4
|
+
* During a graceful stop core keeps the channels running (up to its drain
|
|
5
|
+
* budget, minutes) but refuses to start new work with `GatewayDrainingError`.
|
|
6
|
+
* Refused before core adopted the turn, the update was never handled: the
|
|
7
|
+
* webhook queue and the polling loop keep it and retry after a pause until the
|
|
8
|
+
* channel stops; its journal row then stays on disk for the next process. The
|
|
9
|
+
* plugin SDK exports nothing for this error, so it is recognized by name.
|
|
10
|
+
*/
|
|
11
|
+
import { sleepWithAbort } from 'openclaw/plugin-sdk/runtime-env';
|
|
12
|
+
const DRAINING_ERROR_NAME = 'GatewayDrainingError';
|
|
13
|
+
/** Causes followed below the error itself. */
|
|
14
|
+
const MAX_CAUSE_DEPTH = 5;
|
|
15
|
+
/** Pauses between retries of a refused update: 2 s, 5 s, 10 s, then every 30 s. */
|
|
16
|
+
const DRAIN_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 30_000];
|
|
17
|
+
/** True when `err` or one of its causes (up to 5 deep) is core's drain refusal. */
|
|
18
|
+
export function isMaxGatewayDrainingError(err) {
|
|
19
|
+
let current = err;
|
|
20
|
+
for (let depth = 0; depth <= MAX_CAUSE_DEPTH; depth += 1) {
|
|
21
|
+
if (typeof current !== 'object' || current === null)
|
|
22
|
+
return false;
|
|
23
|
+
if (current.name === DRAINING_ERROR_NAME)
|
|
24
|
+
return true;
|
|
25
|
+
current = current.cause;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
/** Pause before the retry after the `refusals`-th refusal in a row (1-based). */
|
|
30
|
+
export function maxDrainRetryDelayMs(refusals) {
|
|
31
|
+
const index = Math.min(Math.max(refusals, 1), DRAIN_RETRY_DELAYS_MS.length) - 1;
|
|
32
|
+
return DRAIN_RETRY_DELAYS_MS[index];
|
|
33
|
+
}
|
|
34
|
+
/** Waits out the pause after a refusal; false when the channel stopped meanwhile (at once). */
|
|
35
|
+
export async function waitForMaxDrainRetry(refusals, abortSignal) {
|
|
36
|
+
if (abortSignal.aborted)
|
|
37
|
+
return false;
|
|
38
|
+
await sleepWithAbort(maxDrainRetryDelayMs(refusals), abortSignal).catch(() => undefined);
|
|
39
|
+
return !abortSignal.aborted;
|
|
40
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group mention facts beyond the bot's native mention: core's configured
|
|
3
|
+
* mention patterns (messages.groupChat / agent groupChat mentionPatterns),
|
|
4
|
+
* also matched against the transcript of a captionless voice message — MAX's
|
|
5
|
+
* own transcript, else a core preflight transcription.
|
|
6
|
+
*/
|
|
7
|
+
import { resolveAgentConfig } from 'openclaw/plugin-sdk/agent-scope-runtime';
|
|
8
|
+
import { buildMentionRegexes, matchesMentionPatterns, toInboundMediaFacts, } from 'openclaw/plugin-sdk/channel-inbound';
|
|
9
|
+
import { createChannelPreflightAudio, formatAudioTranscriptForAgent, } from 'openclaw/plugin-sdk/media-understanding-runtime';
|
|
10
|
+
import { admitMaxGroupSender } from './access-policy.js';
|
|
11
|
+
import { collectInboundAttachments, readAudioTranscription, } from './inbound-attachments.js';
|
|
12
|
+
const maxPreflightAudio = createChannelPreflightAudio({
|
|
13
|
+
channel: 'max',
|
|
14
|
+
isAudio: (media) => media.kind === 'audio',
|
|
15
|
+
});
|
|
16
|
+
/**
|
|
17
|
+
* Only explicitly configured patterns count: without them core derives
|
|
18
|
+
* patterns from the agent's identity name and emoji, which would start waking
|
|
19
|
+
* the bot in MAX groups that so far needed a real mention.
|
|
20
|
+
*/
|
|
21
|
+
function hasConfiguredMentionPatterns(cfg, agentId) {
|
|
22
|
+
const agentGroupChat = resolveAgentConfig(cfg, agentId)?.groupChat;
|
|
23
|
+
if (agentGroupChat && Object.hasOwn(agentGroupChat, 'mentionPatterns'))
|
|
24
|
+
return true;
|
|
25
|
+
const groupChat = cfg.messages?.groupChat;
|
|
26
|
+
return Boolean(groupChat && Object.hasOwn(groupChat, 'mentionPatterns'));
|
|
27
|
+
}
|
|
28
|
+
/** Core mention regexes for this group, after the channel's mentionPatterns policy. */
|
|
29
|
+
export function resolveMaxMentionRegexes(params) {
|
|
30
|
+
const { cfg, agentId, account, chatId } = params;
|
|
31
|
+
if (!hasConfiguredMentionPatterns(cfg, agentId))
|
|
32
|
+
return [];
|
|
33
|
+
return buildMentionRegexes(cfg, agentId, {
|
|
34
|
+
provider: 'max',
|
|
35
|
+
conversationId: chatId,
|
|
36
|
+
...(account.config.mentionPatterns ? { providerPolicy: account.config.mentionPatterns } : {}),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Transcribe the first audio of the message with core media understanding
|
|
41
|
+
* (tools.media.audio). Returns the transcript and the index of the input core
|
|
42
|
+
* transcribed; undefined when there is no audio, STT is off or it fails.
|
|
43
|
+
*/
|
|
44
|
+
export async function transcribeMaxVoiceForMention(params) {
|
|
45
|
+
const ctx = {
|
|
46
|
+
Provider: 'max',
|
|
47
|
+
Surface: 'max',
|
|
48
|
+
OriginatingChannel: 'max',
|
|
49
|
+
OriginatingTo: `max:${params.chatId}`,
|
|
50
|
+
AccountId: params.accountId,
|
|
51
|
+
ChatType: 'group',
|
|
52
|
+
media: toInboundMediaFacts(params.mediaInputs),
|
|
53
|
+
};
|
|
54
|
+
const transcript = await maxPreflightAudio.resolve({ request: { ctx, cfg: params.cfg } });
|
|
55
|
+
if (!transcript)
|
|
56
|
+
return undefined;
|
|
57
|
+
// Core marks the fact it transcribed on the request context.
|
|
58
|
+
const media = (ctx.media ?? []);
|
|
59
|
+
const marked = media.findIndex((fact, index) => fact?.transcribed === true && params.mediaInputs[index]?.transcribed !== true);
|
|
60
|
+
const index = marked >= 0 ? marked : params.mediaInputs.findIndex((input) => input.kind === 'audio');
|
|
61
|
+
return { transcript, index };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Mention check for a captionless voice message in a mention-gated group
|
|
65
|
+
* whose chat already passed the group policy. MAX's own transcript is matched
|
|
66
|
+
* as is; otherwise, for senders the group admits, the audio is downloaded
|
|
67
|
+
* (mediaMaxMb, mediaMaxCount) and transcribed once by core. The transcript
|
|
68
|
+
* then goes to the agent as core formats it, and the audio fact is marked
|
|
69
|
+
* transcribed so the reply pipeline does not transcribe it again. No match,
|
|
70
|
+
* no audio or any failure: undefined ("not mentioned").
|
|
71
|
+
*/
|
|
72
|
+
export async function resolveMaxVoiceMention(params) {
|
|
73
|
+
const { attachments, mentionRegexes, account, log } = params;
|
|
74
|
+
const audio = attachments.find((att) => att.type === 'audio');
|
|
75
|
+
if (!audio || mentionRegexes.length === 0)
|
|
76
|
+
return undefined;
|
|
77
|
+
const platformTranscript = readAudioTranscription(audio);
|
|
78
|
+
if (platformTranscript) {
|
|
79
|
+
return matchesMentionPatterns(platformTranscript, mentionRegexes) ? {} : undefined;
|
|
80
|
+
}
|
|
81
|
+
// The inbound gate admitted the sender already; the shared rule again, so
|
|
82
|
+
// no caller transcribes for a sender outside the group's allowlist.
|
|
83
|
+
if (!admitMaxGroupSender(account, params.chatId, params.senderId).admitted) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const collected = await collectInboundAttachments({
|
|
88
|
+
attachments,
|
|
89
|
+
messageId: params.messageId,
|
|
90
|
+
chatId: params.chatId,
|
|
91
|
+
api: params.api,
|
|
92
|
+
account,
|
|
93
|
+
log,
|
|
94
|
+
});
|
|
95
|
+
if (!collected.mediaInputs.some((input) => input.kind === 'audio' && !input.transcribed)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const result = await transcribeMaxVoiceForMention({
|
|
99
|
+
cfg: params.cfg,
|
|
100
|
+
accountId: account.accountId,
|
|
101
|
+
chatId: params.chatIdStr,
|
|
102
|
+
mediaInputs: collected.mediaInputs,
|
|
103
|
+
});
|
|
104
|
+
if (!result || !matchesMentionPatterns(result.transcript, mentionRegexes))
|
|
105
|
+
return undefined;
|
|
106
|
+
const mediaInputs = collected.mediaInputs.map((input, index) => index === result.index ? { ...input, transcribed: true } : input);
|
|
107
|
+
return {
|
|
108
|
+
attachments: {
|
|
109
|
+
...collected,
|
|
110
|
+
mediaInputs,
|
|
111
|
+
descriptions: [...collected.descriptions, formatAudioTranscriptForAgent(result.transcript)],
|
|
112
|
+
},
|
|
113
|
+
transcript: result.transcript,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
log?.debug?.(`[${account.accountId}] Voice mention check failed: ${String(err)}`);
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Core's deferred transcript echo (tools.media.audio.echoTranscript), after the gate. */
|
|
122
|
+
export async function echoMaxVoiceTranscript(params) {
|
|
123
|
+
await maxPreflightAudio.send({
|
|
124
|
+
transcript: params.transcript,
|
|
125
|
+
cfg: params.cfg,
|
|
126
|
+
accountId: params.accountId,
|
|
127
|
+
originatingTo: `max:${params.chatId}`,
|
|
128
|
+
});
|
|
129
|
+
}
|