@jackle.dev/zalox-plugin 1.0.19 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/listener.ts +2 -291
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackle.dev/zalox-plugin",
3
- "version": "1.0.19",
3
+ "version": "1.0.20",
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
@@ -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