@jackle.dev/zalox-plugin 1.0.3 → 1.0.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackle.dev/zalox-plugin",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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/channel.ts CHANGED
@@ -110,7 +110,8 @@ export const zaloxPlugin: ChannelPlugin<ResolvedZaloxAccount> = {
110
110
  blockStreaming: true,
111
111
  },
112
112
  reload: { configPrefixes: ['channels.zalox'] },
113
- configSchema: buildChannelConfigSchema(ZaloxConfigSchema),
113
+ // configSchema: buildChannelConfigSchema(ZaloxConfigSchema), // Disabled due to runtime Zod version mismatch
114
+ configSchema: undefined,
114
115
 
115
116
  // ── Config ────────────────────────────────────────────
116
117
  config: {
@@ -248,7 +249,11 @@ export const zaloxPlugin: ChannelPlugin<ResolvedZaloxAccount> = {
248
249
  sendText: async ({ to, text, accountId, cfg }) => {
249
250
  const account = resolveAccount({ cfg, accountId });
250
251
  const profile = resolveProfile(account.config, account.accountId);
251
- const result = await sendText(to, text, { profile });
252
+
253
+ const isGroup = to.startsWith('group:');
254
+ const cleanTo = to.replace(/^group:/, '');
255
+
256
+ const result = await sendText(cleanTo, text, { profile, isGroup });
252
257
  return {
253
258
  channel: 'zalox',
254
259
  ok: result.ok,
@@ -259,7 +264,11 @@ export const zaloxPlugin: ChannelPlugin<ResolvedZaloxAccount> = {
259
264
  sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
260
265
  const account = resolveAccount({ cfg, accountId });
261
266
  const profile = resolveProfile(account.config, account.accountId);
262
- const result = await sendMedia(to, text ?? '', mediaUrl ?? '', { profile });
267
+
268
+ const isGroup = to.startsWith('group:');
269
+ const cleanTo = to.replace(/^group:/, '');
270
+
271
+ const result = await sendMedia(cleanTo, text ?? '', mediaUrl ?? '', { profile, isGroup });
263
272
  return {
264
273
  channel: 'zalox',
265
274
  ok: result.ok,
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * ZaloX Plugin — OpenClaw Channel Plugin Entry Point
3
+ *
4
+ * In-process Zalo messaging. Single login, single WebSocket.
5
+ * No subprocess spawning — solves the session conflict problem.
6
+ */
7
+
8
+ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
9
+ import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
10
+ import { zaloxPlugin, zaloxDock } from './channel.js';
11
+ import { setPluginRuntime } from './listener.js';
12
+
13
+ const plugin = {
14
+ id: 'zalox',
15
+ name: 'Zalo (ZaloX)',
16
+ description: 'Zalo personal messaging — in-process, single login, no session conflicts',
17
+ configSchema: undefined, // Disabled to fix schema.toJSONSchema error
18
+ register(api: OpenClawPluginApi) {
19
+ // Store runtime for listener's message processing
20
+ setPluginRuntime(api.runtime);
21
+
22
+ // Register channel plugin
23
+ api.registerChannel({ plugin: zaloxPlugin, dock: zaloxDock });
24
+ },
25
+ };
26
+
27
+ export default plugin;
package/src/listener.ts CHANGED
@@ -69,18 +69,30 @@ async function downloadZaloMedia(url: string, profile: string, filename?: string
69
69
  };
70
70
 
71
71
  const res = await fetch(url, { headers });
72
- if (!res.ok) return null;
72
+ if (!res.ok) {
73
+ console.error(`[ZaloX] Download failed: ${res.status} ${res.statusText} for ${url}`);
74
+ return null;
75
+ }
73
76
 
77
+ const contentType = res.headers.get('content-type');
74
78
  const buffer = await res.arrayBuffer();
75
- const ext = extname(url).split('?')[0] || '.jpg';
79
+
80
+ // Determine extension
81
+ let ext = extname(url).split('?')[0];
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';
86
+ }
87
+
76
88
  const name = filename || `zalox-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
77
89
  const path = join(getInboundMediaDir(), name);
78
90
 
79
91
  writeFileSync(path, Buffer.from(buffer));
80
- console.log(`[ZaloX] Downloaded media to ${path}`); // DEBUG
92
+ console.log(`[ZaloX] Downloaded media to ${path}`);
81
93
  return path;
82
94
  } catch (err) {
83
- console.error(`[ZaloX] Download media failed: ${err}`); // DEBUG
95
+ console.error(`[ZaloX] Download media failed: ${err}`);
84
96
  return null;
85
97
  }
86
98
  }
@@ -117,7 +129,12 @@ async function processMessage(
117
129
  let mediaPath: string | undefined;
118
130
  if (mediaUrl) {
119
131
  const downloaded = await downloadZaloMedia(mediaUrl, profile);
120
- if (downloaded) mediaPath = downloaded;
132
+ if (downloaded) {
133
+ mediaPath = downloaded;
134
+ runtime.log?.(`[${account.accountId}] ZaloX: Media downloaded to ${mediaPath}`);
135
+ } else {
136
+ runtime.error?.(`[${account.accountId}] ZaloX: Media download failed for ${mediaUrl}`);
137
+ }
121
138
  }
122
139
 
123
140
  // DM policy check
@@ -135,7 +152,10 @@ async function processMessage(
135
152
  const commandAuthorized = shouldComputeAuth
136
153
  ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
137
154
  useAccessGroups,
138
- authorizers: [\n { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },\n ],\n })
155
+ authorizers: [
156
+ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
157
+ ],
158
+ })
139
159
  : undefined;
140
160
 
141
161
  if (!isGroup) {
@@ -292,31 +312,59 @@ export async function startInProcessListener(
292
312
  const isGroup = msg.type === 1;
293
313
  const senderId = data.uidFrom ? String(data.uidFrom) : '';
294
314
 
295
- runtime.log?.(`[${account.accountId}] ZaloX: inbound raw data: ${JSON.stringify(data)}`); // DEBUG LOG
296
-
297
315
  // Skip own messages
298
316
  if (senderId === ownId) return;
299
317
 
300
- // Determine message type and media
301
- // zca-js: type 1=group text, direct text unknown (usually also content)
302
- // Images often come with msgType=2 or data.url
318
+ const threadId = isGroup
319
+ ? String(data.idTo || data.threadId || '')
320
+ : senderId;
321
+
303
322
  const msgType = data.msgType || 0;
304
- let mediaUrl = undefined;
305
323
 
306
- // DEBUG: Log image detection logic
307
- if (data.url) runtime.log?.(`[${account.accountId}] ZaloX: found URL ${data.url} (msgType=${msgType})`);
308
-
309
- if (msgType === 2 && data.url) { // Image
310
- mediaUrl = data.url;
311
- } else if (msgType === 3 && data.url) { // Sticker (treat as media if needed, but usually just url)
312
- // mediaUrl = data.url; // Optional: download stickers?
313
- } else if (data.url && (data.url.includes('.jpg') || data.url.includes('.png'))) {
314
- mediaUrl = data.url;
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 {}
315
364
  }
316
365
 
317
366
  // Group messages: only respond when bot is mentioned (@tagged)
318
367
  // UNLESS it's a media message — we might want to analyze all images?
319
- // No, keep same policy for now to avoid spam.
320
368
  if (isGroup) {
321
369
  const mentions = data.mentions || data.mentionIds || [];
322
370
  const content = typeof data.content === 'string' ? data.content : (data.msg || '');
@@ -324,20 +372,19 @@ export async function startInProcessListener(
324
372
  const isMentioned = Array.isArray(mentions)
325
373
  ? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)
326
374
  : false;
327
- const textMention = content.includes(`@${account.name || 'Tiệp Lê'}`);
375
+ const name = account.name || 'Bot';
376
+ const textMention = content.includes(`@${name}`);
328
377
 
329
- // If has media, we might want to check if the caption mentions bot
330
- if (!isMentioned && !textMention) {
378
+ // DEBUG Group logic
379
+ // console.log(`[ZaloX] Group msg: mentioned=${isMentioned} textMention=${textMention} (name=${name})`);
380
+
381
+ if (!isMentioned && !textMention && !content.startsWith('/')) {
331
382
  return;
332
383
  }
333
384
  }
334
385
 
335
386
  statusSink?.({ lastInboundAt: Date.now() });
336
387
 
337
- const threadId = isGroup
338
- ? String(data.idTo || data.threadId || '')
339
- : senderId;
340
-
341
388
  const normalized = {
342
389
  threadId,
343
390
  msgId: String(data.msgId || data.cliMsgId || ''),
@@ -345,7 +392,11 @@ export async function startInProcessListener(
345
392
  let c = data.content || data.msg || '';
346
393
  if (typeof c !== 'string') return '';
347
394
  if (isGroup) {
348
- c = c.replace(new RegExp(`@${account.name || 'Tiệp Lê'}\\s*`, 'gi'), '').trim();
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();
349
400
  }
350
401
  return c;
351
402
  })(),
@@ -360,8 +411,6 @@ export async function startInProcessListener(
360
411
 
361
412
  if (!normalized.content?.trim() && !normalized.mediaUrl) return;
362
413
 
363
- runtime.log?.(`[${account.accountId}] ZaloX: inbound from ${senderId} (type=${msgType} media=${!!mediaUrl})`);
364
-
365
414
  // React with ❤️
366
415
  try {
367
416
  const reactThreadType = isGroup ? ThreadType.Group : ThreadType.User;
@@ -369,9 +418,36 @@ export async function startInProcessListener(
369
418
  threadId: normalized.threadId,
370
419
  type: reactThreadType,
371
420
  data: {
372
- msgId: String(data.msgId || ''),\n cliMsgId: String(data.cliMsgId || ''),
421
+ msgId: String(data.msgId || ''),
422
+ cliMsgId: String(data.cliMsgId || ''),
373
423
  },
374
424
  }).catch((err: any) => {
375
425
  runtime.error?.(`[${account.accountId}] ZaloX reaction failed: ${String(err)}`);
376
426
  });
377
- } 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
427
+ } catch {}
428
+
429
+ processMessage(normalized, account, config, runtime, profile, statusSink).catch((err) => {
430
+ runtime.error?.(`[${account.accountId}] ZaloX process error: ${String(err)}`);
431
+ });
432
+ });
433
+
434
+ // Handle errors
435
+ listener.on('error', (err: any) => {
436
+ runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);
437
+ });
438
+
439
+ // Start WebSocket listener with retry
440
+ listener.start({ retryOnClose: true });
441
+ runtime.log?.(`[${account.accountId}] ZaloX: WebSocket listener started`);
442
+
443
+ const stop = () => {
444
+ stopped = true;
445
+ try {
446
+ listener.stop();
447
+ } catch {}
448
+ };
449
+
450
+ abortSignal.addEventListener('abort', stop, { once: true });
451
+
452
+ return { stop };
453
+ }
package/src/send.ts CHANGED
@@ -95,14 +95,21 @@ export async function sendMedia(
95
95
  tmpPath = `/tmp/${filename}`;
96
96
  writeFileSync(tmpPath, buffer);
97
97
 
98
- const result = await cached.api.sendMessage(
99
- { msg: text || '', attachments: [tmpPath] },
100
- threadId.trim(),
101
- type,
102
- );
103
-
104
- const msgId = result?.message?.msgId ? String(result.message.msgId) : undefined;
105
- return { ok: true, messageId: msgId };
98
+ try {
99
+ const result = await cached.api.sendMessage(
100
+ { msg: text || '', attachments: [tmpPath] },
101
+ threadId.trim(),
102
+ type,
103
+ );
104
+
105
+ const msgId = result?.message?.msgId ? String(result.message.msgId) : undefined;
106
+ return { ok: true, messageId: msgId };
107
+ } catch (sendErr) {
108
+ console.error(`[ZaloX] Send attachment failed: ${sendErr}`);
109
+ // Fallback: send text with URL as link
110
+ const fullText = text ? `${text}\n${mediaUrl}` : mediaUrl;
111
+ return sendText(threadId, fullText, options);
112
+ }
106
113
  } catch (dlErr: any) {
107
114
  // Fallback: send text with URL as link
108
115
  const fullText = text ? `${text}\n${mediaUrl}` : mediaUrl;