@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.
Files changed (85) hide show
  1. package/LICENSE +2 -0
  2. package/README.md +412 -344
  3. package/README_EN.md +492 -0
  4. package/SKILL.md +45 -37
  5. package/assets/icon.png +0 -0
  6. package/dist/secret-contract-api.js +6 -0
  7. package/dist/src/access-policy.js +123 -0
  8. package/dist/src/accounts.js +170 -23
  9. package/dist/src/action-scope.js +213 -0
  10. package/dist/src/actions.js +159 -31
  11. package/dist/src/api.js +99 -83
  12. package/dist/src/callbacks.js +27 -7
  13. package/dist/src/channel-agent-prompt.js +8 -19
  14. package/dist/src/channel-config.js +25 -3
  15. package/dist/src/channel-lifecycle.js +28 -5
  16. package/dist/src/channel-outbound.js +43 -9
  17. package/dist/src/channel-policy.js +19 -6
  18. package/dist/src/channel.js +16 -0
  19. package/dist/src/command-menu.js +199 -0
  20. package/dist/src/config-schema.js +106 -10
  21. package/dist/src/deliver.js +144 -48
  22. package/dist/src/dispatch.js +12 -11
  23. package/dist/src/gateway-drain.js +40 -0
  24. package/dist/src/group-mention.js +129 -0
  25. package/dist/src/inbound-attachments.js +162 -20
  26. package/dist/src/inbound.js +388 -134
  27. package/dist/src/media-temp.js +31 -20
  28. package/dist/src/network.js +216 -0
  29. package/dist/src/onboarding.js +4 -2
  30. package/dist/src/polling.js +180 -12
  31. package/dist/src/presentation.js +11 -0
  32. package/dist/src/progress-draft.js +168 -0
  33. package/dist/src/runtime.js +15 -0
  34. package/dist/src/secret-contract.js +149 -0
  35. package/dist/src/send.js +250 -46
  36. package/dist/src/stream-draft.js +83 -41
  37. package/dist/src/turn-adoption.js +24 -0
  38. package/dist/src/typing.js +55 -0
  39. package/dist/src/update-journal.js +267 -0
  40. package/dist/src/webhook-queue.js +151 -26
  41. package/dist/src/webhook-runner.js +61 -9
  42. package/dist/src/webhook.js +34 -15
  43. package/openclaw.plugin.json +983 -8
  44. package/package.json +17 -4
  45. package/src/access-policy.ts +154 -0
  46. package/src/accounts.ts +260 -25
  47. package/src/action-scope.ts +274 -0
  48. package/src/actions.ts +188 -35
  49. package/src/api.ts +158 -101
  50. package/src/callbacks.ts +26 -7
  51. package/src/channel-agent-prompt.ts +8 -19
  52. package/src/channel-config.ts +32 -3
  53. package/src/channel-lifecycle.ts +33 -6
  54. package/src/channel-outbound.ts +50 -7
  55. package/src/channel-policy.ts +27 -7
  56. package/src/channel.ts +16 -0
  57. package/src/command-menu.ts +277 -0
  58. package/src/config-schema.ts +111 -10
  59. package/src/deliver.ts +174 -55
  60. package/src/dispatch.ts +12 -11
  61. package/src/gateway-drain.ts +45 -0
  62. package/src/group-mention.ts +191 -0
  63. package/src/inbound-attachments.ts +173 -22
  64. package/src/inbound.ts +443 -130
  65. package/src/media-temp.ts +35 -23
  66. package/src/network.ts +233 -0
  67. package/src/onboarding.ts +4 -2
  68. package/src/polling.ts +221 -15
  69. package/src/presentation.ts +14 -1
  70. package/src/progress-draft.ts +226 -0
  71. package/src/runtime.ts +23 -0
  72. package/src/secret-contract.ts +183 -0
  73. package/src/send.ts +346 -59
  74. package/src/stream-draft.ts +102 -41
  75. package/src/turn-adoption.ts +33 -0
  76. package/src/types.ts +9 -3
  77. package/src/typing.ts +77 -0
  78. package/src/update-journal.ts +302 -0
  79. package/src/webhook-queue.ts +205 -33
  80. package/src/webhook-runner.ts +65 -8
  81. package/src/webhook.ts +39 -18
  82. package/scripts/check-cycles.mjs +0 -38
  83. package/scripts/test-api.mjs +0 -169
  84. package/scripts/test-send.mjs +0 -162
  85. package/scripts/update-schema.mjs +0 -48
@@ -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, media albums and delivery.pin.
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 { readMaxDeliveryPin } from './presentation.js';
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, readMaxChannelSendOptions, sendMaxMediaGroup, sendMaxMessage, } from './send.js';
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
- const sendOptions = readMaxChannelSendOptions(payload.channelData);
13
- if (params.callbackId && (payload.text || buttons?.length)) {
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
- const body = err?.body;
24
- log?.error(`[${account.accountId}] MAX callback answer failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
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
- if (payload.text) {
38
- const chunkLimit = 4000; // MAX message limit
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
- const sent = await sendMaxMessage(chatId, chunk, {
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
- const body = err?.body;
56
- log?.error(`[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
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
- const sent = await sendMaxMessage(chatId, '', {
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
- const body = err?.body;
74
- log?.error(`[${account.accountId}] MAX send failed: ${String(err)}${body ? ` body=${JSON.stringify(body)}` : ''}`);
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 links are
84
- // sent by URL, other remote media is downloaded and uploaded.
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) => log?.error(`[${account.accountId}] MAX media send failed (${failed.length} item(s)): ${String(err)}`),
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
- const pin = readMaxDeliveryPin(payload.delivery);
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
- /** channelData with `max.buttons` removed (other max options kept). */
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
  }
@@ -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 sent once, by processIncomingMessage right before the agent
22
- // run — only for messages that pass the group/DM gates.
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
- if (!update.message)
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} text="${update.message?.body?.text ?? '<null>'}" hasBody=${!!update.message?.body}`);
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 editedMessage = { ...update.message };
74
- const originalMid = editedMessage.body.mid;
75
- editedMessage.body = {
76
- ...editedMessage.body,
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: "${fetchedMsg.body.text.slice(0, 50)}"`);
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
+ }