@jackle.dev/zalox-plugin 1.0.22 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/listener.ts +6 -267
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackle.dev/zalox-plugin",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
4
4
  "description": "OpenClaw channel plugin for Zalo via zca-js (in-process, single login)",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
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