@jackle.dev/zalox-plugin 1.0.18 → 1.0.20
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 +2 -291
- package/src/send.ts +17 -9
package/package.json
CHANGED
package/src/listener.ts
CHANGED
|
@@ -80,9 +80,7 @@ async function downloadZaloMedia(url: string, profile: string, filename?: string
|
|
|
80
80
|
// Determine extension
|
|
81
81
|
let ext = extname(url).split('?')[0];
|
|
82
82
|
if (!ext || ext.length > 5) {
|
|
83
|
-
if (contentType?.includes('jpeg')) ext = '.jpg';
|
|
84
|
-
else if (contentType?.includes('png')) ext = '.png';
|
|
85
|
-
else ext = '.jpg';
|
|
83
|
+
if (contentType?.includes('jpeg')) ext = '.jpg';\n else if (contentType?.includes('png')) ext = '.png';\n else ext = '.jpg';
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
const name = filename || `zalox-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
|
|
@@ -151,291 +149,4 @@ async function processMessage(
|
|
|
151
149
|
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
|
152
150
|
const commandAuthorized = shouldComputeAuth
|
|
153
151
|
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
154
|
-
useAccessGroups,
|
|
155
|
-
authorizers: [
|
|
156
|
-
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
157
|
-
],
|
|
158
|
-
})
|
|
159
|
-
: undefined;
|
|
160
|
-
|
|
161
|
-
if (!isGroup) {
|
|
162
|
-
if (dmPolicy === 'disabled') {
|
|
163
|
-
runtime.log?.(`[zalox] Blocked DM from ${senderId} (dmPolicy=disabled)`);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
if (dmPolicy !== 'open') {
|
|
167
|
-
if (!senderAllowedForCommands) {
|
|
168
|
-
if (dmPolicy === 'pairing') {
|
|
169
|
-
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
170
|
-
channel: 'zalox',
|
|
171
|
-
id: senderId,
|
|
172
|
-
meta: { name: senderName || undefined },
|
|
173
|
-
});
|
|
174
|
-
if (created) {
|
|
175
|
-
runtime.log?.(`[zalox] pairing request sender=${senderId}`);
|
|
176
|
-
try {
|
|
177
|
-
await sendText(
|
|
178
|
-
chatId,
|
|
179
|
-
core.channel.pairing.buildPairingReply({
|
|
180
|
-
channel: 'zalox',
|
|
181
|
-
idLine: `Your Zalo user id: ${senderId}`,
|
|
182
|
-
code,
|
|
183
|
-
}),
|
|
184
|
-
{ profile },
|
|
185
|
-
);
|
|
186
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
187
|
-
} catch (err) {
|
|
188
|
-
runtime.error?.(`[zalox] pairing reply failed: ${String(err)}`);
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Group command authorization
|
|
198
|
-
if (
|
|
199
|
-
isGroup &&
|
|
200
|
-
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
|
201
|
-
commandAuthorized !== true
|
|
202
|
-
) {
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Route to agent
|
|
207
|
-
const peer = isGroup
|
|
208
|
-
? { kind: 'group' as const, id: chatId }
|
|
209
|
-
: { kind: 'dm' as const, id: senderId };
|
|
210
|
-
|
|
211
|
-
const route = core.channel.routing.resolveAgentRoute({
|
|
212
|
-
cfg: config,
|
|
213
|
-
channel: 'zalox',
|
|
214
|
-
accountId: account.accountId,
|
|
215
|
-
peer,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
|
219
|
-
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
|
220
|
-
agentId: route.agentId,
|
|
221
|
-
});
|
|
222
|
-
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
|
223
|
-
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
224
|
-
storePath,
|
|
225
|
-
sessionKey: route.sessionKey,
|
|
226
|
-
});
|
|
227
|
-
const body = core.channel.reply.formatAgentEnvelope({
|
|
228
|
-
channel: 'Zalo (ZaloX)',
|
|
229
|
-
from: fromLabel,
|
|
230
|
-
timestamp: timestamp ? timestamp * 1000 : undefined,
|
|
231
|
-
previousTimestamp,
|
|
232
|
-
envelope: envelopeOptions,
|
|
233
|
-
body: rawBody || (mediaPath ? '[Image]' : ''),
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
237
|
-
Body: body,
|
|
238
|
-
RawBody: rawBody,
|
|
239
|
-
CommandBody: rawBody,
|
|
240
|
-
From: isGroup ? `zalox:group:${chatId}` : `zalox:${senderId}`,
|
|
241
|
-
To: `zalox:${chatId}`,
|
|
242
|
-
SessionKey: route.sessionKey,
|
|
243
|
-
AccountId: route.accountId,
|
|
244
|
-
ChatType: isGroup ? 'group' : 'direct',
|
|
245
|
-
ConversationLabel: fromLabel,
|
|
246
|
-
SenderName: senderName || undefined,
|
|
247
|
-
SenderId: senderId,
|
|
248
|
-
CommandAuthorized: commandAuthorized,
|
|
249
|
-
Provider: 'zalox',
|
|
250
|
-
Surface: 'zalox',
|
|
251
|
-
MessageSid: message.msgId ?? `${timestamp}`,
|
|
252
|
-
OriginatingChannel: 'zalox',
|
|
253
|
-
OriginatingTo: `zalox:${chatId}`,
|
|
254
|
-
MediaPath: mediaPath, // Pass media path here
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
await core.channel.session.recordInboundSession({
|
|
258
|
-
storePath,
|
|
259
|
-
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
260
|
-
ctx: ctxPayload,
|
|
261
|
-
onRecordError: (err: unknown) => {
|
|
262
|
-
runtime.error?.(`[zalox] session meta error: ${String(err)}`);
|
|
263
|
-
},
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
267
|
-
ctx: ctxPayload,
|
|
268
|
-
cfg: config,
|
|
269
|
-
dispatcherOptions: {
|
|
270
|
-
deliver: async (payload: any) => {
|
|
271
|
-
const text = payload.text ?? '';
|
|
272
|
-
if (!text) return;
|
|
273
|
-
|
|
274
|
-
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
275
|
-
cfg: config,
|
|
276
|
-
channel: 'zalox',
|
|
277
|
-
accountId: account.accountId,
|
|
278
|
-
});
|
|
279
|
-
const converted = core.channel.text.convertMarkdownTables(text, tableMode ?? 'code');
|
|
280
|
-
const chunkMode = core.channel.text.resolveChunkMode(config, 'zalox', account.accountId);
|
|
281
|
-
const chunks = core.channel.text.chunkMarkdownTextWithMode(converted, 2000, chunkMode);
|
|
282
|
-
|
|
283
|
-
for (const chunk of chunks) {
|
|
284
|
-
try {
|
|
285
|
-
await sendText(chatId, chunk, { profile, isGroup });
|
|
286
|
-
statusSink?.({ lastOutboundAt: Date.now() });
|
|
287
|
-
} catch (err) {
|
|
288
|
-
runtime.error?.(`[zalox] send failed: ${String(err)}`);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
},
|
|
292
|
-
onError: (err: unknown, info: any) => {
|
|
293
|
-
runtime.error?.(`[${account.accountId}] ZaloX ${info.kind} reply failed: ${String(err)}`);
|
|
294
|
-
},
|
|
295
|
-
},
|
|
296
|
-
});
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
export async function startInProcessListener(
|
|
300
|
-
options: ListenerOptions,
|
|
301
|
-
): Promise<{ stop: () => void }> {
|
|
302
|
-
const { account, config, runtime, api, ownId, profile, abortSignal, statusSink } = options;
|
|
303
|
-
let stopped = false;
|
|
304
|
-
|
|
305
|
-
const listener = api.listener;
|
|
306
|
-
|
|
307
|
-
// Handle incoming messages
|
|
308
|
-
listener.on('message', (msg: any) => {
|
|
309
|
-
if (stopped || abortSignal.aborted) return;
|
|
310
|
-
|
|
311
|
-
const data = msg.data || msg;
|
|
312
|
-
const isGroup = msg.type === 1;
|
|
313
|
-
const senderId = data.uidFrom ? String(data.uidFrom) : '';
|
|
314
|
-
|
|
315
|
-
// Skip own messages
|
|
316
|
-
if (senderId === ownId) return;
|
|
317
|
-
|
|
318
|
-
const threadId = isGroup
|
|
319
|
-
? String(data.idTo || data.threadId || '')
|
|
320
|
-
: senderId;
|
|
321
|
-
|
|
322
|
-
const msgType = data.msgType || 0;
|
|
323
|
-
|
|
324
|
-
// DEBUG: Force reply details for every DM to user (DISABLED)
|
|
325
|
-
// if (!isGroup) { ... }
|
|
326
|
-
|
|
327
|
-
let mediaUrl = undefined;
|
|
328
|
-
// Prioritize high-res URL if available
|
|
329
|
-
mediaUrl = data.url || data.href;
|
|
330
|
-
if (!mediaUrl && (msgType === 2 || String(msgType) === 'chat.photo')) {
|
|
331
|
-
// Try to find URL in params or content
|
|
332
|
-
if (data.params?.url) mediaUrl = data.params.url;
|
|
333
|
-
else if (typeof data.content === 'object' && data.content !== null) {
|
|
334
|
-
// Already parsed object
|
|
335
|
-
mediaUrl = (data.content as any).href || (data.content as any).url || (data.content as any).thumb || (data.content as any).normalUrl;
|
|
336
|
-
}
|
|
337
|
-
else if (typeof data.content === 'string' && (data.content.includes('http') || data.content.startsWith('{'))) {
|
|
338
|
-
try {
|
|
339
|
-
const parsed = JSON.parse(data.content);
|
|
340
|
-
mediaUrl = parsed.href || parsed.url || parsed.thumb || parsed.normalUrl;
|
|
341
|
-
} catch {
|
|
342
|
-
// Maybe content IS the url?
|
|
343
|
-
if (data.content.startsWith('http')) mediaUrl = data.content;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
// Also check for image extension if URL exists but type != 2
|
|
348
|
-
if (!mediaUrl && data.url && (data.url.includes('.jpg') || data.url.includes('.png'))) {
|
|
349
|
-
mediaUrl = data.url;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// Check quote/reply for media
|
|
353
|
-
if (!mediaUrl && data.quote) {
|
|
354
|
-
try {
|
|
355
|
-
const q = data.quote;
|
|
356
|
-
if (q.attach) {
|
|
357
|
-
const attach = typeof q.attach === 'string' ? JSON.parse(q.attach) : q.attach;
|
|
358
|
-
mediaUrl = attach.href || attach.url || attach.thumb || attach.normalUrl;
|
|
359
|
-
}
|
|
360
|
-
if (!mediaUrl && (q.href || q.url)) {
|
|
361
|
-
mediaUrl = q.href || q.url;
|
|
362
|
-
}
|
|
363
|
-
} catch {}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// Group messages: only respond when bot is mentioned (@tagged)
|
|
367
|
-
// UNLESS it's a media message — we might want to analyze all images?
|
|
368
|
-
if (isGroup) {
|
|
369
|
-
const mentions = data.mentions || data.mentionIds || [];
|
|
370
|
-
const content = typeof data.content === 'string' ? data.content : (data.msg || '');
|
|
371
|
-
|
|
372
|
-
const isMentioned = Array.isArray(mentions)
|
|
373
|
-
? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)
|
|
374
|
-
: false;
|
|
375
|
-
const name = account.name || 'Bot';
|
|
376
|
-
const textMention = content.includes(`@${name}`) || content.includes('@Tiệp Lê');
|
|
377
|
-
|
|
378
|
-
// DEBUG Group logic
|
|
379
|
-
console.log(`[ZaloX] Group Check: ownId=${ownId} mentioned=${isMentioned} textMention=${textMention} content="${content}"`);
|
|
380
|
-
|
|
381
|
-
if (!isMentioned && !textMention && !content.startsWith('/')) {
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
statusSink?.({ lastInboundAt: Date.now() });
|
|
387
|
-
|
|
388
|
-
const normalized = {
|
|
389
|
-
threadId,
|
|
390
|
-
msgId: String(data.msgId || data.cliMsgId || ''),
|
|
391
|
-
content: (() => {
|
|
392
|
-
let c = data.content || data.msg || '';
|
|
393
|
-
if (typeof c !== 'string') return '';
|
|
394
|
-
if (isGroup) {
|
|
395
|
-
const name = account.name || 'Bot';
|
|
396
|
-
// Simple cleanup without regex if possible, or use safe regex
|
|
397
|
-
// But escaping special chars is safer
|
|
398
|
-
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
399
|
-
c = c.replace(new RegExp(`@${escaped}\\s*`, 'gi'), '').trim();
|
|
400
|
-
}
|
|
401
|
-
return c;
|
|
402
|
-
})(),
|
|
403
|
-
msgType,
|
|
404
|
-
mediaUrl,
|
|
405
|
-
timestamp: data.ts ? Math.floor(data.ts / 1000) : Math.floor(Date.now() / 1000),
|
|
406
|
-
isGroup,
|
|
407
|
-
senderId,
|
|
408
|
-
senderName: data.dName || data.senderName || undefined,
|
|
409
|
-
groupName: data.threadName || data.groupName || undefined,
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
if (!normalized.content?.trim() && !normalized.mediaUrl) return;
|
|
413
|
-
|
|
414
|
-
// React logic removed.
|
|
415
|
-
// Agent should decide when to react.
|
|
416
|
-
|
|
417
|
-
processMessage(normalized, account, config, runtime, profile, statusSink).catch((err) => {
|
|
418
|
-
runtime.error?.(`[${account.accountId}] ZaloX process error: ${String(err)}`);
|
|
419
|
-
});
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
// Handle errors
|
|
423
|
-
listener.on('error', (err: any) => {
|
|
424
|
-
runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
// Start WebSocket listener with retry
|
|
428
|
-
listener.start({ retryOnClose: true });
|
|
429
|
-
runtime.log?.(`[${account.accountId}] ZaloX: WebSocket listener started`);
|
|
430
|
-
|
|
431
|
-
const stop = () => {
|
|
432
|
-
stopped = true;
|
|
433
|
-
try {
|
|
434
|
-
listener.stop();
|
|
435
|
-
} catch {}
|
|
436
|
-
};
|
|
437
|
-
|
|
438
|
-
abortSignal.addEventListener('abort', stop, { once: true });
|
|
439
|
-
|
|
440
|
-
return { stop };
|
|
441
|
-
}
|
|
152
|
+
useAccessGroups,\n authorizers: [\n { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },\n ],\n })\n : undefined;\n\n if (!isGroup) {\n if (dmPolicy === 'disabled') {\n runtime.log?.(`[zalox] Blocked DM from ${senderId} (dmPolicy=disabled)`);\n return;\n }\n if (dmPolicy !== 'open') {\n if (!senderAllowedForCommands) {\n if (dmPolicy === 'pairing') {\n const { code, created } = await core.channel.pairing.upsertPairingRequest({\n channel: 'zalox',\n id: senderId,\n 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.20 FIX: Removed unsafe name check (@Tiệp Lê)\n const textMention = \n content.includes(`@${name}`) || \n content.includes('@Bot') || \n content.includes('@Javis');\n \n // DEBUG Group logic\n console.log(`[ZaloX] Group Check: ownId=${ownId} mentions=${JSON.stringify(mentions)} textMention=${textMention} content=\"${content}\"`);\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 const escaped = name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n c = c.replace(new RegExp(`@${escaped}\\\\s*`, 'gi'), '').trim();\n c = c.replace(/@Javis\\s*/gi, '').trim();\n // Removed @Tiệp replacement\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
|
package/src/send.ts
CHANGED
|
@@ -22,6 +22,12 @@ function cleanText(input: string): string {
|
|
|
22
22
|
return input.replace(/<ctrl\d+>/gi, '').trim();
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function cleanThreadId(id: string): string {
|
|
26
|
+
if (!id) return '';
|
|
27
|
+
// Remove zalox: or zx: prefix commonly added by OpenClaw routing
|
|
28
|
+
return id.replace(/^(zalox|zx):/i, '').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
async function withRetry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
|
|
26
32
|
let lastErr;
|
|
27
33
|
for (let i = 0; i < retries; i++) {
|
|
@@ -41,7 +47,8 @@ export async function sendText(
|
|
|
41
47
|
text: string,
|
|
42
48
|
options: { profile: string; isGroup?: boolean } = { profile: 'default' },
|
|
43
49
|
): Promise<SendResult> {
|
|
44
|
-
|
|
50
|
+
const cleanId = cleanThreadId(threadId);
|
|
51
|
+
if (!cleanId) {
|
|
45
52
|
return { ok: false, error: 'No threadId provided' };
|
|
46
53
|
}
|
|
47
54
|
|
|
@@ -58,11 +65,11 @@ export async function sendText(
|
|
|
58
65
|
try {
|
|
59
66
|
const type = options.isGroup ? ThreadType.Group : ThreadType.User;
|
|
60
67
|
|
|
61
|
-
console.log(`[ZaloX] Sending text to ${
|
|
68
|
+
console.log(`[ZaloX] Sending text to ${cleanId} (type=${type}): "${cleaned.slice(0, 50)}..."`);
|
|
62
69
|
|
|
63
70
|
// Add retry logic
|
|
64
71
|
const result = await withRetry(() =>
|
|
65
|
-
cached.api.sendMessage(cleaned.slice(0, 2000),
|
|
72
|
+
cached.api.sendMessage(cleaned.slice(0, 2000), cleanId, type)
|
|
66
73
|
);
|
|
67
74
|
|
|
68
75
|
console.log(`[ZaloX] Send result:`, JSON.stringify(result));
|
|
@@ -80,7 +87,8 @@ export async function sendMedia(
|
|
|
80
87
|
mediaUrl: string,
|
|
81
88
|
options: { profile: string; isGroup?: boolean } = { profile: 'default' },
|
|
82
89
|
): Promise<SendResult> {
|
|
83
|
-
|
|
90
|
+
const cleanId = cleanThreadId(threadId);
|
|
91
|
+
if (!cleanId) {
|
|
84
92
|
return { ok: false, error: 'No threadId provided' };
|
|
85
93
|
}
|
|
86
94
|
|
|
@@ -108,7 +116,7 @@ export async function sendMedia(
|
|
|
108
116
|
const result = await withRetry(() =>
|
|
109
117
|
cached.api.sendMessage(
|
|
110
118
|
{ msg: cleaned || '', attachments: [filePath!] },
|
|
111
|
-
|
|
119
|
+
cleanId,
|
|
112
120
|
type,
|
|
113
121
|
)
|
|
114
122
|
);
|
|
@@ -135,7 +143,7 @@ export async function sendMedia(
|
|
|
135
143
|
const result = await withRetry(() =>
|
|
136
144
|
cached.api.sendMessage(
|
|
137
145
|
{ msg: cleaned || '', attachments: [tmpPath!] },
|
|
138
|
-
|
|
146
|
+
cleanId,
|
|
139
147
|
type,
|
|
140
148
|
)
|
|
141
149
|
);
|
|
@@ -146,12 +154,12 @@ export async function sendMedia(
|
|
|
146
154
|
console.error(`[ZaloX] Send attachment failed: ${sendErr}`);
|
|
147
155
|
// Fallback: send text with URL as link
|
|
148
156
|
const fullText = cleaned ? `${cleaned}\n${mediaUrl}` : mediaUrl;
|
|
149
|
-
return sendText(
|
|
157
|
+
return sendText(cleanId, fullText, options);
|
|
150
158
|
}
|
|
151
159
|
} catch (dlErr: any) {
|
|
152
160
|
// Fallback: send text with URL as link
|
|
153
161
|
const fullText = cleaned ? `${cleaned}\n${mediaUrl}` : mediaUrl;
|
|
154
|
-
return sendText(
|
|
162
|
+
return sendText(cleanId, fullText, options);
|
|
155
163
|
} finally {
|
|
156
164
|
if (tmpPath) try { unlinkSync(tmpPath); } catch {}
|
|
157
165
|
}
|
|
@@ -159,7 +167,7 @@ export async function sendMedia(
|
|
|
159
167
|
|
|
160
168
|
// Fallback: send text only
|
|
161
169
|
const fullText = cleaned ? `${cleaned}\n${mediaUrl}` : mediaUrl;
|
|
162
|
-
return sendText(
|
|
170
|
+
return sendText(cleanId, fullText, options);
|
|
163
171
|
} catch (err: any) {
|
|
164
172
|
return { ok: false, error: err.message || String(err) };
|
|
165
173
|
}
|