@jackle.dev/zalox-plugin 1.0.23 → 1.0.24
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 +1 -1
- package/src/listener.ts +6 -267
package/package.json
CHANGED
package/src/listener.ts
CHANGED
|
@@ -62,6 +62,7 @@ async function downloadZaloMedia(url: string, profile: string, filename?: string
|
|
|
62
62
|
const credentialsPath = resolveCredentialsPath(profile);
|
|
63
63
|
const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8'));
|
|
64
64
|
|
|
65
|
+
// Zalo media URLs usually require cookies
|
|
65
66
|
const headers: Record<string, string> = {
|
|
66
67
|
'User-Agent': creds.userAgent || 'Mozilla/5.0',
|
|
67
68
|
'Cookie': typeof creds.cookie === 'object' ? JSON.stringify(creds.cookie) : String(creds.cookie),
|
|
@@ -76,6 +77,7 @@ async function downloadZaloMedia(url: string, profile: string, filename?: string
|
|
|
76
77
|
const contentType = res.headers.get('content-type');
|
|
77
78
|
const buffer = await res.arrayBuffer();
|
|
78
79
|
|
|
80
|
+
// Determine extension
|
|
79
81
|
let ext = extname(url).split('?')[0];
|
|
80
82
|
if (!ext || ext.length > 5) {
|
|
81
83
|
if (contentType?.includes('jpeg')) ext = '.jpg';
|
|
@@ -117,11 +119,13 @@ async function processMessage(
|
|
|
117
119
|
const core = getRuntime();
|
|
118
120
|
const { threadId, content, msgType, timestamp, isGroup, senderId, senderName, groupName, mediaUrl } = message;
|
|
119
121
|
|
|
122
|
+
// Allow empty content if it's an image
|
|
120
123
|
if (!content?.trim() && !mediaUrl) return;
|
|
121
124
|
|
|
122
125
|
const chatId = threadId;
|
|
123
126
|
const rawBody = content.trim();
|
|
124
127
|
|
|
128
|
+
// Handle media download
|
|
125
129
|
let mediaPath: string | undefined;
|
|
126
130
|
if (mediaUrl) {
|
|
127
131
|
const downloaded = await downloadZaloMedia(mediaUrl, profile);
|
|
@@ -133,6 +137,7 @@ async function processMessage(
|
|
|
133
137
|
}
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
// DM policy check
|
|
136
141
|
const configAllowFrom = (account.config.allowFrom ?? []).map(String);
|
|
137
142
|
const dmPolicy = account.config.dmPolicy ?? 'pairing';
|
|
138
143
|
|
|
@@ -164,270 +169,4 @@ async function processMessage(
|
|
|
164
169
|
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
165
170
|
channel: 'zalox',
|
|
166
171
|
id: senderId,
|
|
167
|
-
meta: { name: senderName || undefined },
|
|
168
|
-
});
|
|
169
|
-
if (created) {
|
|
170
|
-
runtime.log?.(`[zalox] pairing request sender=${senderId}`);
|
|
171
|
-
try {
|
|
172
|
-
await sendText(
|
|
173
|
-
chatId,
|
|
174
|
-
core.channel.pairing.buildPairingReply({
|
|
175
|
-
channel: 'zalox',
|
|
176
|
-
idLine: `Your Zalo user id: ${senderId}`,
|
|
177
|
-
code,
|
|
178
|
-
}),
|
|
179
|
-
{ profile },
|
|
180
|
-
);
|
|
181
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
182
|
-
} catch (err) {
|
|
183
|
-
runtime.error?.(`[zalox] pairing reply failed: ${String(err)}`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
isGroup &&
|
|
194
|
-
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
|
195
|
-
commandAuthorized !== true
|
|
196
|
-
) {
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const peer = isGroup
|
|
201
|
-
? { kind: 'group' as const, id: chatId }
|
|
202
|
-
: { kind: 'dm' as const, id: senderId };
|
|
203
|
-
|
|
204
|
-
const route = core.channel.routing.resolveAgentRoute({
|
|
205
|
-
cfg: config,
|
|
206
|
-
channel: 'zalox',
|
|
207
|
-
accountId: account.accountId,
|
|
208
|
-
peer,
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
|
212
|
-
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
213
|
-
agentId: route.agentId,
|
|
214
|
-
});
|
|
215
|
-
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
216
|
-
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
217
|
-
storePath,
|
|
218
|
-
sessionKey: route.sessionKey,
|
|
219
|
-
});
|
|
220
|
-
const body = core.channel.reply.formatAgentEnvelope({
|
|
221
|
-
channel: 'Zalo (ZaloX)',
|
|
222
|
-
from: fromLabel,
|
|
223
|
-
timestamp: timestamp ? timestamp * 1000 : undefined,
|
|
224
|
-
previousTimestamp,
|
|
225
|
-
envelope: envelopeOptions,
|
|
226
|
-
body: rawBody || (mediaPath ? '[Image]' : ''),
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
230
|
-
Body: body,
|
|
231
|
-
RawBody: rawBody,
|
|
232
|
-
CommandBody: rawBody,
|
|
233
|
-
From: isGroup ? `zalox:group:${chatId}` : `zalox:${senderId}`,
|
|
234
|
-
To: `zalox:${chatId}`,
|
|
235
|
-
SessionKey: route.sessionKey,
|
|
236
|
-
AccountId: route.accountId,
|
|
237
|
-
ChatType: isGroup ? 'group' : 'direct',
|
|
238
|
-
ConversationLabel: fromLabel,
|
|
239
|
-
SenderName: senderName || undefined,
|
|
240
|
-
SenderId: senderId,
|
|
241
|
-
CommandAuthorized: commandAuthorized,
|
|
242
|
-
Provider: 'zalox',
|
|
243
|
-
Surface: 'zalox',
|
|
244
|
-
MessageSid: message.msgId ?? `${timestamp}`,
|
|
245
|
-
OriginatingChannel: 'zalox',
|
|
246
|
-
OriginatingTo: `zalox:${chatId}`,
|
|
247
|
-
MediaPath: mediaPath,
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
await core.channel.session.recordInboundSession({
|
|
251
|
-
storePath,
|
|
252
|
-
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
253
|
-
ctx: ctxPayload,
|
|
254
|
-
onRecordError: (err: unknown) => {
|
|
255
|
-
runtime.error?.(`[zalox] session meta error: ${String(err)}`);
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
260
|
-
ctx: ctxPayload,
|
|
261
|
-
cfg: config,
|
|
262
|
-
dispatcherOptions: {
|
|
263
|
-
deliver: async (payload: any) => {
|
|
264
|
-
const text = payload.text ?? '';
|
|
265
|
-
if (!text) return;
|
|
266
|
-
|
|
267
|
-
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
268
|
-
cfg: config,
|
|
269
|
-
channel: 'zalox',
|
|
270
|
-
accountId: account.accountId,
|
|
271
|
-
});
|
|
272
|
-
const converted = core.channel.text.convertMarkdownTables(text, tableMode ?? 'code');
|
|
273
|
-
const chunkMode = core.channel.text.resolveChunkMode(config, 'zalox', account.accountId);
|
|
274
|
-
const chunks = core.channel.text.chunkMarkdownTextWithMode(converted, 2000, chunkMode);
|
|
275
|
-
|
|
276
|
-
for (const chunk of chunks) {
|
|
277
|
-
try {
|
|
278
|
-
await sendText(chatId, chunk, { profile, isGroup });
|
|
279
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
280
|
-
} catch (err) {
|
|
281
|
-
runtime.error?.(`[zalox] send failed: ${String(err)}`);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
},
|
|
285
|
-
onError: (err: unknown, info: any) => {
|
|
286
|
-
runtime.error?.(`[${account.accountId}] ZaloX ${info.kind} reply failed: ${String(err)}`);
|
|
287
|
-
},
|
|
288
|
-
},
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
export async function startInProcessListener(
|
|
293
|
-
options: ListenerOptions,
|
|
294
|
-
): Promise<{ stop: () => void }> {
|
|
295
|
-
const { account, config, runtime, api, ownId, profile, abortSignal, statusSink } = options;
|
|
296
|
-
let stopped = false;
|
|
297
|
-
|
|
298
|
-
const listener = api.listener;
|
|
299
|
-
|
|
300
|
-
listener.on('message', (msg: any) => {
|
|
301
|
-
if (stopped || abortSignal.aborted) return;
|
|
302
|
-
|
|
303
|
-
const data = msg.data || msg;
|
|
304
|
-
const isGroup = msg.type === 1;
|
|
305
|
-
const senderId = data.uidFrom ? String(data.uidFrom) : '';
|
|
306
|
-
|
|
307
|
-
if (senderId === ownId) return;
|
|
308
|
-
|
|
309
|
-
const threadId = isGroup
|
|
310
|
-
? String(data.idTo || data.threadId || '')
|
|
311
|
-
: senderId;
|
|
312
|
-
|
|
313
|
-
const msgType = data.msgType || 0;
|
|
314
|
-
|
|
315
|
-
let mediaUrl = undefined;
|
|
316
|
-
mediaUrl = data.url || data.href;
|
|
317
|
-
if (!mediaUrl && (msgType === 2 || String(msgType) === 'chat.photo')) {
|
|
318
|
-
if (data.params?.url) mediaUrl = data.params.url;
|
|
319
|
-
else if (typeof data.content === 'object' && data.content !== null) {
|
|
320
|
-
mediaUrl = (data.content as any).href || (data.content as any).url || (data.content as any).thumb || (data.content as any).normalUrl;
|
|
321
|
-
}
|
|
322
|
-
else if (typeof data.content === 'string' && (data.content.includes('http') || data.content.startsWith('{'))) {
|
|
323
|
-
try {
|
|
324
|
-
const parsed = JSON.parse(data.content);
|
|
325
|
-
mediaUrl = parsed.href || parsed.url || parsed.thumb || parsed.normalUrl;
|
|
326
|
-
} catch {
|
|
327
|
-
if (data.content.startsWith('http')) mediaUrl = data.content;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
if (!mediaUrl && data.url && (data.url.includes('.jpg') || data.url.includes('.png'))) {
|
|
332
|
-
mediaUrl = data.url;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
if (!mediaUrl && data.quote) {
|
|
336
|
-
try {
|
|
337
|
-
const q = data.quote;
|
|
338
|
-
if (q.attach) {
|
|
339
|
-
const attach = typeof q.attach === 'string' ? JSON.parse(q.attach) : q.attach;
|
|
340
|
-
mediaUrl = attach.href || attach.url || attach.thumb || attach.normalUrl;
|
|
341
|
-
}
|
|
342
|
-
if (!mediaUrl && (q.href || q.url)) {
|
|
343
|
-
mediaUrl = q.href || q.url;
|
|
344
|
-
}
|
|
345
|
-
} catch {}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (isGroup) {
|
|
349
|
-
const mentions = data.mentions || data.mentionIds || [];
|
|
350
|
-
const content = typeof data.content === 'string' ? data.content : (data.msg || '');
|
|
351
|
-
|
|
352
|
-
const isMentioned = Array.isArray(mentions)
|
|
353
|
-
? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)
|
|
354
|
-
: false;
|
|
355
|
-
const name = account.name || 'Bot';
|
|
356
|
-
|
|
357
|
-
const textMention =
|
|
358
|
-
content.includes(`@${name}`) ||
|
|
359
|
-
content.includes('@Bot') ||
|
|
360
|
-
content.includes('@Javis');
|
|
361
|
-
|
|
362
|
-
console.log(`[ZaloX] Group Check: ownId=${ownId} mentions=${JSON.stringify(mentions)} textMention=${textMention} content="${content.slice(0, 50)}..."`);
|
|
363
|
-
|
|
364
|
-
if (content.trim() === '/whoami') {
|
|
365
|
-
// Pass
|
|
366
|
-
} else if (!isMentioned && !textMention && !content.startsWith('/')) {
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
statusSink?.({ lastInboundAt: Date.now() });
|
|
372
|
-
|
|
373
|
-
const normalized = {
|
|
374
|
-
threadId,
|
|
375
|
-
msgId: String(data.msgId || data.cliMsgId || ''),
|
|
376
|
-
content: (() => {
|
|
377
|
-
let c = data.content || data.msg || '';
|
|
378
|
-
if (typeof c !== 'string') return '';
|
|
379
|
-
if (isGroup) {
|
|
380
|
-
const name = account.name || 'Bot';
|
|
381
|
-
// SAFE CLEANUP: No Regex
|
|
382
|
-
if (c.includes(`@${name}`)) c = c.split(`@${name}`).join('').trim();
|
|
383
|
-
if (c.includes('@Javis')) c = c.split('@Javis').join('').trim();
|
|
384
|
-
if (c.includes('@Bot')) c = c.split('@Bot').join('').trim();
|
|
385
|
-
}
|
|
386
|
-
return c;
|
|
387
|
-
})(),
|
|
388
|
-
msgType,
|
|
389
|
-
mediaUrl,
|
|
390
|
-
timestamp: data.ts ? Math.floor(data.ts / 1000) : Math.floor(Date.now() / 1000),
|
|
391
|
-
isGroup,
|
|
392
|
-
senderId,
|
|
393
|
-
senderName: data.dName || data.senderName || undefined,
|
|
394
|
-
groupName: data.threadName || data.groupName || undefined,
|
|
395
|
-
};
|
|
396
|
-
|
|
397
|
-
if (!normalized.content?.trim() && !normalized.mediaUrl) return;
|
|
398
|
-
|
|
399
|
-
try {
|
|
400
|
-
const reactThreadType = isGroup ? ThreadType.Group : ThreadType.User;
|
|
401
|
-
api.addReaction(Reactions.HEART, {
|
|
402
|
-
threadId: normalized.threadId,
|
|
403
|
-
type: reactThreadType,
|
|
404
|
-
data: {
|
|
405
|
-
msgId: String(data.msgId || ''),
|
|
406
|
-
cliMsgId: String(data.cliMsgId || ''),
|
|
407
|
-
},
|
|
408
|
-
}).catch(() => {});
|
|
409
|
-
} catch {}
|
|
410
|
-
|
|
411
|
-
processMessage(normalized, account, config, runtime, profile, statusSink).catch((err) => {
|
|
412
|
-
runtime.error?.(`[${account.accountId}] ZaloX process error: ${String(err)}`);
|
|
413
|
-
});
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
listener.on('error', (err: any) => {
|
|
417
|
-
runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
listener.start({ retryOnClose: true });
|
|
421
|
-
runtime.log?.(`[${account.accountId}] ZaloX: WebSocket listener started`);
|
|
422
|
-
|
|
423
|
-
const stop = () => {
|
|
424
|
-
stopped = true;
|
|
425
|
-
try {
|
|
426
|
-
listener.stop();
|
|
427
|
-
} catch {}
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
abortSignal.addEventListener('abort', stop, { once: true });
|
|
431
|
-
|
|
432
|
-
return { stop };
|
|
433
|
-
}
|
|
172
|
+
meta: { name: senderName || undefined },\n });\n if (created) {\n runtime.log?.(`[zalox] pairing request sender=${senderId}`);\n try {\n await sendText(\n chatId,\n core.channel.pairing.buildPairingReply({\n channel: 'zalox',\n idLine: `Your Zalo user id: ${senderId}`,\n code,\n }),\n { profile },\n );\n statusSink?.({ lastOutboundAt: Date.now() });\n } catch (err) {\n runtime.error?.(`[zalox] pairing reply failed: ${String(err)}`);\n }\n }\n }\n return;\n }\n }\n }\n\n // Group command authorization\n if (\n isGroup &&\n core.channel.commands.isControlCommandMessage(rawBody, config) &&\n commandAuthorized !== true\n ) {\n return;\n }\n\n // Route to agent\n const peer = isGroup\n ? { kind: 'group' as const, id: chatId }\n : { kind: 'dm' as const, id: senderId };\n\n const route = core.channel.routing.resolveAgentRoute({\n cfg: config,\n channel: 'zalox',\n accountId: account.accountId,\n peer,\n });\n\n const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;\n const storePath = core.channel.session.resolveStorePath(config.session?.store, {\n agentId: route.agentId,\n });\n const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);\n const previousTimestamp = core.channel.session.readSessionUpdatedAt({\n storePath,\n sessionKey: route.sessionKey,\n });\n const body = core.channel.reply.formatAgentEnvelope({\n channel: 'Zalo (ZaloX)',\n from: fromLabel,\n timestamp: timestamp ? timestamp * 1000 : undefined,\n previousTimestamp,\n envelope: envelopeOptions,\n body: rawBody || (mediaPath ? '[Image]' : ''),\n });\n\n const ctxPayload = core.channel.reply.finalizeInboundContext({\n Body: body,\n RawBody: rawBody,\n CommandBody: rawBody,\n From: isGroup ? `zalox:group:${chatId}` : `zalox:${senderId}`,\n To: `zalox:${chatId}`,\n SessionKey: route.sessionKey,\n AccountId: route.accountId,\n ChatType: isGroup ? 'group' : 'direct',\n ConversationLabel: fromLabel,\n SenderName: senderName || undefined,\n SenderId: senderId,\n CommandAuthorized: commandAuthorized,\n Provider: 'zalox',\n Surface: 'zalox',\n MessageSid: message.msgId ?? `${timestamp}`,\n OriginatingChannel: 'zalox',\n OriginatingTo: `zalox:${chatId}`,\n MediaPath: mediaPath, // Pass media path here\n });\n\n await core.channel.session.recordInboundSession({\n storePath,\n sessionKey: ctxPayload.SessionKey ?? route.sessionKey,\n ctx: ctxPayload,\n onRecordError: (err: unknown) => {\n runtime.error?.(`[zalox] session meta error: ${String(err)}`);\n },\n });\n\n await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({\n ctx: ctxPayload,\n cfg: config,\n dispatcherOptions: {\n deliver: async (payload: any) => {\n const text = payload.text ?? '';\n if (!text) return;\n\n const tableMode = core.channel.text.resolveMarkdownTableMode({\n cfg: config,\n channel: 'zalox',\n accountId: account.accountId,\n });\n const converted = core.channel.text.convertMarkdownTables(text, tableMode ?? 'code');\n const chunkMode = core.channel.text.resolveChunkMode(config, 'zalox', account.accountId);\n const chunks = core.channel.text.chunkMarkdownTextWithMode(converted, 2000, chunkMode);\n\n for (const chunk of chunks) {\n try {\n await sendText(chatId, chunk, { profile, isGroup });\n statusSink?.({ lastOutboundAt: Date.now() });\n } catch (err) {\n runtime.error?.(`[zalox] send failed: ${String(err)}`);\n }\n }\n },\n onError: (err: unknown, info: any) => {\n runtime.error?.(`[${account.accountId}] ZaloX ${info.kind} reply failed: ${String(err)}`);\n },\n },\n });\n}\n\nexport async function startInProcessListener(\n options: ListenerOptions,\n): Promise<{ stop: () => void }> {\n const { account, config, runtime, api, ownId, profile, abortSignal, statusSink } = options;\n let stopped = false;\n\n const listener = api.listener;\n\n // Handle incoming messages\n listener.on('message', (msg: any) => {\n if (stopped || abortSignal.aborted) return;\n\n const data = msg.data || msg;\n const isGroup = msg.type === 1;\n const senderId = data.uidFrom ? String(data.uidFrom) : '';\n \n // Skip own messages\n if (senderId === ownId) return;\n\n const threadId = isGroup\n ? String(data.idTo || data.threadId || '')\n : senderId;\n\n const msgType = data.msgType || 0;\n \n let mediaUrl = undefined;\n // Prioritize high-res URL if available\n mediaUrl = data.url || data.href;\n if (!mediaUrl && (msgType === 2 || String(msgType) === 'chat.photo')) {\n // Try to find URL in params or content\n if (data.params?.url) mediaUrl = data.params.url;\n else if (typeof data.content === 'object' && data.content !== null) {\n // Already parsed object\n mediaUrl = (data.content as any).href || (data.content as any).url || (data.content as any).thumb || (data.content as any).normalUrl;\n }\n else if (typeof data.content === 'string' && (data.content.includes('http') || data.content.startsWith('{'))) {\n try {\n const parsed = JSON.parse(data.content);\n mediaUrl = parsed.href || parsed.url || parsed.thumb || parsed.normalUrl;\n } catch {\n // Maybe content IS the url?\n if (data.content.startsWith('http')) mediaUrl = data.content;\n }\n }\n }\n // Also check for image extension if URL exists but type != 2\n if (!mediaUrl && data.url && (data.url.includes('.jpg') || data.url.includes('.png'))) {\n mediaUrl = data.url;\n }\n\n // Check quote/reply for media\n if (!mediaUrl && data.quote) {\n try {\n const q = data.quote;\n if (q.attach) {\n const attach = typeof q.attach === 'string' ? JSON.parse(q.attach) : q.attach;\n mediaUrl = attach.href || attach.url || attach.thumb || attach.normalUrl;\n }\n if (!mediaUrl && (q.href || q.url)) {\n mediaUrl = q.href || q.url;\n }\n } catch {} \n }\n\n // Group messages: only respond when bot is mentioned (@tagged)\n if (isGroup) {\n const mentions = data.mentions || data.mentionIds || [];\n const content = typeof data.content === 'string' ? data.content : (data.msg || '');\n \n const isMentioned = Array.isArray(mentions)\n ? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)\n : false;\n const name = account.name || 'Bot';\n \n // V1.0.24 FIX: Restore @Tiệp Lê check (Safe string includes)\n const textMention = \n content.includes(`@${name}`) || \n content.includes('@Bot') || \n content.includes('@Javis') ||\n content.includes('@Tiệp Lê');\n \n // DEBUG Group logic\n console.log(`[ZaloX] Group Check: ownId=${ownId} mentions=${JSON.stringify(mentions)} textMention=${textMention} content=\"${content.slice(0, 50)}...\"`);\n\n // Allow /whoami bypass\n if (content.trim() === '/whoami') {\n // Pass\n } else if (!isMentioned && !textMention && !content.startsWith('/')) {\n return;\n }\n }\n\n statusSink?.({ lastInboundAt: Date.now() });\n\n const normalized = {\n threadId,\n msgId: String(data.msgId || data.cliMsgId || ''),\n content: (() => {\n let c = data.content || data.msg || '';\n if (typeof c !== 'string') return '';\n if (isGroup) {\n const name = account.name || 'Bot';\n // SAFE CLEANUP: No Regex\n if (c.includes(`@${name}`)) c = c.split(`@${name}`).join('').trim();\n if (c.includes('@Javis')) c = c.split('@Javis').join('').trim();\n if (c.includes('@Bot')) c = c.split('@Bot').join('').trim();\n if (c.includes('@Tiệp Lê')) c = c.split('@Tiệp Lê').join('').trim(); // Cleanup\n }\n return c;\n })(),\n msgType,\n mediaUrl,\n timestamp: data.ts ? Math.floor(data.ts / 1000) : Math.floor(Date.now() / 1000),\n isGroup,\n senderId,\n senderName: data.dName || data.senderName || undefined,\n groupName: data.threadName || data.groupName || undefined,\n };\n\n if (!normalized.content?.trim() && !normalized.mediaUrl) return;\n\n // React with ❤️ to confirm reception\n try {\n const reactThreadType = isGroup ? ThreadType.Group : ThreadType.User;\n api.addReaction(Reactions.HEART, {\n threadId: normalized.threadId,\n type: reactThreadType,\n data: {\n msgId: String(data.msgId || ''),\n cliMsgId: String(data.cliMsgId || ''),\n },\n }).catch(() => {});\n } catch {}\n\n processMessage(normalized, account, config, runtime, profile, statusSink).catch((err) => {\n runtime.error?.(`[${account.accountId}] ZaloX process error: ${String(err)}`);\n });\n });\n\n // Handle errors\n listener.on('error', (err: any) => {\n runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);\n });\n\n // Start WebSocket listener with retry\n listener.start({ retryOnClose: true });\n runtime.log?.(`[${account.accountId}] ZaloX: WebSocket listener started`);\n\n const stop = () => {\n stopped = true;\n try {\n listener.stop();\n } catch {}\n };\n\n abortSignal.addEventListener('abort', stop, { once: true });\n\n return { stop };\n}\n
|