@imessaging/max 0.7.0

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/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@imessaging/max",
3
+ "version": "0.7.0",
4
+ "description": "MAX Bot API transport for imessaging",
5
+ "keywords": [
6
+ "max",
7
+ "messaging",
8
+ "transport"
9
+ ],
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/iconicompany/imessaging.git",
14
+ "directory": "packages/max"
15
+ },
16
+ "files": [
17
+ "src"
18
+ ],
19
+ "type": "module",
20
+ "sideEffects": false,
21
+ "exports": {
22
+ ".": "./src/index.ts"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "type-check": "bunx tsgo --noEmit -p tsconfig.json",
29
+ "test": "bun test"
30
+ },
31
+ "dependencies": {
32
+ "@imessaging/core": "0.7.0"
33
+ },
34
+ "peerDependencies": {
35
+ "@maxhub/max-bot-api": ">=0.3.1"
36
+ }
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { MaxTransport, type MaxTransportOptions } from "./max-transport";
@@ -0,0 +1,159 @@
1
+ import type {
2
+ EditableMessage,
3
+ EditableMessageTransport,
4
+ EditResult,
5
+ MessageTransport,
6
+ OutboundButton,
7
+ OutboundDocument,
8
+ OutboundMessage,
9
+ SendResult,
10
+ TransportStatus,
11
+ } from "@imessaging/core";
12
+ import { Bot, Keyboard } from "@maxhub/max-bot-api";
13
+ import { Buffer } from "node:buffer";
14
+
15
+ const DEFAULT_API_BASE_URL = "https://platform-api.max.ru";
16
+
17
+ export type MaxTransportOptions = {
18
+ accountId: string;
19
+ token: string;
20
+ /** Базовый URL API — позволяет переопределить адрес или настроить тестовый сервер. По умолчанию https://platform-api.max.ru */
21
+ apiBaseUrl?: string;
22
+ /** Инжектированный экземпляр Bot — для модульных тестов без сетевых запросов. */
23
+ bot?: Bot;
24
+ };
25
+
26
+ /** Транспорт MAX поверх официального `@maxhub/max-bot-api`. */
27
+ export class MaxTransport implements MessageTransport, EditableMessageTransport {
28
+ readonly id: string;
29
+ private readonly bot: Bot;
30
+ private connected = false;
31
+ private lastActivityAt?: Date;
32
+ private error?: string;
33
+
34
+ constructor(private readonly options: MaxTransportOptions) {
35
+ if (!options.accountId.trim()) throw new Error("accountId must not be empty");
36
+ if (!options.token.trim()) throw new Error("token must not be empty");
37
+ this.id = `max:${options.accountId}`;
38
+ const baseUrl = options.apiBaseUrl ?? DEFAULT_API_BASE_URL;
39
+ this.bot = options.bot ?? new Bot(options.token, { clientOptions: { baseUrl } });
40
+ }
41
+
42
+ /** Проверяет действительность токена, не запуская polling и не читая окружение. */
43
+ async connect(): Promise<void> {
44
+ try {
45
+ await this.bot.api.getMyInfo();
46
+ this.connected = true;
47
+ this.error = undefined;
48
+ } catch (cause) {
49
+ this.connected = false;
50
+ this.error = messageOf(cause);
51
+ throw cause;
52
+ }
53
+ }
54
+
55
+ async disconnect(): Promise<void> {
56
+ this.connected = false;
57
+ }
58
+
59
+ async send(message: OutboundMessage): Promise<SendResult> {
60
+ if (!this.connected) throw new Error(`${this.id} is not connected`);
61
+ if (message.recipient.type !== "max") {
62
+ throw new Error(`${this.id} sends MAX messages and cannot address ${message.recipient.type}`);
63
+ }
64
+ const rawChatId = message.recipient.chatId.trim();
65
+ if (rawChatId.startsWith("+") || !/^-?\d+$/.test(rawChatId)) {
66
+ throw new Error("MAX chatId must be an integer returned by MAX API (not a phone number)");
67
+ }
68
+ const chatId = Number(rawChatId);
69
+ if (!Number.isSafeInteger(chatId)) {
70
+ throw new Error("MAX chatId must be a safe integer returned by MAX API");
71
+ }
72
+
73
+ try {
74
+ const docAttachments = await Promise.all(
75
+ (message.documents ?? []).map((document) => this.upload(document)),
76
+ );
77
+ const keyboardAttachment = mapButtons(message.buttons);
78
+ const attachments = [...docAttachments, ...(keyboardAttachment ? [keyboardAttachment] : [])];
79
+
80
+ const sent = await this.bot.api.sendMessageToChat(chatId, message.text, {
81
+ attachments: attachments.length > 0 ? attachments : undefined,
82
+ format: mapFormat(message.parseMode),
83
+ });
84
+ this.lastActivityAt = new Date();
85
+ return {
86
+ transportId: this.id,
87
+ messageId: sent.body.mid,
88
+ recipientId: message.recipient.chatId,
89
+ };
90
+ } catch (cause) {
91
+ this.error = messageOf(cause);
92
+ throw cause;
93
+ }
94
+ }
95
+
96
+ async edit(message: EditableMessage): Promise<EditResult> {
97
+ if (!this.connected) throw new Error(`${this.id} is not connected`);
98
+ if (message.recipient.type !== "max") {
99
+ throw new Error(`${this.id} sends MAX messages and cannot address ${message.recipient.type}`);
100
+ }
101
+ try {
102
+ const keyboardAttachment = mapButtons(message.buttons);
103
+ await this.bot.api.editMessage(message.messageId, {
104
+ text: message.text,
105
+ attachments: keyboardAttachment ? [keyboardAttachment] : undefined,
106
+ format: mapFormat(message.parseMode),
107
+ });
108
+ this.lastActivityAt = new Date();
109
+ return {
110
+ transportId: this.id,
111
+ messageId: message.messageId,
112
+ recipientId: message.recipient.chatId,
113
+ };
114
+ } catch (cause) {
115
+ this.error = messageOf(cause);
116
+ throw cause;
117
+ }
118
+ }
119
+
120
+ async getStatus(): Promise<TransportStatus> {
121
+ return {
122
+ connected: this.connected,
123
+ transportId: this.id,
124
+ accountId: this.options.accountId,
125
+ lastActivityAt: this.lastActivityAt,
126
+ error: this.error,
127
+ };
128
+ }
129
+
130
+ private async upload(document: OutboundDocument) {
131
+ const source = Buffer.from(document.data);
132
+ const uploaded = document.mimeType?.startsWith("image/")
133
+ ? await this.bot.api.uploadImage({ source })
134
+ : await this.bot.api.uploadFile({ source });
135
+ return uploaded.toJson();
136
+ }
137
+ }
138
+
139
+ function mapFormat(parseMode?: OutboundMessage["parseMode"]): "html" | "markdown" {
140
+ if (parseMode === "Markdown" || parseMode === "MarkdownV2") return "markdown";
141
+ return "html";
142
+ }
143
+
144
+ function mapButtons(buttons?: OutboundButton[][]) {
145
+ if (!buttons || buttons.length === 0) return undefined;
146
+ const keyboardButtons = buttons.map((row) =>
147
+ row.map((button) => {
148
+ if (button.url) {
149
+ return Keyboard.button.link(button.text, button.url);
150
+ }
151
+ return Keyboard.button.callback(button.text, button.callbackData ?? "");
152
+ }),
153
+ );
154
+ return Keyboard.inlineKeyboard(keyboardButtons);
155
+ }
156
+
157
+ function messageOf(cause: unknown): string {
158
+ return cause instanceof Error ? cause.message : String(cause);
159
+ }