@imessaging/avito 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -4
- package/src/avito-transport.ts +520 -0
- package/src/index.ts +1 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imessaging/avito",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Avito Messenger transport for imessaging",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"avito",
|
|
7
|
+
"messaging",
|
|
8
|
+
"messenger",
|
|
9
|
+
"transport"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/iconicompany/imessaging.git",
|
|
15
|
+
"directory": "packages/avito"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"type-check": "bunx tsgo --noEmit -p tsconfig.json",
|
|
30
|
+
"test": "bun test"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@imessaging/core": "^0.8.14"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CursorStore,
|
|
3
|
+
IncomingAttachment,
|
|
4
|
+
IncomingLink,
|
|
5
|
+
IncomingMessage,
|
|
6
|
+
IncomingMessageKind,
|
|
7
|
+
IncomingMessageTransport,
|
|
8
|
+
MessageRecipient,
|
|
9
|
+
MessageTransport,
|
|
10
|
+
OutboundMessage,
|
|
11
|
+
ReadReceiptTransport,
|
|
12
|
+
SelfAccount,
|
|
13
|
+
SelfAwareTransport,
|
|
14
|
+
SendResult,
|
|
15
|
+
TransportStatus,
|
|
16
|
+
} from "@imessaging/core";
|
|
17
|
+
import { formattedOf, MemoryCursorStore, toPlainText } from "@imessaging/core";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_API_BASE_URL = "https://api.avito.ru";
|
|
20
|
+
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
|
21
|
+
/** Пределы API: `limit` до 100, `offset` до 1000. */
|
|
22
|
+
const PAGE_LIMIT = 100;
|
|
23
|
+
const MAX_OFFSET = 1000;
|
|
24
|
+
/** Предел текста сообщения в API Avito. */
|
|
25
|
+
const MAX_TEXT_LENGTH = 1000;
|
|
26
|
+
/** Токен обновляется заранее, чтобы не истёк посреди запроса. */
|
|
27
|
+
const TOKEN_MARGIN_MS = 60_000;
|
|
28
|
+
const VOICE_PREFIX = "voice:";
|
|
29
|
+
|
|
30
|
+
type Handler = (message: IncomingMessage) => void | Promise<void>;
|
|
31
|
+
|
|
32
|
+
export type AvitoTransportOptions = {
|
|
33
|
+
accountId: string;
|
|
34
|
+
/** Учётка приложения Avito (client_credentials). Токен транспорт получает и обновляет сам. */
|
|
35
|
+
clientId: string;
|
|
36
|
+
clientSecret: string;
|
|
37
|
+
/** Номер пользователя Avito. Не задан — берётся из `/core/v1/accounts/self` при connect. */
|
|
38
|
+
userId?: string;
|
|
39
|
+
/** Базовый URL API — позволяет проверить транспорт без сети. */
|
|
40
|
+
apiBaseUrl?: string;
|
|
41
|
+
/** Курсоры опроса чатов. По умолчанию в памяти: после рестарта опрос начинает с текущего момента. */
|
|
42
|
+
cursorStore?: CursorStore;
|
|
43
|
+
/** Пауза между проходами опроса; 0 — без таймера, проход только через `poll()`. */
|
|
44
|
+
pollIntervalMs?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export class AvitoApiError extends Error {
|
|
48
|
+
constructor(
|
|
49
|
+
readonly status: number,
|
|
50
|
+
readonly payload: unknown,
|
|
51
|
+
) {
|
|
52
|
+
super(`Avito API returned HTTP ${status}: ${payloadText(payload)}`);
|
|
53
|
+
this.name = "AvitoApiError";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type AvitoContent = {
|
|
58
|
+
text?: string | null;
|
|
59
|
+
image?: { sizes?: Record<string, string> } | null;
|
|
60
|
+
link?: { text?: string; url?: string } | null;
|
|
61
|
+
item?: { title?: string; item_url?: string } | null;
|
|
62
|
+
location?: { text?: string; title?: string } | null;
|
|
63
|
+
voice?: { voice_id?: string } | null;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
type AvitoMessage = {
|
|
67
|
+
id: string;
|
|
68
|
+
author_id?: number;
|
|
69
|
+
created: number;
|
|
70
|
+
direction?: "in" | "out";
|
|
71
|
+
type?: string;
|
|
72
|
+
content?: AvitoContent;
|
|
73
|
+
quote?: { id?: string } | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type AvitoChat = {
|
|
77
|
+
id: string;
|
|
78
|
+
updated?: number;
|
|
79
|
+
context?: { type?: string; value?: { title?: string } };
|
|
80
|
+
users?: { id?: number; name?: string }[];
|
|
81
|
+
last_message?: { id?: string; created?: number } | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Курсор чата: время последнего выданного сообщения и номера сообщений с этим временем (секунды совпадают). */
|
|
85
|
+
type ChatCursor = { t: number; ids: string[] };
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Avito Messenger: чаты по объявлениям и между пользователями.
|
|
89
|
+
* Входящие — опросом списка чатов или через `handleWebhook`; дубли отсекаются курсором чата.
|
|
90
|
+
* Правки сообщений API Avito не поддерживает.
|
|
91
|
+
*/
|
|
92
|
+
export class AvitoTransport
|
|
93
|
+
implements MessageTransport, IncomingMessageTransport, ReadReceiptTransport, SelfAwareTransport
|
|
94
|
+
{
|
|
95
|
+
readonly id: string;
|
|
96
|
+
private readonly apiBaseUrl: string;
|
|
97
|
+
private readonly cursors: CursorStore;
|
|
98
|
+
private readonly messageHandlers = new Set<Handler>();
|
|
99
|
+
private readonly chats = new Map<string, AvitoChat>();
|
|
100
|
+
private readonly chatLocks = new Map<string, Promise<void>>();
|
|
101
|
+
private userId?: string;
|
|
102
|
+
private me?: SelfAccount;
|
|
103
|
+
private token?: { value: string; expiresAt: number };
|
|
104
|
+
private connected = false;
|
|
105
|
+
private lastActivityAt?: Date;
|
|
106
|
+
private error?: string;
|
|
107
|
+
private pollTimer?: ReturnType<typeof setTimeout>;
|
|
108
|
+
private polling?: Promise<void>;
|
|
109
|
+
|
|
110
|
+
constructor(private readonly options: AvitoTransportOptions) {
|
|
111
|
+
if (!options.accountId.trim()) throw new Error("accountId must not be empty");
|
|
112
|
+
if (!options.clientId.trim()) throw new Error("clientId must not be empty");
|
|
113
|
+
if (!options.clientSecret.trim()) throw new Error("clientSecret must not be empty");
|
|
114
|
+
this.id = `avito:${options.accountId}`;
|
|
115
|
+
this.apiBaseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/+$/u, "");
|
|
116
|
+
this.cursors = options.cursorStore ?? new MemoryCursorStore();
|
|
117
|
+
this.userId = options.userId?.trim() || undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async connect(): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
await this.self();
|
|
123
|
+
this.connected = true;
|
|
124
|
+
this.error = undefined;
|
|
125
|
+
this.schedulePoll(0);
|
|
126
|
+
} catch (cause) {
|
|
127
|
+
this.connected = false;
|
|
128
|
+
this.error = messageOf(cause);
|
|
129
|
+
throw cause;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async disconnect(): Promise<void> {
|
|
134
|
+
this.connected = false;
|
|
135
|
+
clearTimeout(this.pollTimer);
|
|
136
|
+
this.pollTimer = undefined;
|
|
137
|
+
await this.polling;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async self(): Promise<SelfAccount> {
|
|
141
|
+
if (!this.me) {
|
|
142
|
+
const me = await this.request<{ id?: number | string; name?: string }>(
|
|
143
|
+
"/core/v1/accounts/self",
|
|
144
|
+
);
|
|
145
|
+
const id = String(me.id ?? "");
|
|
146
|
+
if (this.userId && this.userId !== id) {
|
|
147
|
+
throw new Error(`Avito token belongs to user ${id}, not configured userId ${this.userId}`);
|
|
148
|
+
}
|
|
149
|
+
this.userId = id;
|
|
150
|
+
this.me = { id, isBot: false };
|
|
151
|
+
}
|
|
152
|
+
return this.me;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async send(message: OutboundMessage): Promise<SendResult> {
|
|
156
|
+
if (!this.connected) throw new Error(`${this.id} is not connected`);
|
|
157
|
+
const chatId = avitoChatId(this.id, message.recipient);
|
|
158
|
+
if (message.documents?.length) throw new Error(`${this.id} does not send documents`);
|
|
159
|
+
const ft = formattedOf(message);
|
|
160
|
+
const text = ft ? toPlainText(ft) : message.text;
|
|
161
|
+
if (!text.trim()) throw new Error("Avito message text must not be empty");
|
|
162
|
+
if (text.length > MAX_TEXT_LENGTH) {
|
|
163
|
+
throw new Error(`Avito message text exceeds ${MAX_TEXT_LENGTH} characters`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const result = await this.request<{ id?: string }>(
|
|
168
|
+
`${this.accountPath("v1")}/chats/${encodeURIComponent(chatId)}/messages`,
|
|
169
|
+
json("POST", { message: { text }, type: "text" }),
|
|
170
|
+
);
|
|
171
|
+
this.lastActivityAt = new Date();
|
|
172
|
+
return { transportId: this.id, messageId: String(result.id ?? ""), recipientId: chatId };
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
this.error = messageOf(cause);
|
|
175
|
+
throw cause;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Avito отмечает прочитанным чат целиком; `messageId` не используется. */
|
|
180
|
+
async markRead(recipient: MessageRecipient, _messageId: string): Promise<void> {
|
|
181
|
+
const chatId = avitoChatId(this.id, recipient);
|
|
182
|
+
await this.request(`${this.accountPath("v1")}/chats/${encodeURIComponent(chatId)}/read`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
onMessage(handler: Handler): () => void {
|
|
188
|
+
this.messageHandlers.add(handler);
|
|
189
|
+
this.schedulePoll(0);
|
|
190
|
+
return () => {
|
|
191
|
+
this.messageHandlers.delete(handler);
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Картинка — прямой адрес; голосовое — `voice:<voice_id>`, адрес выдаёт API. */
|
|
196
|
+
async download(attachment: IncomingAttachment): Promise<Uint8Array> {
|
|
197
|
+
let url = attachment.id;
|
|
198
|
+
if (url.startsWith(VOICE_PREFIX)) {
|
|
199
|
+
const voiceId = url.slice(VOICE_PREFIX.length);
|
|
200
|
+
const files = await this.request<{ voices_urls?: Record<string, string> }>(
|
|
201
|
+
`${this.accountPath("v1")}/getVoiceFiles?voice_ids=${encodeURIComponent(voiceId)}`,
|
|
202
|
+
);
|
|
203
|
+
const voiceUrl = files.voices_urls?.[voiceId];
|
|
204
|
+
if (!voiceUrl) throw new Error(`Avito did not return a URL for voice ${voiceId}`);
|
|
205
|
+
url = voiceUrl;
|
|
206
|
+
}
|
|
207
|
+
const response = await fetch(url);
|
|
208
|
+
if (!response.ok) throw new AvitoApiError(response.status, await readPayload(response));
|
|
209
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Включить вебхуки учётки. Адрес должен отвечать 200 за 2 секунды. */
|
|
213
|
+
async subscribeWebhook(url: string): Promise<void> {
|
|
214
|
+
await this.request("/messenger/v3/webhook", json("POST", { url }));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Тело вебхука v3. Сообщение берётся из API по курсору чата: так опрос и вебхук не выдают его дважды. */
|
|
218
|
+
async handleWebhook(body: unknown): Promise<void> {
|
|
219
|
+
const event = body as { payload?: { type?: string; value?: { chat_id?: string } } };
|
|
220
|
+
const chatId = event?.payload?.value?.chat_id;
|
|
221
|
+
if (event?.payload?.type !== "message" || typeof chatId !== "string" || !chatId) return;
|
|
222
|
+
await this.syncChat(chatId);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Один проход опроса: все чаты, у которых последнее сообщение новее курсора. */
|
|
226
|
+
async poll(): Promise<void> {
|
|
227
|
+
if (this.polling) return this.polling;
|
|
228
|
+
this.polling = this.pollOnce().finally(() => {
|
|
229
|
+
this.polling = undefined;
|
|
230
|
+
});
|
|
231
|
+
return this.polling;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async getStatus(): Promise<TransportStatus> {
|
|
235
|
+
return {
|
|
236
|
+
connected: this.connected,
|
|
237
|
+
transportId: this.id,
|
|
238
|
+
accountId: this.options.accountId,
|
|
239
|
+
lastActivityAt: this.lastActivityAt,
|
|
240
|
+
error: this.error,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private schedulePoll(delayMs: number): void {
|
|
245
|
+
const interval = this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
246
|
+
if (!this.connected || interval <= 0 || this.messageHandlers.size === 0 || this.pollTimer) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.pollTimer = setTimeout(async () => {
|
|
250
|
+
try {
|
|
251
|
+
await this.poll();
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
this.error = messageOf(cause);
|
|
254
|
+
}
|
|
255
|
+
this.pollTimer = undefined;
|
|
256
|
+
this.schedulePoll(interval);
|
|
257
|
+
}, delayMs);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private async pollOnce(): Promise<void> {
|
|
261
|
+
await this.self();
|
|
262
|
+
await this.since();
|
|
263
|
+
for (let offset = 0; offset <= MAX_OFFSET; offset += PAGE_LIMIT) {
|
|
264
|
+
const list = await this.request<{ chats?: AvitoChat[] }>(
|
|
265
|
+
`${this.accountPath("v2")}/chats?chat_types=u2i,u2u&limit=${PAGE_LIMIT}&offset=${offset}`,
|
|
266
|
+
);
|
|
267
|
+
const chats = list.chats ?? [];
|
|
268
|
+
for (const chat of chats) {
|
|
269
|
+
this.chats.set(chat.id, chat);
|
|
270
|
+
const last = chat.last_message;
|
|
271
|
+
if (!last?.id || last.created === undefined) continue;
|
|
272
|
+
const cursor = await this.cursor(chat.id);
|
|
273
|
+
if (cursor && !isNewer(cursor, last.created, last.id)) continue;
|
|
274
|
+
await this.syncChat(chat.id);
|
|
275
|
+
}
|
|
276
|
+
if (chats.length < PAGE_LIMIT) break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Время первого запуска (секунды): без курсора чата старее него ничего не выдаётся. */
|
|
281
|
+
private async since(): Promise<number> {
|
|
282
|
+
const key = `${this.id}:since`;
|
|
283
|
+
const stored = await this.cursors.get(key);
|
|
284
|
+
if (stored) return Number(stored);
|
|
285
|
+
const now = Math.floor(Date.now() / 1000);
|
|
286
|
+
await this.cursors.set(key, String(now));
|
|
287
|
+
return now;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private syncChat(chatId: string): Promise<void> {
|
|
291
|
+
const previous = this.chatLocks.get(chatId) ?? Promise.resolve();
|
|
292
|
+
const next = previous.then(() => this.syncChatNow(chatId));
|
|
293
|
+
const settled = next.catch(() => undefined);
|
|
294
|
+
this.chatLocks.set(chatId, settled);
|
|
295
|
+
void settled.then(() => {
|
|
296
|
+
if (this.chatLocks.get(chatId) === settled) this.chatLocks.delete(chatId);
|
|
297
|
+
});
|
|
298
|
+
return next;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private async syncChatNow(chatId: string): Promise<void> {
|
|
302
|
+
await this.self();
|
|
303
|
+
const cursor = (await this.cursor(chatId)) ?? { t: await this.since(), ids: [] };
|
|
304
|
+
const fresh: AvitoMessage[] = [];
|
|
305
|
+
// Сообщения идут от новых к старым; листаем, пока не дойдём до курсора.
|
|
306
|
+
for (let offset = 0; offset <= MAX_OFFSET; offset += PAGE_LIMIT) {
|
|
307
|
+
const page = await this.request<AvitoMessage[] | { messages?: AvitoMessage[] }>(
|
|
308
|
+
`${this.accountPath("v3")}/chats/${encodeURIComponent(chatId)}/messages/?limit=${PAGE_LIMIT}&offset=${offset}`,
|
|
309
|
+
);
|
|
310
|
+
const messages = Array.isArray(page) ? page : (page.messages ?? []);
|
|
311
|
+
const newer = messages.filter((m) => isNewer(cursor, m.created, m.id));
|
|
312
|
+
fresh.push(...newer);
|
|
313
|
+
if (messages.length < PAGE_LIMIT || newer.length < messages.length) break;
|
|
314
|
+
}
|
|
315
|
+
if (fresh.length === 0) return;
|
|
316
|
+
|
|
317
|
+
const chat = await this.chatInfo(chatId);
|
|
318
|
+
let next = cursor;
|
|
319
|
+
for (const message of fresh.sort((a, b) => a.created - b.created)) {
|
|
320
|
+
if (message.direction === "in" && message.type !== "system" && message.type !== "deleted") {
|
|
321
|
+
await this.dispatch(this.incoming(chatId, chat, message));
|
|
322
|
+
}
|
|
323
|
+
next =
|
|
324
|
+
next.t === message.created
|
|
325
|
+
? { t: next.t, ids: [...next.ids, message.id] }
|
|
326
|
+
: { t: message.created, ids: [message.id] };
|
|
327
|
+
await this.cursors.set(this.cursorKey(chatId), JSON.stringify(next));
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async dispatch(message: IncomingMessage): Promise<void> {
|
|
332
|
+
this.lastActivityAt = new Date();
|
|
333
|
+
for (const handler of this.messageHandlers) {
|
|
334
|
+
try {
|
|
335
|
+
await handler(message);
|
|
336
|
+
} catch (cause) {
|
|
337
|
+
// Сообщение не повторяется: иначе одно «ядовитое» остановило бы чат навсегда.
|
|
338
|
+
this.error = messageOf(cause);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Сведения о чате нужны для имени собеседника и заголовка; вебхук их не несёт. */
|
|
344
|
+
private async chatInfo(chatId: string): Promise<AvitoChat | undefined> {
|
|
345
|
+
const known = this.chats.get(chatId);
|
|
346
|
+
if (known) return known;
|
|
347
|
+
try {
|
|
348
|
+
const chat = await this.request<AvitoChat>(
|
|
349
|
+
`${this.accountPath("v2")}/chats/${encodeURIComponent(chatId)}`,
|
|
350
|
+
);
|
|
351
|
+
this.chats.set(chatId, chat);
|
|
352
|
+
return chat;
|
|
353
|
+
} catch (cause) {
|
|
354
|
+
this.error = messageOf(cause);
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private incoming(
|
|
360
|
+
chatId: string,
|
|
361
|
+
chat: AvitoChat | undefined,
|
|
362
|
+
message: AvitoMessage,
|
|
363
|
+
): IncomingMessage {
|
|
364
|
+
const content = message.content ?? {};
|
|
365
|
+
const sender = chat?.users?.find((user) => user.id === message.author_id);
|
|
366
|
+
const attachments: IncomingAttachment[] = [];
|
|
367
|
+
const image = largestImage(content.image?.sizes);
|
|
368
|
+
if (image) attachments.push({ id: image, filename: "image.jpg", mimeType: "image/jpeg" });
|
|
369
|
+
if (content.voice?.voice_id) {
|
|
370
|
+
attachments.push({ id: `${VOICE_PREFIX}${content.voice.voice_id}`, filename: "voice" });
|
|
371
|
+
}
|
|
372
|
+
const links: IncomingLink[] = [];
|
|
373
|
+
if (content.link?.url)
|
|
374
|
+
links.push({ url: content.link.url, label: content.link.text, origin: "preview" });
|
|
375
|
+
if (content.item?.item_url)
|
|
376
|
+
links.push({ url: content.item.item_url, label: content.item.title, origin: "preview" });
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
transportId: this.id,
|
|
380
|
+
accountId: this.options.accountId,
|
|
381
|
+
chatId,
|
|
382
|
+
chatType: "user",
|
|
383
|
+
senderId: message.author_id === undefined ? null : String(message.author_id),
|
|
384
|
+
senderName: sender?.name,
|
|
385
|
+
chatTitle: chat?.context?.value?.title ?? sender?.name,
|
|
386
|
+
messageId: message.id,
|
|
387
|
+
text:
|
|
388
|
+
content.text ??
|
|
389
|
+
content.link?.text ??
|
|
390
|
+
content.location?.text ??
|
|
391
|
+
content.location?.title ??
|
|
392
|
+
content.item?.title ??
|
|
393
|
+
"",
|
|
394
|
+
kind: kindOf(message.type),
|
|
395
|
+
sentAt: new Date(message.created * 1000),
|
|
396
|
+
outgoing: false,
|
|
397
|
+
attachments,
|
|
398
|
+
links: links.length > 0 ? links : undefined,
|
|
399
|
+
replyToMessageId: message.quote?.id,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private async cursor(chatId: string): Promise<ChatCursor | null> {
|
|
404
|
+
const stored = await this.cursors.get(this.cursorKey(chatId));
|
|
405
|
+
return stored ? (JSON.parse(stored) as ChatCursor) : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private cursorKey(chatId: string): string {
|
|
409
|
+
return `${this.id}:chat:${chatId}`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private accountPath(version: "v1" | "v2" | "v3"): string {
|
|
413
|
+
if (!this.userId) throw new Error(`${this.id} has no userId: call connect() first`);
|
|
414
|
+
return `/messenger/${version}/accounts/${encodeURIComponent(this.userId)}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private async request<T = Record<string, never>>(path: string, init?: RequestInit): Promise<T> {
|
|
418
|
+
const attempt = async () =>
|
|
419
|
+
fetch(`${this.apiBaseUrl}${path}`, {
|
|
420
|
+
...init,
|
|
421
|
+
headers: { Authorization: `Bearer ${await this.accessToken()}`, ...init?.headers },
|
|
422
|
+
});
|
|
423
|
+
let response = await attempt();
|
|
424
|
+
if (response.status === 401) {
|
|
425
|
+
// Токен отозван раньше срока: получить новый и повторить один раз.
|
|
426
|
+
await response.body?.cancel();
|
|
427
|
+
this.token = undefined;
|
|
428
|
+
response = await attempt();
|
|
429
|
+
}
|
|
430
|
+
const payload = await readPayload(response);
|
|
431
|
+
if (!response.ok) throw new AvitoApiError(response.status, payload);
|
|
432
|
+
return payload as T;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private async accessToken(): Promise<string> {
|
|
436
|
+
if (this.token && this.token.expiresAt > Date.now() + TOKEN_MARGIN_MS) return this.token.value;
|
|
437
|
+
const response = await fetch(`${this.apiBaseUrl}/token`, {
|
|
438
|
+
method: "POST",
|
|
439
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
440
|
+
body: new URLSearchParams({
|
|
441
|
+
grant_type: "client_credentials",
|
|
442
|
+
client_id: this.options.clientId,
|
|
443
|
+
client_secret: this.options.clientSecret,
|
|
444
|
+
}).toString(),
|
|
445
|
+
});
|
|
446
|
+
const payload = (await readPayload(response)) as { access_token?: string; expires_in?: number };
|
|
447
|
+
if (!response.ok || !payload.access_token) throw new AvitoApiError(response.status, payload);
|
|
448
|
+
this.token = {
|
|
449
|
+
value: payload.access_token,
|
|
450
|
+
expiresAt: Date.now() + (payload.expires_in ?? 86_400) * 1000,
|
|
451
|
+
};
|
|
452
|
+
return this.token.value;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function avitoChatId(transportId: string, recipient: MessageRecipient): string {
|
|
457
|
+
// Ответ на входящее: шлюз адресует `{ type: "user", id: message.chatId }`, как в Telegram и MAX.
|
|
458
|
+
if (recipient.type !== "avito" && recipient.type !== "user") {
|
|
459
|
+
throw new Error(`${transportId} sends Avito messages and cannot address ${recipient.type}`);
|
|
460
|
+
}
|
|
461
|
+
const chatId = ("id" in recipient ? recipient.id : recipient.chatId).trim();
|
|
462
|
+
if (!chatId) throw new Error("Avito chatId must not be empty");
|
|
463
|
+
return chatId;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isNewer(cursor: ChatCursor, created: number, id: string): boolean {
|
|
467
|
+
return created > cursor.t || (created === cursor.t && !cursor.ids.includes(id));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function kindOf(type: string | undefined): IncomingMessageKind {
|
|
471
|
+
switch (type) {
|
|
472
|
+
case "text":
|
|
473
|
+
case "link":
|
|
474
|
+
return "text";
|
|
475
|
+
case "image":
|
|
476
|
+
return "photo";
|
|
477
|
+
case "voice":
|
|
478
|
+
return "voice";
|
|
479
|
+
case "location":
|
|
480
|
+
return "geo";
|
|
481
|
+
case undefined:
|
|
482
|
+
return "unsupported";
|
|
483
|
+
default:
|
|
484
|
+
return "other";
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Ключи вида `1280x960`; берётся самая большая картинка. */
|
|
489
|
+
function largestImage(sizes: Record<string, string> | undefined): string | undefined {
|
|
490
|
+
if (!sizes) return undefined;
|
|
491
|
+
const area = (key: string) => key.split("x").reduce((product, side) => product * Number(side), 1);
|
|
492
|
+
const best = Object.keys(sizes).sort((a, b) => area(b) - area(a))[0];
|
|
493
|
+
return best ? sizes[best] : undefined;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function json(method: string, body: unknown): RequestInit {
|
|
497
|
+
return {
|
|
498
|
+
method,
|
|
499
|
+
headers: { "Content-Type": "application/json" },
|
|
500
|
+
body: JSON.stringify(body),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function readPayload(response: Response): Promise<unknown> {
|
|
505
|
+
const text = await response.text();
|
|
506
|
+
if (!text) return {};
|
|
507
|
+
try {
|
|
508
|
+
return JSON.parse(text) as unknown;
|
|
509
|
+
} catch {
|
|
510
|
+
return text;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function payloadText(value: unknown): string {
|
|
515
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function messageOf(cause: unknown): string {
|
|
519
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
520
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AvitoApiError, AvitoTransport, type AvitoTransportOptions } from "./avito-transport";
|
package/README.md
DELETED