@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
@@ -0,0 +1,191 @@
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
+
8
+ import { resolveAgentConfig } from 'openclaw/plugin-sdk/agent-scope-runtime';
9
+ import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
10
+ import {
11
+ buildMentionRegexes,
12
+ type ChannelInboundMediaInput,
13
+ matchesMentionPatterns,
14
+ toInboundMediaFacts,
15
+ } from 'openclaw/plugin-sdk/channel-inbound';
16
+ import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
17
+ import {
18
+ createChannelPreflightAudio,
19
+ formatAudioTranscriptForAgent,
20
+ } from 'openclaw/plugin-sdk/media-understanding-runtime';
21
+
22
+ import { admitMaxGroupSender } from './access-policy.js';
23
+ import type { ResolvedMaxAccount } from './accounts.js';
24
+ import type { MaxApi, MaxAttachment } from './api.js';
25
+ import {
26
+ collectInboundAttachments,
27
+ type MaxInboundAttachments,
28
+ readAudioTranscription,
29
+ } from './inbound-attachments.js';
30
+
31
+ type PreflightRequest = Parameters<
32
+ ReturnType<typeof createChannelPreflightAudio>['resolve']
33
+ >[0]['request'];
34
+
35
+ const maxPreflightAudio = createChannelPreflightAudio<ChannelInboundMediaInput>({
36
+ channel: 'max',
37
+ isAudio: (media) => media.kind === 'audio',
38
+ });
39
+
40
+ /**
41
+ * Only explicitly configured patterns count: without them core derives
42
+ * patterns from the agent's identity name and emoji, which would start waking
43
+ * the bot in MAX groups that so far needed a real mention.
44
+ */
45
+ function hasConfiguredMentionPatterns(cfg: OpenClawConfig, agentId: string): boolean {
46
+ const agentGroupChat = resolveAgentConfig(cfg, agentId)?.groupChat;
47
+ if (agentGroupChat && Object.hasOwn(agentGroupChat, 'mentionPatterns')) return true;
48
+ const groupChat = cfg.messages?.groupChat;
49
+ return Boolean(groupChat && Object.hasOwn(groupChat, 'mentionPatterns'));
50
+ }
51
+
52
+ /** Core mention regexes for this group, after the channel's mentionPatterns policy. */
53
+ export function resolveMaxMentionRegexes(params: {
54
+ cfg: OpenClawConfig;
55
+ agentId: string;
56
+ account: ResolvedMaxAccount;
57
+ chatId: string;
58
+ }): RegExp[] {
59
+ const { cfg, agentId, account, chatId } = params;
60
+ if (!hasConfiguredMentionPatterns(cfg, agentId)) return [];
61
+ return buildMentionRegexes(cfg, agentId, {
62
+ provider: 'max',
63
+ conversationId: chatId,
64
+ ...(account.config.mentionPatterns ? { providerPolicy: account.config.mentionPatterns } : {}),
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Transcribe the first audio of the message with core media understanding
70
+ * (tools.media.audio). Returns the transcript and the index of the input core
71
+ * transcribed; undefined when there is no audio, STT is off or it fails.
72
+ */
73
+ export async function transcribeMaxVoiceForMention(params: {
74
+ cfg: OpenClawConfig;
75
+ accountId: string;
76
+ chatId: string;
77
+ mediaInputs: ChannelInboundMediaInput[];
78
+ }): Promise<{ transcript: string; index: number } | undefined> {
79
+ const ctx = {
80
+ Provider: 'max',
81
+ Surface: 'max',
82
+ OriginatingChannel: 'max',
83
+ OriginatingTo: `max:${params.chatId}`,
84
+ AccountId: params.accountId,
85
+ ChatType: 'group',
86
+ media: toInboundMediaFacts(params.mediaInputs),
87
+ } as PreflightRequest['ctx'];
88
+ const transcript = await maxPreflightAudio.resolve({ request: { ctx, cfg: params.cfg } });
89
+ if (!transcript) return undefined;
90
+ // Core marks the fact it transcribed on the request context.
91
+ const media = (ctx.media ?? []) as Array<{ transcribed?: boolean }>;
92
+ const marked = media.findIndex(
93
+ (fact, index) => fact?.transcribed === true && params.mediaInputs[index]?.transcribed !== true,
94
+ );
95
+ const index =
96
+ marked >= 0 ? marked : params.mediaInputs.findIndex((input) => input.kind === 'audio');
97
+ return { transcript, index };
98
+ }
99
+
100
+ export interface MaxVoiceMention {
101
+ /** Attachments already collected for the check, the transcribed audio marked. */
102
+ attachments?: MaxInboundAttachments;
103
+ /** Core preflight transcript (not set when MAX's own transcript matched). */
104
+ transcript?: string;
105
+ }
106
+
107
+ /**
108
+ * Mention check for a captionless voice message in a mention-gated group
109
+ * whose chat already passed the group policy. MAX's own transcript is matched
110
+ * as is; otherwise, for senders the group admits, the audio is downloaded
111
+ * (mediaMaxMb, mediaMaxCount) and transcribed once by core. The transcript
112
+ * then goes to the agent as core formats it, and the audio fact is marked
113
+ * transcribed so the reply pipeline does not transcribe it again. No match,
114
+ * no audio or any failure: undefined ("not mentioned").
115
+ */
116
+ export async function resolveMaxVoiceMention(params: {
117
+ attachments: MaxAttachment[];
118
+ messageId: string;
119
+ chatId: number | undefined;
120
+ chatIdStr: string;
121
+ senderId: string;
122
+ mentionRegexes: RegExp[];
123
+ cfg: OpenClawConfig;
124
+ api: MaxApi;
125
+ account: ResolvedMaxAccount;
126
+ log?: ChannelLogSink;
127
+ }): Promise<MaxVoiceMention | undefined> {
128
+ const { attachments, mentionRegexes, account, log } = params;
129
+ const audio = attachments.find((att) => att.type === 'audio');
130
+ if (!audio || mentionRegexes.length === 0) return undefined;
131
+
132
+ const platformTranscript = readAudioTranscription(audio);
133
+ if (platformTranscript) {
134
+ return matchesMentionPatterns(platformTranscript, mentionRegexes) ? {} : undefined;
135
+ }
136
+ // The inbound gate admitted the sender already; the shared rule again, so
137
+ // no caller transcribes for a sender outside the group's allowlist.
138
+ if (!admitMaxGroupSender(account, params.chatId, params.senderId).admitted) {
139
+ return undefined;
140
+ }
141
+
142
+ try {
143
+ const collected = await collectInboundAttachments({
144
+ attachments,
145
+ messageId: params.messageId,
146
+ chatId: params.chatId,
147
+ api: params.api,
148
+ account,
149
+ log,
150
+ });
151
+ if (!collected.mediaInputs.some((input) => input.kind === 'audio' && !input.transcribed)) {
152
+ return undefined;
153
+ }
154
+ const result = await transcribeMaxVoiceForMention({
155
+ cfg: params.cfg,
156
+ accountId: account.accountId,
157
+ chatId: params.chatIdStr,
158
+ mediaInputs: collected.mediaInputs,
159
+ });
160
+ if (!result || !matchesMentionPatterns(result.transcript, mentionRegexes)) return undefined;
161
+ const mediaInputs = collected.mediaInputs.map((input, index) =>
162
+ index === result.index ? { ...input, transcribed: true } : input,
163
+ );
164
+ return {
165
+ attachments: {
166
+ ...collected,
167
+ mediaInputs,
168
+ descriptions: [...collected.descriptions, formatAudioTranscriptForAgent(result.transcript)],
169
+ },
170
+ transcript: result.transcript,
171
+ };
172
+ } catch (err) {
173
+ log?.debug?.(`[${account.accountId}] Voice mention check failed: ${String(err)}`);
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ /** Core's deferred transcript echo (tools.media.audio.echoTranscript), after the gate. */
179
+ export async function echoMaxVoiceTranscript(params: {
180
+ cfg: OpenClawConfig;
181
+ accountId: string;
182
+ chatId: string;
183
+ transcript: string;
184
+ }): Promise<void> {
185
+ await maxPreflightAudio.send({
186
+ transcript: params.transcript,
187
+ cfg: params.cfg,
188
+ accountId: params.accountId,
189
+ originatingTo: `max:${params.chatId}`,
190
+ });
191
+ }
@@ -6,9 +6,9 @@
6
6
  import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
7
7
  import type { ChannelInboundMediaInput } from 'openclaw/plugin-sdk/channel-inbound';
8
8
 
9
- import type { ResolvedMaxAccount } from './accounts.js';
9
+ import { type ResolvedMaxAccount, resolveMaxAccountNetwork } from './accounts.js';
10
10
  import type { MaxApi, MaxAttachment } from './api.js';
11
- import { sanitizeMaxFileName } from './media-temp.js';
11
+ import { fetchMaxRemoteMedia, sanitizeMaxFileName } from './media-temp.js';
12
12
  import { getMaxRuntime } from './runtime.js';
13
13
  import { rememberStickerCode } from './sticker-cache.js';
14
14
 
@@ -17,8 +17,24 @@ export interface MaxInboundAttachments {
17
17
  descriptions: string[];
18
18
  /** One entry per downloaded attachment; array position is attachment identity. */
19
19
  mediaInputs: ChannelInboundMediaInput[];
20
+ /** Media attachments taken for download (the per-message count limit). */
21
+ mediaTaken: number;
20
22
  }
21
23
 
24
+ /** Default most media attachments downloaded per inbound message: a full MAX album. */
25
+ export const DEFAULT_INBOUND_MEDIA_MAX_COUNT = 12;
26
+
27
+ /** Per-message media count limit of the account (`mediaMaxCount`). */
28
+ export function resolveInboundMediaMaxCount(account: ResolvedMaxAccount): number {
29
+ return account.config.mediaMaxCount ?? DEFAULT_INBOUND_MEDIA_MAX_COUNT;
30
+ }
31
+
32
+ /** A voice message whose audio could not be loaded and has no MAX transcript. */
33
+ const VOICE_UNAVAILABLE = '[Voice message: audio unavailable, no transcript]';
34
+
35
+ /** Longest link-preview description handed to the agent. */
36
+ const MAX_SHARE_DESCRIPTION_CHARS = 300;
37
+
22
38
  /** Process attachments: download media, build descriptions for non-downloadable types. */
23
39
  export async function collectInboundAttachments(params: {
24
40
  attachments: MaxAttachment[];
@@ -27,11 +43,16 @@ export async function collectInboundAttachments(params: {
27
43
  api: MaxApi;
28
44
  account: ResolvedMaxAccount;
29
45
  log?: ChannelLogSink;
46
+ /** Media downloads left for this message; default: the account's mediaMaxCount. */
47
+ mediaBudget?: number;
30
48
  }): Promise<MaxInboundAttachments> {
31
49
  const { attachments, messageId, chatId, api, account, log } = params;
32
50
  const core = getMaxRuntime();
33
51
  const attachmentDescriptions: string[] = [];
34
52
  const mediaInputs: ChannelInboundMediaInput[] = [];
53
+ const mediaBudget = params.mediaBudget ?? resolveInboundMediaMaxCount(account);
54
+ let mediaTaken = 0;
55
+ let mediaSkipped = 0;
35
56
 
36
57
  for (const att of attachments) {
37
58
  const attType = att.type ?? 'unknown';
@@ -49,6 +70,25 @@ export async function collectInboundAttachments(params: {
49
70
  }
50
71
  }
51
72
 
73
+ // MAX may transcribe voice messages itself (AudioAttachment.transcription,
74
+ // a sibling of payload). The platform transcript wins: the text goes to
75
+ // the agent and the audio fact is marked transcribed (the SDK contract
76
+ // for platform transcripts), so core media understanding skips STT.
77
+ // Without it the fact stays untranscribed and core STT
78
+ // (tools.media.audio) runs; a failed STT leaves core's marker.
79
+ const transcription = attType === 'audio' ? readAudioTranscription(att) : undefined;
80
+ if (transcription) {
81
+ attachmentDescriptions.push(`[Voice transcript: ${transcription}]`);
82
+ }
83
+
84
+ // Over the per-message count limit: no lookup, no download; the text
85
+ // parts above (sticker code, transcript) are cheap and stay.
86
+ if (mediaTaken >= mediaBudget) {
87
+ mediaSkipped += 1;
88
+ continue;
89
+ }
90
+ mediaTaken += 1;
91
+
52
92
  let url = (payload?.url ?? (att as Record<string, unknown>).url ?? '') as string;
53
93
 
54
94
  // Inbound video attachments often carry only a token — resolve playback
@@ -75,18 +115,11 @@ export async function collectInboundAttachments(params: {
75
115
  }
76
116
  }
77
117
 
78
- // MAX may transcribe voice messages itself (AudioAttachment.transcription,
79
- // a sibling of payload). The text goes to the agent and the audio fact is
80
- // marked transcribed, so core media understanding does not run STT again.
81
- const transcription = attType === 'audio' ? readAudioTranscription(att) : undefined;
82
- if (transcription) {
83
- attachmentDescriptions.push(`[Voice transcript: ${transcription}]`);
84
- }
85
-
86
118
  if (url && typeof url === 'string' && url.startsWith('http')) {
87
119
  try {
88
120
  const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
89
- const fetched = await core.channel.media.fetchRemoteMedia({ url, maxBytes });
121
+ const { proxyUrl } = resolveMaxAccountNetwork(account);
122
+ const fetched = await fetchMaxRemoteMedia(url, maxBytes, proxyUrl);
90
123
  const inboundFileName = fetched.fileName
91
124
  ? sanitizeMaxFileName(fetched.fileName, fetched.contentType)
92
125
  : undefined;
@@ -99,23 +132,31 @@ export async function collectInboundAttachments(params: {
99
132
  );
100
133
  // Only the local copy goes to the agent: MAX download URLs are signed
101
134
  // and short-lived, so they are not recorded as the media url.
135
+ // A MAX audio attachment is a voice message: the explicit kind keeps
136
+ // core STT selecting it even when the CDN answers with a generic
137
+ // content type.
102
138
  mediaInputs.push({
103
139
  path: saved.path,
104
140
  contentType: saved.contentType,
105
141
  fileName: inboundFileName,
106
142
  messageId,
143
+ ...(attType === 'audio' ? { kind: 'audio' as const } : {}),
107
144
  ...(transcription ? { transcribed: true } : {}),
108
145
  });
109
146
  } catch (err) {
110
147
  log?.error?.(`[${account.accountId}] Failed to download ${attType}: ${String(err)}`);
111
148
  // Fall back to text description (sticker code already added above)
112
- if (attType !== 'sticker') {
149
+ if (attType === 'audio') {
150
+ if (!transcription) attachmentDescriptions.push(VOICE_UNAVAILABLE);
151
+ } else if (attType !== 'sticker') {
113
152
  attachmentDescriptions.push(`[${attType}: ${url}]`);
114
153
  }
115
154
  }
116
155
  } else {
117
156
  // No URL — text description
118
- if (attType === 'sticker') {
157
+ if (attType === 'audio') {
158
+ if (!transcription) attachmentDescriptions.push(VOICE_UNAVAILABLE);
159
+ } else if (attType === 'sticker') {
119
160
  const code = payload?.code ?? '';
120
161
  attachmentDescriptions.push(`[Sticker${code ? `: ${code}` : ''}]`);
121
162
  } else if (attType === 'file') {
@@ -126,25 +167,135 @@ export async function collectInboundAttachments(params: {
126
167
  }
127
168
  }
128
169
  } else if (attType === 'share') {
129
- const url = (payload?.url ?? (att as Record<string, unknown>).url ?? '') as string;
130
- attachmentDescriptions.push(`[Share${url ? `: ${url}` : ''}]`);
170
+ attachmentDescriptions.push(describeShare(att));
131
171
  } else if (attType === 'location') {
132
- const lat = (att as Record<string, unknown>).latitude ?? payload?.latitude ?? '';
133
- const lon = (att as Record<string, unknown>).longitude ?? payload?.longitude ?? '';
134
- attachmentDescriptions.push(`[Location: ${lat}, ${lon}]`);
172
+ attachmentDescriptions.push(describeLocation(att));
135
173
  } else if (attType === 'contact') {
136
- const name = payload?.name ?? payload?.vcf_info ?? '';
137
- attachmentDescriptions.push(`[Contact${name ? `: ${name}` : ''}]`);
174
+ attachmentDescriptions.push(describeContact(payload));
138
175
  } else if (attType !== 'inline_keyboard') {
139
176
  attachmentDescriptions.push(`[${attType}]`);
140
177
  }
141
178
  }
142
179
 
143
- return { descriptions: attachmentDescriptions, mediaInputs };
180
+ if (mediaSkipped > 0) {
181
+ log?.warn?.(
182
+ `[${account.accountId}] ${mediaSkipped} media attachment(s) of ${messageId} not loaded: mediaMaxCount`,
183
+ );
184
+ attachmentDescriptions.push(
185
+ `[${mediaSkipped} more media attachment(s) not loaded: limit of ${resolveInboundMediaMaxCount(account)} per message]`,
186
+ );
187
+ }
188
+
189
+ return { descriptions: attachmentDescriptions, mediaInputs, mediaTaken };
190
+ }
191
+
192
+ /** One line of untrusted text: whitespace collapsed, clipped by code points. */
193
+ function toInlineText(value: unknown, maxChars?: number): string {
194
+ if (typeof value !== 'string') return '';
195
+ const chars = Array.from(value.replace(/\s+/g, ' ').trim());
196
+ if (maxChars === undefined || chars.length <= maxChars) return chars.join('');
197
+ return `${chars.slice(0, maxChars - 1).join('')}…`;
198
+ }
199
+
200
+ /** Link preview card: title, description and link, whichever MAX sent. */
201
+ function describeShare(att: MaxAttachment): string {
202
+ const raw = att as Record<string, unknown>;
203
+ const payload = raw.payload as Record<string, unknown> | undefined;
204
+ const url = toInlineText(payload?.url ?? raw.url);
205
+ const parts = [
206
+ toInlineText(raw.title),
207
+ toInlineText(raw.description, MAX_SHARE_DESCRIPTION_CHARS),
208
+ url,
209
+ ].filter(Boolean);
210
+ return parts.length ? `[Share: ${parts.join(' — ')}]` : '[Share]';
211
+ }
212
+
213
+ /** Coordinates with a map link (a Yandex Maps placemark). */
214
+ function describeLocation(att: MaxAttachment): string {
215
+ const raw = att as Record<string, unknown>;
216
+ const payload = raw.payload as Record<string, unknown> | undefined;
217
+ const lat = Number(raw.latitude ?? payload?.latitude);
218
+ const lon = Number(raw.longitude ?? payload?.longitude);
219
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180)
220
+ return '[Location]';
221
+ return `[Location: ${lat}, ${lon} — https://yandex.ru/maps/?pt=${lon},${lat}&z=16&l=map]`;
222
+ }
223
+
224
+ /** Contact card: name and phones from the VCard, the MAX profile if linked; never the raw VCard. */
225
+ function describeContact(payload: Record<string, unknown> | undefined): string {
226
+ const vcard = typeof payload?.vcf_info === 'string' ? parseMaxVcard(payload.vcf_info) : undefined;
227
+ const user = payload?.max_info as Record<string, unknown> | null | undefined;
228
+ const profileName = [user?.first_name, user?.last_name]
229
+ .map((part) => toInlineText(part))
230
+ .filter(Boolean)
231
+ .join(' ');
232
+ const name = vcard?.name || profileName || toInlineText(payload?.name);
233
+ const parts = name ? [name] : [];
234
+ if (vcard?.phones.length) parts.push(`phone: ${vcard.phones.join(', ')}`);
235
+ if (user && typeof user.user_id === 'number') {
236
+ const username = toInlineText(user.username);
237
+ parts.push(`MAX user: ${user.user_id}${username ? ` (@${username.replace(/^@/, '')})` : ''}`);
238
+ }
239
+ return parts.length ? `[Contact: ${parts.join('; ')}]` : '[Contact]';
240
+ }
241
+
242
+ /** Most phone numbers taken from one VCard. */
243
+ const MAX_VCARD_PHONES = 5;
244
+
245
+ /**
246
+ * Display name and phone numbers of a VCard (2.1/3.0/4.0): FN, else N; TEL
247
+ * values (a `tel:` URI prefix dropped). Folded lines, escapes and
248
+ * quoted-printable values are decoded; everything else is ignored.
249
+ * @internal exported for testing.
250
+ */
251
+ export function parseMaxVcard(vcf: string): { name: string; phones: string[] } {
252
+ const lines = vcf.replace(/\r?\n[ \t]/g, '').split(/\r?\n/);
253
+ let fullName = '';
254
+ let structuredName = '';
255
+ const phones: string[] = [];
256
+ for (const line of lines) {
257
+ const colon = line.indexOf(':');
258
+ if (colon <= 0) continue;
259
+ const [rawProperty, ...params] = line.slice(0, colon).split(';');
260
+ const property = rawProperty.replace(/^[^.]*\./, '').toUpperCase();
261
+ let value = line.slice(colon + 1);
262
+ if (params.some((param) => /^ENCODING=QUOTED-PRINTABLE$/i.test(param))) {
263
+ value = decodeQuotedPrintable(value);
264
+ }
265
+ if (property === 'FN' && !fullName) {
266
+ fullName = toInlineText(unescapeVcardValue(value));
267
+ } else if (property === 'N' && !structuredName) {
268
+ // N: family;given;additional;prefix;suffix
269
+ const [family = '', given = ''] = value.split(';').map(unescapeVcardValue);
270
+ structuredName = toInlineText(`${given} ${family}`);
271
+ } else if (property === 'TEL') {
272
+ const phone = toInlineText(unescapeVcardValue(value).replace(/^tel:/i, ''));
273
+ if (phone && !phones.includes(phone) && phones.length < MAX_VCARD_PHONES) phones.push(phone);
274
+ }
275
+ }
276
+ return { name: fullName || structuredName, phones };
277
+ }
278
+
279
+ function unescapeVcardValue(value: string): string {
280
+ return value.replace(/\\([nN,;\\])/g, (_, ch: string) => (ch === 'n' || ch === 'N' ? ' ' : ch));
281
+ }
282
+
283
+ function decodeQuotedPrintable(value: string): string {
284
+ const bytes: number[] = [];
285
+ for (let i = 0; i < value.length; i += 1) {
286
+ const hex = value[i] === '=' ? value.slice(i + 1, i + 3) : '';
287
+ if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
288
+ bytes.push(parseInt(hex, 16));
289
+ i += 2;
290
+ } else {
291
+ bytes.push(...Buffer.from(value[i], 'utf8'));
292
+ }
293
+ }
294
+ return Buffer.from(bytes).toString('utf8');
144
295
  }
145
296
 
146
297
  /** Non-empty MAX transcription of an audio attachment, if any. */
147
- function readAudioTranscription(att: MaxAttachment): string | undefined {
298
+ export function readAudioTranscription(att: MaxAttachment): string | undefined {
148
299
  const value = att.transcription;
149
300
  if (typeof value !== 'string') return undefined;
150
301
  const trimmed = value.trim();