@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 +1 -1
- package/src/channel.ts +12 -3
- package/src/index.ts +27 -0
- package/src/listener.ts +110 -34
- package/src/send.ts +15 -8
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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}`);
|
|
92
|
+
console.log(`[ZaloX] Downloaded media to ${path}`);
|
|
81
93
|
return path;
|
|
82
94
|
} catch (err) {
|
|
83
|
-
console.error(`[ZaloX] Download media failed: ${err}`);
|
|
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)
|
|
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: [
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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:
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
375
|
+
const name = account.name || 'Bot';
|
|
376
|
+
const textMention = content.includes(`@${name}`);
|
|
328
377
|
|
|
329
|
-
//
|
|
330
|
-
|
|
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
|
-
|
|
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 || '')
|
|
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 {}
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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;
|