@aspalagin/openclaw-max 0.7.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/LICENSE +21 -0
- package/README.md +424 -0
- package/SKILL.md +108 -0
- package/dist/index.js +17 -0
- package/dist/setup-entry.js +8 -0
- package/dist/src/accounts.js +148 -0
- package/dist/src/actions.js +421 -0
- package/dist/src/api.js +553 -0
- package/dist/src/callbacks.js +111 -0
- package/dist/src/channel-agent-prompt.js +38 -0
- package/dist/src/channel-config.js +103 -0
- package/dist/src/channel-directory.js +84 -0
- package/dist/src/channel-lifecycle.js +219 -0
- package/dist/src/channel-outbound.js +133 -0
- package/dist/src/channel-policy.js +130 -0
- package/dist/src/channel.js +76 -0
- package/dist/src/config-schema.js +133 -0
- package/dist/src/deliver.js +126 -0
- package/dist/src/dispatch.js +186 -0
- package/dist/src/format.js +31 -0
- package/dist/src/inbound-attachments.js +125 -0
- package/dist/src/inbound.js +362 -0
- package/dist/src/media-temp.js +92 -0
- package/dist/src/model-buttons.js +96 -0
- package/dist/src/monitor-types.js +27 -0
- package/dist/src/monitor.js +40 -0
- package/dist/src/onboarding.js +180 -0
- package/dist/src/polling.js +98 -0
- package/dist/src/presentation.js +374 -0
- package/dist/src/runtime.js +36 -0
- package/dist/src/russian-trusted-ca.js +84 -0
- package/dist/src/send.js +612 -0
- package/dist/src/state.js +111 -0
- package/dist/src/sticker-cache.js +26 -0
- package/dist/src/stream-draft.js +102 -0
- package/dist/src/types.js +5 -0
- package/dist/src/webhook-queue.js +215 -0
- package/dist/src/webhook-runner.js +233 -0
- package/dist/src/webhook.js +196 -0
- package/openclaw.plugin.json +263 -0
- package/package.json +100 -0
- package/scripts/check-cycles.mjs +38 -0
- package/scripts/test-api.mjs +169 -0
- package/scripts/test-send.mjs +162 -0
- package/scripts/update-schema.mjs +48 -0
- package/src/accounts.ts +193 -0
- package/src/actions.ts +501 -0
- package/src/api.ts +758 -0
- package/src/callbacks.ts +140 -0
- package/src/channel-agent-prompt.ts +47 -0
- package/src/channel-config.ts +138 -0
- package/src/channel-directory.ts +90 -0
- package/src/channel-lifecycle.ts +256 -0
- package/src/channel-outbound.ts +159 -0
- package/src/channel-policy.ts +185 -0
- package/src/channel.ts +99 -0
- package/src/config-schema.ts +154 -0
- package/src/deliver.ts +168 -0
- package/src/dispatch.ts +224 -0
- package/src/format.ts +33 -0
- package/src/inbound-attachments.ts +152 -0
- package/src/inbound.ts +412 -0
- package/src/media-temp.ts +103 -0
- package/src/model-buttons.ts +150 -0
- package/src/monitor-types.ts +61 -0
- package/src/monitor.ts +49 -0
- package/src/onboarding.ts +215 -0
- package/src/polling.ts +125 -0
- package/src/presentation.ts +452 -0
- package/src/runtime.ts +56 -0
- package/src/russian-trusted-ca.ts +86 -0
- package/src/send.ts +774 -0
- package/src/state.ts +149 -0
- package/src/sticker-cache.ts +28 -0
- package/src/stream-draft.ts +125 -0
- package/src/types.ts +263 -0
- package/src/webhook-queue.ts +248 -0
- package/src/webhook-runner.ts +296 -0
- package/src/webhook.ts +268 -0
package/src/send.ts
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outbound message sending for MAX.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { OpenClawConfig } from 'openclaw/plugin-sdk/core';
|
|
6
|
+
import { retryAsync } from 'openclaw/plugin-sdk/runtime-env';
|
|
7
|
+
|
|
8
|
+
import { resolveMaxAccount } from './accounts.js';
|
|
9
|
+
import {
|
|
10
|
+
MaxApi,
|
|
11
|
+
MaxApiError,
|
|
12
|
+
type MaxAttachment,
|
|
13
|
+
type MaxInlineKeyboardAttachment,
|
|
14
|
+
type MaxInlineKeyboardButton,
|
|
15
|
+
type MaxNewMessageBody,
|
|
16
|
+
MaxRequestTimeoutError,
|
|
17
|
+
type MaxSendResult,
|
|
18
|
+
type MaxStickerAttachment,
|
|
19
|
+
} from './api.js';
|
|
20
|
+
import { toMaxMarkdown } from './format.js';
|
|
21
|
+
import { withRemoteMediaTempFile } from './media-temp.js';
|
|
22
|
+
|
|
23
|
+
export type MaxSendButton = {
|
|
24
|
+
text: string;
|
|
25
|
+
/** Button type; default: "link" when url is set, otherwise "callback" */
|
|
26
|
+
type?:
|
|
27
|
+
| 'callback'
|
|
28
|
+
| 'link'
|
|
29
|
+
| 'message'
|
|
30
|
+
| 'clipboard'
|
|
31
|
+
| 'open_app'
|
|
32
|
+
| 'request_contact'
|
|
33
|
+
| 'request_geo_location';
|
|
34
|
+
payload?: string;
|
|
35
|
+
callback_data?: string;
|
|
36
|
+
url?: string;
|
|
37
|
+
/** open_app: public name of the bot wired to the mini app (falls back to `url`) */
|
|
38
|
+
webApp?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export interface MaxSendOptions {
|
|
42
|
+
token?: string;
|
|
43
|
+
accountId?: string;
|
|
44
|
+
cfg?: OpenClawConfig;
|
|
45
|
+
replyToMessageId?: string;
|
|
46
|
+
format?: 'markdown' | 'html';
|
|
47
|
+
disableLinkPreview?: boolean;
|
|
48
|
+
notify?: boolean;
|
|
49
|
+
buttons?: MaxSendButton[][];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Send target: numeric chat id, or an explicit user id (user:<id> targets). */
|
|
53
|
+
export type MaxSendTarget = { chat_id: number } | { user_id: number };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a token from options or config.
|
|
57
|
+
*/
|
|
58
|
+
function resolveToken(opts: MaxSendOptions): string {
|
|
59
|
+
if (opts.token) return opts.token;
|
|
60
|
+
if (opts.cfg) {
|
|
61
|
+
const account = resolveMaxAccount({ cfg: opts.cfg, accountId: opts.accountId });
|
|
62
|
+
if (account.token) return account.token;
|
|
63
|
+
}
|
|
64
|
+
throw new Error('MAX bot token not available');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a target string into API send params.
|
|
69
|
+
* Supported forms: "12345", "max:12345" (chat id), "user:12345" / "max:user:12345"
|
|
70
|
+
* (user id).
|
|
71
|
+
*
|
|
72
|
+
* "@username" and max.ru links are rejected with an explicit error: the Bot API
|
|
73
|
+
* schema has only GET /chats/{chatId} (numeric), and a link lookup answers
|
|
74
|
+
* `chat.not.found` live. Address groups by chat_id (from bot_added), users by
|
|
75
|
+
* "user:<id>".
|
|
76
|
+
*/
|
|
77
|
+
export async function resolveMaxTarget(_api: MaxApi, to: string): Promise<MaxSendTarget> {
|
|
78
|
+
let normalized = to.trim();
|
|
79
|
+
if (normalized.startsWith('max:')) normalized = normalized.slice(4);
|
|
80
|
+
|
|
81
|
+
if (normalized.startsWith('user:')) {
|
|
82
|
+
const userId = Number(normalized.slice(5));
|
|
83
|
+
if (Number.isNaN(userId)) throw new Error(`Invalid MAX target: ${to}`);
|
|
84
|
+
return { user_id: userId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const chatId = Number(normalized);
|
|
88
|
+
if (!Number.isNaN(chatId) && normalized !== '') {
|
|
89
|
+
return { chat_id: chatId };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (normalized.startsWith('@') || /(^|\/\/|www\.)max\.ru\//i.test(normalized)) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`MAX API does not resolve @username or chat links ("${to}"); ` +
|
|
95
|
+
'use a numeric chat_id for chats and channels or user:<id> for users',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
throw new Error(`Invalid MAX target: ${to}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Button types the plugin builds (schema.yaml Button.discriminator). */
|
|
103
|
+
export const MAX_SEND_BUTTON_TYPES: ReadonlySet<string> = new Set([
|
|
104
|
+
'callback',
|
|
105
|
+
'link',
|
|
106
|
+
'message',
|
|
107
|
+
'clipboard',
|
|
108
|
+
'open_app',
|
|
109
|
+
'request_contact',
|
|
110
|
+
'request_geo_location',
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse loosely typed button rows (`channelData.max.buttons`, the message
|
|
115
|
+
* tool's `buttons` param): [[{text|label, type?, payload?, callback_data?,
|
|
116
|
+
* url?, webApp|web_app?}]]. A bare button in place of a row is one row;
|
|
117
|
+
* non-object items and buttons without text are dropped.
|
|
118
|
+
*/
|
|
119
|
+
export function readMaxSendButtons(rawButtons: unknown): MaxSendButton[][] | undefined {
|
|
120
|
+
if (!Array.isArray(rawButtons)) return undefined;
|
|
121
|
+
|
|
122
|
+
const rows = rawButtons
|
|
123
|
+
.map((row) => {
|
|
124
|
+
const items = Array.isArray(row) ? row : [row];
|
|
125
|
+
return items
|
|
126
|
+
.filter(
|
|
127
|
+
(button): button is Record<string, unknown> =>
|
|
128
|
+
Boolean(button) && typeof button === 'object' && !Array.isArray(button),
|
|
129
|
+
)
|
|
130
|
+
.map((button) => ({
|
|
131
|
+
text: String(button.text ?? button.label ?? ''),
|
|
132
|
+
type: MAX_SEND_BUTTON_TYPES.has(String(button.type))
|
|
133
|
+
? (String(button.type) as MaxSendButton['type'])
|
|
134
|
+
: undefined,
|
|
135
|
+
payload: button.payload != null ? String(button.payload) : undefined,
|
|
136
|
+
callback_data: button.callback_data != null ? String(button.callback_data) : undefined,
|
|
137
|
+
url: button.url != null ? String(button.url) : undefined,
|
|
138
|
+
webApp:
|
|
139
|
+
button.webApp != null
|
|
140
|
+
? String(button.webApp)
|
|
141
|
+
: button.web_app != null
|
|
142
|
+
? String(button.web_app)
|
|
143
|
+
: undefined,
|
|
144
|
+
}))
|
|
145
|
+
.filter((button) => button.text.trim().length > 0);
|
|
146
|
+
})
|
|
147
|
+
.filter((row) => row.length > 0);
|
|
148
|
+
|
|
149
|
+
return rows.length > 0 ? rows : undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function readMaxChannelButtons(channelData: unknown): MaxSendButton[][] | undefined {
|
|
153
|
+
if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData))
|
|
154
|
+
return undefined;
|
|
155
|
+
const maxData = (channelData as Record<string, unknown>).max;
|
|
156
|
+
if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData)) return undefined;
|
|
157
|
+
return readMaxSendButtons((maxData as Record<string, unknown>).buttons);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Extra per-message options passed via channelData.max (notify, link preview). */
|
|
161
|
+
export function readMaxChannelSendOptions(channelData: unknown): {
|
|
162
|
+
notify?: boolean;
|
|
163
|
+
disableLinkPreview?: boolean;
|
|
164
|
+
} {
|
|
165
|
+
if (!channelData || typeof channelData !== 'object' || Array.isArray(channelData)) return {};
|
|
166
|
+
const maxData = (channelData as Record<string, unknown>).max;
|
|
167
|
+
if (!maxData || typeof maxData !== 'object' || Array.isArray(maxData)) return {};
|
|
168
|
+
const data = maxData as Record<string, unknown>;
|
|
169
|
+
const result: { notify?: boolean; disableLinkPreview?: boolean } = {};
|
|
170
|
+
if (typeof data.notify === 'boolean') result.notify = data.notify;
|
|
171
|
+
if (data.silent === true) result.notify = false;
|
|
172
|
+
if (typeof data.disableLinkPreview === 'boolean')
|
|
173
|
+
result.disableLinkPreview = data.disableLinkPreview;
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildMaxButton(btn: MaxSendButton): MaxInlineKeyboardButton {
|
|
178
|
+
const type = btn.type ?? (btn.url ? 'link' : 'callback');
|
|
179
|
+
const payload = btn.payload ?? btn.callback_data;
|
|
180
|
+
switch (type) {
|
|
181
|
+
case 'link':
|
|
182
|
+
return { type: 'link', text: btn.text, url: btn.url ?? '' };
|
|
183
|
+
case 'message':
|
|
184
|
+
return { type: 'message', text: btn.text, ...(payload ? { payload } : {}) };
|
|
185
|
+
case 'clipboard':
|
|
186
|
+
return { type: 'clipboard', text: btn.text, payload: payload ?? btn.text };
|
|
187
|
+
case 'open_app': {
|
|
188
|
+
// OpenAppButton has no `url`: the mini app is addressed by `web_app`
|
|
189
|
+
// (the wired bot's public name). A legacy `url` is carried over there.
|
|
190
|
+
const webApp = btn.webApp ?? btn.url;
|
|
191
|
+
return {
|
|
192
|
+
type: 'open_app',
|
|
193
|
+
text: btn.text,
|
|
194
|
+
...(webApp ? { web_app: webApp } : {}),
|
|
195
|
+
...(payload ? { payload } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
case 'request_contact':
|
|
199
|
+
return { type: 'request_contact', text: btn.text };
|
|
200
|
+
case 'request_geo_location':
|
|
201
|
+
return { type: 'request_geo_location', text: btn.text };
|
|
202
|
+
case 'callback':
|
|
203
|
+
default:
|
|
204
|
+
return {
|
|
205
|
+
type: 'callback',
|
|
206
|
+
text: btn.text,
|
|
207
|
+
payload: payload ?? btn.text,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildInlineKeyboard(buttons: MaxSendButton[][]): MaxInlineKeyboardAttachment {
|
|
213
|
+
return {
|
|
214
|
+
type: 'inline_keyboard',
|
|
215
|
+
payload: {
|
|
216
|
+
buttons: buttons.map((row) => row.map((btn) => buildMaxButton(btn))),
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Apply MAX markdown dialect conversion when sending formatted text. */
|
|
222
|
+
function formatOutboundText(text: string, format?: 'markdown' | 'html'): string {
|
|
223
|
+
if (format === 'markdown' && text) return toMaxMarkdown(text);
|
|
224
|
+
return text;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
type MaxBodyAttachments = NonNullable<MaxNewMessageBody['attachments']>;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* NewMessageBody: text (MAX markdown dialect) with its format when `text` is
|
|
231
|
+
* given (attachment-only bodies carry neither), notify, attachments and the
|
|
232
|
+
* reply link.
|
|
233
|
+
*/
|
|
234
|
+
function buildMaxBody(
|
|
235
|
+
opts: MaxSendOptions,
|
|
236
|
+
text: string | undefined,
|
|
237
|
+
attachments?: MaxBodyAttachments,
|
|
238
|
+
): MaxNewMessageBody {
|
|
239
|
+
const body: MaxNewMessageBody =
|
|
240
|
+
text === undefined
|
|
241
|
+
? { notify: opts.notify }
|
|
242
|
+
: {
|
|
243
|
+
text: formatOutboundText(text, opts.format) || undefined,
|
|
244
|
+
format: opts.format ?? undefined,
|
|
245
|
+
notify: opts.notify,
|
|
246
|
+
};
|
|
247
|
+
if (attachments?.length) body.attachments = attachments;
|
|
248
|
+
if (opts.replyToMessageId) {
|
|
249
|
+
body.link = { type: 'reply', mid: opts.replyToMessageId };
|
|
250
|
+
}
|
|
251
|
+
return body;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Text body with the inline keyboard from `opts.buttons`. */
|
|
255
|
+
function buildMaxTextBody(text: string, opts: MaxSendOptions = {}): MaxNewMessageBody {
|
|
256
|
+
return buildMaxBody(
|
|
257
|
+
opts,
|
|
258
|
+
text,
|
|
259
|
+
opts.buttons?.length ? [buildInlineKeyboard(opts.buttons)] : undefined,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function buildSendParams(
|
|
264
|
+
target: MaxSendTarget,
|
|
265
|
+
opts: MaxSendOptions,
|
|
266
|
+
): { chat_id?: number; user_id?: number; disable_link_preview?: boolean } {
|
|
267
|
+
const params: { chat_id?: number; user_id?: number; disable_link_preview?: boolean } = {
|
|
268
|
+
...target,
|
|
269
|
+
};
|
|
270
|
+
if (opts.disableLinkPreview) {
|
|
271
|
+
params.disable_link_preview = true;
|
|
272
|
+
}
|
|
273
|
+
return params;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export type MaxSendOutcome = { messageId: string; raw: MaxSendResult };
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The one POST /messages path of every send* helper: resolve the target, build
|
|
280
|
+
* the body (after the target, so a bad target fails before any upload), send —
|
|
281
|
+
* the per-chat limiter lives in MaxApi.sendMessage — through the caller's retry
|
|
282
|
+
* policy, and report the sent mid. typing_on is not sent here: the inbound
|
|
283
|
+
* pipeline sends it once per handled message.
|
|
284
|
+
*/
|
|
285
|
+
async function sendWithBody(params: {
|
|
286
|
+
api: MaxApi;
|
|
287
|
+
to: string;
|
|
288
|
+
opts: MaxSendOptions;
|
|
289
|
+
body: MaxNewMessageBody | (() => Promise<MaxNewMessageBody>);
|
|
290
|
+
retry?: (send: () => Promise<MaxSendResult>) => Promise<MaxSendResult>;
|
|
291
|
+
}): Promise<MaxSendOutcome> {
|
|
292
|
+
const { api, to, opts, retry } = params;
|
|
293
|
+
const target = await resolveMaxTarget(api, to);
|
|
294
|
+
const body = typeof params.body === 'function' ? await params.body() : params.body;
|
|
295
|
+
const sendTo = (to: MaxSendTarget) => {
|
|
296
|
+
const sendParams = buildSendParams(to, opts);
|
|
297
|
+
const send = () => api.sendMessage(body, sendParams);
|
|
298
|
+
return retry ? retry(send) : send();
|
|
299
|
+
};
|
|
300
|
+
let result: MaxSendResult;
|
|
301
|
+
try {
|
|
302
|
+
result = await sendTo(target);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
// A bare positive number is ambiguous: agents often pass a user id where
|
|
305
|
+
// a chat id is expected (the directory hands out user ids of DM peers).
|
|
306
|
+
// MAX rejected the send outright, so nothing was delivered and one retry
|
|
307
|
+
// as user_id cannot duplicate.
|
|
308
|
+
if (!('chat_id' in target) || target.chat_id <= 0 || !isChatNotFound(err)) throw err;
|
|
309
|
+
result = await sendTo({ user_id: target.chat_id });
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
messageId: result.message?.body?.mid ?? '',
|
|
313
|
+
raw: result,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** MAX answer for a chat_id that names no chat of the bot (e.g. a user id). */
|
|
318
|
+
export function isChatNotFound(err: unknown): boolean {
|
|
319
|
+
if (!(err instanceof MaxApiError)) return false;
|
|
320
|
+
if (err.code === 'dialog.not.found' || err.code === 'chat.not.found') return true;
|
|
321
|
+
return /\b(dialog|chat)\.not\.found\b/.test(err.message);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* A client-side timeout aborts before the request reaches MAX: aborted sends
|
|
326
|
+
* never appear in the chat and never duplicate (verified against the live chat
|
|
327
|
+
* history). So one retry on a fresh connection is duplicate-safe and recovers
|
|
328
|
+
* the intermittent in-process send stall. Scoped to text sends on purpose —
|
|
329
|
+
* media is not retried here to avoid re-uploading the file.
|
|
330
|
+
*/
|
|
331
|
+
async function retryOnceOnTimeout(send: () => Promise<MaxSendResult>): Promise<MaxSendResult> {
|
|
332
|
+
try {
|
|
333
|
+
return await send();
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (!(err instanceof MaxRequestTimeoutError)) throw err;
|
|
336
|
+
console.error(
|
|
337
|
+
`[MAX] send timed out after ${err.elapsedMs}ms (phase=${err.phase}); retrying once`,
|
|
338
|
+
);
|
|
339
|
+
return send();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** MAX processes video/file uploads asynchronously: retry the send (not the upload) while attachment.not.ready. */
|
|
344
|
+
function retryWhileAttachmentNotReady(send: () => Promise<MaxSendResult>): Promise<MaxSendResult> {
|
|
345
|
+
return retryAsync(send, {
|
|
346
|
+
attempts: 6,
|
|
347
|
+
minDelayMs: 1_500,
|
|
348
|
+
maxDelayMs: 4_000,
|
|
349
|
+
label: 'MAX send media (attachment.not.ready)',
|
|
350
|
+
shouldRetry: (err) => isAttachmentNotReady(err),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Send a text message to a MAX chat or user.
|
|
356
|
+
*/
|
|
357
|
+
export async function sendMaxMessage(
|
|
358
|
+
to: string,
|
|
359
|
+
text: string,
|
|
360
|
+
opts: MaxSendOptions = {},
|
|
361
|
+
): Promise<MaxSendOutcome> {
|
|
362
|
+
const api = new MaxApi({ token: resolveToken(opts) });
|
|
363
|
+
return sendWithBody({
|
|
364
|
+
api,
|
|
365
|
+
to,
|
|
366
|
+
opts,
|
|
367
|
+
body: buildMaxTextBody(text, opts),
|
|
368
|
+
retry: retryOnceOnTimeout,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Answer a MAX callback. Unlike /messages, /answers is scoped by callback_id
|
|
374
|
+
* and works even when the callback update does not expose a sendable chat_id.
|
|
375
|
+
*/
|
|
376
|
+
export async function answerMaxCallback(
|
|
377
|
+
callbackId: string,
|
|
378
|
+
text: string,
|
|
379
|
+
opts: MaxSendOptions & { notification?: string } = {},
|
|
380
|
+
): Promise<void> {
|
|
381
|
+
const token = resolveToken(opts);
|
|
382
|
+
const api = new MaxApi({ token });
|
|
383
|
+
const body = buildMaxTextBody(text, opts);
|
|
384
|
+
await api.answerCallback(callbackId, {
|
|
385
|
+
...(text || opts.buttons?.length ? { message: body } : {}),
|
|
386
|
+
...(opts.notification ? { notification: opts.notification } : {}),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Edit an existing MAX message. PUT /messages keeps the current attachments
|
|
392
|
+
* when `attachments` is absent/null and deletes them all on an empty list, so
|
|
393
|
+
* the keyboard is sent only when `buttons` are given (it then replaces the
|
|
394
|
+
* message attachments, e.g. adds buttons to a text-only stream draft).
|
|
395
|
+
*/
|
|
396
|
+
export async function editMaxMessage(
|
|
397
|
+
messageId: string,
|
|
398
|
+
text: string,
|
|
399
|
+
opts: MaxSendOptions = {},
|
|
400
|
+
): Promise<void> {
|
|
401
|
+
const token = resolveToken(opts);
|
|
402
|
+
const api = new MaxApi({ token });
|
|
403
|
+
|
|
404
|
+
await api.editMessage(messageId, {
|
|
405
|
+
text: formatOutboundText(text, opts.format),
|
|
406
|
+
format: opts.format ?? undefined,
|
|
407
|
+
...(opts.buttons?.length ? { attachments: [buildInlineKeyboard(opts.buttons)] } : {}),
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Delete a MAX message.
|
|
413
|
+
*/
|
|
414
|
+
export async function deleteMaxMessage(
|
|
415
|
+
messageId: string,
|
|
416
|
+
opts: MaxSendOptions = {},
|
|
417
|
+
): Promise<void> {
|
|
418
|
+
const token = resolveToken(opts);
|
|
419
|
+
const api = new MaxApi({ token });
|
|
420
|
+
|
|
421
|
+
await api.deleteMessage(messageId);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Pin result: MAX has no pinned messages in dialogs (1:1 chats). */
|
|
425
|
+
export type MaxPinOutcome = { pinned: true } | { pinned: false; reason: string };
|
|
426
|
+
|
|
427
|
+
export const MAX_PIN_DIALOG_REASON = 'MAX does not support pinned messages in dialogs';
|
|
428
|
+
|
|
429
|
+
/** 400 proto.payload "Method is not available for dialogs" from /chats/{id}/pin. */
|
|
430
|
+
function isPinUnavailableForDialog(err: unknown): boolean {
|
|
431
|
+
if (!(err instanceof MaxApiError)) return false;
|
|
432
|
+
const message =
|
|
433
|
+
typeof (err.body as { message?: unknown })?.message === 'string'
|
|
434
|
+
? String((err.body as { message?: unknown }).message)
|
|
435
|
+
: err.message;
|
|
436
|
+
return /not available for dialogs/i.test(message);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Pin a message in a MAX chat. Dialogs cannot have pinned messages (MAX
|
|
441
|
+
* answers 400 "Method is not available for dialogs"), so a dialog target is
|
|
442
|
+
* reported as `{ pinned: false }` without calling the pin API: `user:<id>`
|
|
443
|
+
* targets are always dialogs, negative ids are groups/channels, and a
|
|
444
|
+
* positive chat id is checked through `chatType` (the recipient of the sent
|
|
445
|
+
* message) or GET /chats/{id}.
|
|
446
|
+
*/
|
|
447
|
+
export async function pinMaxMessage(
|
|
448
|
+
to: string,
|
|
449
|
+
messageId: string,
|
|
450
|
+
opts: MaxSendOptions & {
|
|
451
|
+
pinNotify?: boolean;
|
|
452
|
+
/** recipient.chat_type of the sent message, when the caller has it. */
|
|
453
|
+
chatType?: string;
|
|
454
|
+
/** Checked synchronously right before each MAX request (delivery-owner guard). */
|
|
455
|
+
beforeRequest?: () => void;
|
|
456
|
+
} = {},
|
|
457
|
+
): Promise<MaxPinOutcome> {
|
|
458
|
+
const token = resolveToken(opts);
|
|
459
|
+
const api = new MaxApi({ token });
|
|
460
|
+
const target = await resolveMaxTarget(api, to);
|
|
461
|
+
if (!('chat_id' in target) || opts.chatType === 'dialog') {
|
|
462
|
+
return { pinned: false, reason: MAX_PIN_DIALOG_REASON };
|
|
463
|
+
}
|
|
464
|
+
const chatId = target.chat_id;
|
|
465
|
+
if (chatId > 0 && !opts.chatType) {
|
|
466
|
+
opts.beforeRequest?.();
|
|
467
|
+
const chatType = await api.getChat(chatId).then(
|
|
468
|
+
(chat) => chat.type,
|
|
469
|
+
() => undefined, // unknown: let the pin call decide
|
|
470
|
+
);
|
|
471
|
+
if (chatType === 'dialog') return { pinned: false, reason: MAX_PIN_DIALOG_REASON };
|
|
472
|
+
}
|
|
473
|
+
opts.beforeRequest?.();
|
|
474
|
+
try {
|
|
475
|
+
await api.pinMessage(chatId, messageId, opts.pinNotify);
|
|
476
|
+
} catch (err) {
|
|
477
|
+
if (isPinUnavailableForDialog(err)) return { pinned: false, reason: MAX_PIN_DIALOG_REASON };
|
|
478
|
+
throw err;
|
|
479
|
+
}
|
|
480
|
+
return { pinned: true };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export async function unpinMaxMessage(to: string, opts: MaxSendOptions = {}): Promise<void> {
|
|
484
|
+
const token = resolveToken(opts);
|
|
485
|
+
const api = new MaxApi({ token });
|
|
486
|
+
const target = await resolveMaxTarget(api, to);
|
|
487
|
+
if (!('chat_id' in target)) throw new Error('MAX unpin requires a chat id target');
|
|
488
|
+
await api.unpinMessage(target.chat_id);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Extension → upload type routing. Everything else goes as a generic file.
|
|
492
|
+
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'heic', 'tif', 'tiff', 'bmp'];
|
|
493
|
+
const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'webm'];
|
|
494
|
+
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'opus'];
|
|
495
|
+
|
|
496
|
+
export function detectMaxMediaType(mediaPath: string): 'image' | 'video' | 'audio' | 'file' {
|
|
497
|
+
const ext = mediaPath.split('.').pop()?.toLowerCase() ?? '';
|
|
498
|
+
if (IMAGE_EXTENSIONS.includes(ext)) return 'image';
|
|
499
|
+
if (VIDEO_EXTENSIONS.includes(ext)) return 'video';
|
|
500
|
+
if (AUDIO_EXTENSIONS.includes(ext)) return 'audio';
|
|
501
|
+
return 'file';
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function isAttachmentNotReady(err: unknown): boolean {
|
|
505
|
+
if (!(err instanceof MaxApiError)) return false;
|
|
506
|
+
if (err.code === 'attachment.not.ready') return true;
|
|
507
|
+
const message =
|
|
508
|
+
typeof (err.body as { message?: unknown })?.message === 'string'
|
|
509
|
+
? String((err.body as { message?: unknown }).message)
|
|
510
|
+
: '';
|
|
511
|
+
return /not\s*\.?\s*ready|not processed/i.test(message);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Images and videos per message (docs «Медиафайлы»: up to 12 in total). */
|
|
515
|
+
export const MAX_VISUAL_MEDIA_PER_MESSAGE = 12;
|
|
516
|
+
|
|
517
|
+
const DEFAULT_MEDIA_MAX_BYTES = 20 * 1024 * 1024;
|
|
518
|
+
|
|
519
|
+
export interface MaxMediaSendOptions extends MaxSendOptions {
|
|
520
|
+
/** Download cap for remote media that has to be uploaded (default 20 MB). */
|
|
521
|
+
mediaMaxBytes?: number;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isRemoteUrl(source: string): boolean {
|
|
525
|
+
return source.startsWith('https://') || source.startsWith('http://');
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** https link whose path looks like an image: MAX can fetch it itself (image.payload.url). */
|
|
529
|
+
function isImageLink(source: string): boolean {
|
|
530
|
+
if (!source.startsWith('https://')) return false;
|
|
531
|
+
try {
|
|
532
|
+
return detectMaxMediaType(new URL(source).pathname) === 'image';
|
|
533
|
+
} catch {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function mediaKind(source: string): 'image' | 'video' | 'audio' | 'file' {
|
|
539
|
+
if (!isRemoteUrl(source)) return detectMaxMediaType(source);
|
|
540
|
+
try {
|
|
541
|
+
return detectMaxMediaType(new URL(source).pathname);
|
|
542
|
+
} catch {
|
|
543
|
+
return 'file';
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Upload a local path, or a remote URL through a temporary file. */
|
|
548
|
+
async function uploadMaxAttachment(
|
|
549
|
+
api: MaxApi,
|
|
550
|
+
source: string,
|
|
551
|
+
opts: MaxMediaSendOptions,
|
|
552
|
+
): Promise<MaxAttachment> {
|
|
553
|
+
if (isRemoteUrl(source)) {
|
|
554
|
+
return withRemoteMediaTempFile(
|
|
555
|
+
source,
|
|
556
|
+
opts.mediaMaxBytes ?? DEFAULT_MEDIA_MAX_BYTES,
|
|
557
|
+
async ({ path, contentType }) => {
|
|
558
|
+
const type = detectMaxMediaType(path);
|
|
559
|
+
const uploaded = await api.uploadMedia(type, path, contentType);
|
|
560
|
+
return { type, payload: { token: uploaded.token } };
|
|
561
|
+
},
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
const type = detectMaxMediaType(source);
|
|
565
|
+
const uploaded = await api.uploadMedia(type, source);
|
|
566
|
+
return { type, payload: { token: uploaded.token } };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* One message with the given media. https image links go as image.payload.url
|
|
571
|
+
* (no download/upload round trip); if MAX refuses the message, those links are
|
|
572
|
+
* uploaded and the message is sent once more. MAX processes video/file uploads
|
|
573
|
+
* asynchronously — sendMessage may answer attachment.not.ready for a few
|
|
574
|
+
* seconds; retry the send (not the upload).
|
|
575
|
+
*/
|
|
576
|
+
async function sendMaxAttachmentsMessage(
|
|
577
|
+
api: MaxApi,
|
|
578
|
+
to: string,
|
|
579
|
+
caption: string,
|
|
580
|
+
sources: string[],
|
|
581
|
+
opts: MaxMediaSendOptions,
|
|
582
|
+
): Promise<MaxSendOutcome> {
|
|
583
|
+
const build = async (allowLinks: boolean): Promise<MaxNewMessageBody> => {
|
|
584
|
+
const attachments: MaxAttachment[] = [];
|
|
585
|
+
for (const source of sources) {
|
|
586
|
+
attachments.push(
|
|
587
|
+
allowLinks && isImageLink(source)
|
|
588
|
+
? { type: 'image', payload: { url: source } }
|
|
589
|
+
: await uploadMaxAttachment(api, source, opts),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (opts.buttons?.length) {
|
|
593
|
+
attachments.push(buildInlineKeyboard(opts.buttons) as unknown as MaxAttachment);
|
|
594
|
+
}
|
|
595
|
+
return buildMaxBody(opts, caption, attachments);
|
|
596
|
+
};
|
|
597
|
+
const send = (allowLinks: boolean) =>
|
|
598
|
+
sendWithBody({
|
|
599
|
+
api,
|
|
600
|
+
to,
|
|
601
|
+
opts,
|
|
602
|
+
body: () => build(allowLinks),
|
|
603
|
+
retry: retryWhileAttachmentNotReady,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
if (!sources.some(isImageLink)) return send(false);
|
|
607
|
+
try {
|
|
608
|
+
return await send(true);
|
|
609
|
+
} catch (err) {
|
|
610
|
+
if (!(err instanceof MaxApiError)) throw err;
|
|
611
|
+
console.warn(`[MAX] image by URL refused (${err.code ?? err.status}); uploading instead`);
|
|
612
|
+
return send(false);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Send a media message to MAX (with upload).
|
|
618
|
+
* @param to Chat ID or user ID
|
|
619
|
+
* @param caption Text caption
|
|
620
|
+
* @param mediaPath Local file path or URL (https image links are sent by URL)
|
|
621
|
+
* @param opts Send options
|
|
622
|
+
*/
|
|
623
|
+
export async function sendMaxMediaMessage(
|
|
624
|
+
to: string,
|
|
625
|
+
caption: string,
|
|
626
|
+
mediaPath: string,
|
|
627
|
+
opts: MaxMediaSendOptions = {},
|
|
628
|
+
): Promise<MaxSendOutcome> {
|
|
629
|
+
const token = resolveToken(opts);
|
|
630
|
+
const api = new MaxApi({ token });
|
|
631
|
+
return sendMaxAttachmentsMessage(api, to, caption, [mediaPath], opts);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Split media into messages MAX accepts: consecutive images/videos go together
|
|
636
|
+
* (up to 12 per message, an album), audio and files one per message. Order is
|
|
637
|
+
* kept.
|
|
638
|
+
*/
|
|
639
|
+
export function groupMaxMedia(sources: string[]): string[][] {
|
|
640
|
+
const groups: string[][] = [];
|
|
641
|
+
let album: string[] = [];
|
|
642
|
+
const flush = () => {
|
|
643
|
+
if (album.length) groups.push(album);
|
|
644
|
+
album = [];
|
|
645
|
+
};
|
|
646
|
+
for (const source of sources) {
|
|
647
|
+
const kind = mediaKind(source);
|
|
648
|
+
if (kind === 'image' || kind === 'video') {
|
|
649
|
+
if (album.length >= MAX_VISUAL_MEDIA_PER_MESSAGE) flush();
|
|
650
|
+
album.push(source);
|
|
651
|
+
} else {
|
|
652
|
+
flush();
|
|
653
|
+
groups.push([source]);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
flush();
|
|
657
|
+
return groups;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Send several media as few messages as MAX allows (albums of up to 12
|
|
662
|
+
* images/videos). Caption and reply link go on the first message, buttons on
|
|
663
|
+
* the last. Without `onError` the first failure throws; with it, a failed
|
|
664
|
+
* message is reported and the rest are still sent.
|
|
665
|
+
*/
|
|
666
|
+
export async function sendMaxMediaGroup(
|
|
667
|
+
to: string,
|
|
668
|
+
caption: string,
|
|
669
|
+
sources: string[],
|
|
670
|
+
opts: MaxMediaSendOptions & { onError?: (err: unknown, sources: string[]) => void } = {},
|
|
671
|
+
): Promise<{ messageIds: string[] }> {
|
|
672
|
+
const token = resolveToken(opts);
|
|
673
|
+
const api = new MaxApi({ token });
|
|
674
|
+
const { onError, ...sendOpts } = opts;
|
|
675
|
+
const groups = groupMaxMedia(sources);
|
|
676
|
+
const messageIds: string[] = [];
|
|
677
|
+
for (let index = 0; index < groups.length; index += 1) {
|
|
678
|
+
const first = index === 0;
|
|
679
|
+
const last = index === groups.length - 1;
|
|
680
|
+
try {
|
|
681
|
+
const sent = await sendMaxAttachmentsMessage(api, to, first ? caption : '', groups[index], {
|
|
682
|
+
...sendOpts,
|
|
683
|
+
replyToMessageId: first ? sendOpts.replyToMessageId : undefined,
|
|
684
|
+
buttons: last ? sendOpts.buttons : undefined,
|
|
685
|
+
});
|
|
686
|
+
if (sent.messageId) messageIds.push(sent.messageId);
|
|
687
|
+
} catch (err) {
|
|
688
|
+
if (!onError) throw err;
|
|
689
|
+
onError(err, groups[index]);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return { messageIds };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Send a contact attachment to MAX.
|
|
697
|
+
*/
|
|
698
|
+
export async function sendMaxContact(
|
|
699
|
+
to: string,
|
|
700
|
+
contact: { name: string; contactId?: number; vcfPhone?: string; vcfInfo?: string },
|
|
701
|
+
opts: MaxSendOptions = {},
|
|
702
|
+
): Promise<MaxSendOutcome> {
|
|
703
|
+
const api = new MaxApi({ token: resolveToken(opts) });
|
|
704
|
+
|
|
705
|
+
// MAX API requires either contact_id (MAX user_id) or vcf_info (VCard string)
|
|
706
|
+
// Without either, returns 400 "Missing info for contact attachment".
|
|
707
|
+
// Field names follow ContactAttachmentRequestPayload (snake_case).
|
|
708
|
+
const payload: Record<string, unknown> = { name: contact.name };
|
|
709
|
+
if (contact.contactId != null) {
|
|
710
|
+
payload.contact_id = contact.contactId;
|
|
711
|
+
if (contact.vcfPhone) payload.vcf_phone = contact.vcfPhone;
|
|
712
|
+
} else if (contact.vcfInfo) {
|
|
713
|
+
payload.vcf_info = contact.vcfInfo;
|
|
714
|
+
} else {
|
|
715
|
+
// Generate VCard from name + phone
|
|
716
|
+
// Use literal \n escape sequence for JSON serialization
|
|
717
|
+
const vcfParts = ['BEGIN:VCARD', 'VERSION:3.0', `FN:${contact.name}`];
|
|
718
|
+
if (contact.vcfPhone) vcfParts.push(`TEL:${contact.vcfPhone}`);
|
|
719
|
+
vcfParts.push('END:VCARD');
|
|
720
|
+
payload.vcf_info = vcfParts.join('\n');
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return sendWithBody({
|
|
724
|
+
api,
|
|
725
|
+
to,
|
|
726
|
+
opts,
|
|
727
|
+
body: buildMaxBody(opts, undefined, [{ type: 'contact', payload }]),
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Send a location attachment to MAX.
|
|
733
|
+
*/
|
|
734
|
+
export async function sendMaxLocation(
|
|
735
|
+
to: string,
|
|
736
|
+
location: { latitude: number; longitude: number },
|
|
737
|
+
text?: string,
|
|
738
|
+
opts: MaxSendOptions = {},
|
|
739
|
+
): Promise<MaxSendOutcome> {
|
|
740
|
+
const api = new MaxApi({ token: resolveToken(opts) });
|
|
741
|
+
const attachment: MaxAttachment = {
|
|
742
|
+
type: 'location',
|
|
743
|
+
latitude: location.latitude,
|
|
744
|
+
longitude: location.longitude,
|
|
745
|
+
};
|
|
746
|
+
return sendWithBody({
|
|
747
|
+
api,
|
|
748
|
+
to,
|
|
749
|
+
opts,
|
|
750
|
+
body: buildMaxBody(opts, text ?? '', [attachment]),
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Send a sticker to MAX by sticker code.
|
|
756
|
+
* Sticker codes come from incoming sticker attachments (payload.code).
|
|
757
|
+
*/
|
|
758
|
+
export async function sendMaxSticker(
|
|
759
|
+
to: string,
|
|
760
|
+
stickerCode: string,
|
|
761
|
+
opts: MaxSendOptions = {},
|
|
762
|
+
): Promise<MaxSendOutcome> {
|
|
763
|
+
const api = new MaxApi({ token: resolveToken(opts) });
|
|
764
|
+
const stickerAttachment: MaxStickerAttachment = {
|
|
765
|
+
type: 'sticker',
|
|
766
|
+
payload: { code: stickerCode },
|
|
767
|
+
};
|
|
768
|
+
return sendWithBody({
|
|
769
|
+
api,
|
|
770
|
+
to,
|
|
771
|
+
opts,
|
|
772
|
+
body: buildMaxBody(opts, undefined, [stickerAttachment]),
|
|
773
|
+
});
|
|
774
|
+
}
|