@jackle.dev/zalox-plugin 1.0.23 → 1.0.25

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 -262
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackle.dev/zalox-plugin",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
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
@@ -148,8 +148,7 @@ async function processMessage(
148
148
  ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
149
149
  useAccessGroups,
150
150
  authorizers: [
151
- { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
152
- ],
151
+ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },\n ],
153
152
  })
154
153
  : undefined;
155
154
 
@@ -171,263 +170,4 @@ async function processMessage(
171
170
  try {
172
171
  await sendText(
173
172
  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
- }
173
+ 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 if (\n isGroup &&\n core.channel.commands.isControlCommandMessage(rawBody, config) &&\n commandAuthorized !== true\n ) {\n return;\n }\n\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,\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 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 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 mediaUrl = data.url || data.href;\n if (!mediaUrl && (msgType === 2 || String(msgType) === 'chat.photo')) {\n if (data.params?.url) mediaUrl = data.params.url;\n else if (typeof data.content === 'object' && data.content !== null) {\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 if (data.content.startsWith('http')) mediaUrl = data.content;\n }\n }\n }\n if (!mediaUrl && data.url && (data.url.includes('.jpg') || data.url.includes('.png'))) {\n mediaUrl = data.url;\n }\n\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 if (isGroup) {\n const mentions = data.mentions || data.mentionIds || [];\n const content = typeof data.content === 'string' ? data.content : (data.msg || '');\n \n // STRICT: Check UID Only\n const isMentioned = Array.isArray(mentions)\n ? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)\n : false;\n \n // DEBUG Group logic\n console.log(`[ZaloX] Group Check (Strict): ownId=${ownId} mentions=${JSON.stringify(mentions)} isMentioned=${isMentioned}`);\n\n // Allow /whoami bypass\n if (content.trim() === '/whoami') {\n // Pass\n } else if (!isMentioned && !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 // Cleanup logic removed as per request (no string replacement)\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 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 listener.on('error', (err: any) => {\n runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);\n });\n\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