@jackle.dev/zalox-plugin 1.0.27 → 1.0.29

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.27",
3
+ "version": "1.0.29",
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/client.ts CHANGED
@@ -60,72 +60,52 @@ export async function getOrCreateApi(profile: string): Promise<CachedApiEntry> {
60
60
  const zalo = new Zalo({ logging: false, imageMetadataGetter } as any);
61
61
  const api = await zalo.login(credentials);
62
62
 
63
- // Get own user info
64
- let ownId = '';
63
+ // Get own user info with robust fallback
64
+ let ownId = String((credentials as any).uid || (credentials as any).id || (credentials as any).userId || '');
65
65
  let displayName: string | undefined;
66
66
  let avatar: string | undefined;
67
- try {
68
- const info = await api.fetchAccountInfo();
69
- ownId = String((info as any).userId || '');
70
- displayName = (info as any).displayName || (info as any).zaloName;
71
- avatar = (info as any).avatar;
72
- } catch {
73
- // Try to get from context
74
- try {
75
- const ctx = await api.getContext();
76
- ownId = String((ctx as any).uid || '');
77
- } catch {}
78
- }
79
-
80
- const entry: CachedApiEntry = {
81
- api,
82
- ownId,
83
- displayName,
84
- avatar,
85
- connectedAt: Date.now(),
86
- };
87
-
88
- apiCache.set(profile, entry);
89
- return entry;
90
- }
91
-
92
- /**
93
- * Get cached API instance (no login if not cached)
94
- */
95
- export function getCachedApi(profile: string): CachedApiEntry | undefined {
96
- return apiCache.get(profile);
97
- }
98
-
99
- /**
100
- * Check if credentials exist for a profile (offline, no login)
101
- */
102
- export function hasCredentials(profile: string): boolean {
103
- return existsSync(resolveCredentialsPath(profile));
104
- }
105
67
 
106
- /**
107
- * Clear cached API instance
108
- */
109
- export function clearApi(profile: string): void {
110
- apiCache.delete(profile);
111
- }
112
-
113
- function resolveOpenClawDir(): string {
114
- return process.env.OPENCLAW_DIR || join(homedir(), '.openclaw');
115
- }
68
+ // Try API if credentials didn't have ID
69
+ if (!ownId || ownId === 'undefined') {
70
+ try {
71
+ if (typeof (api as any).getOwnId === 'function') {
72
+ const id = await (api as any).getOwnId();
73
+ if (id) ownId = String(id);
74
+ }
75
+ } catch (e) {
76
+ console.warn('[ZaloX] Failed to getOwnId from API:', e);
77
+ }
78
+ }
116
79
 
117
- export function resolveCredentialsPath(profile: string): string {
118
- // 1. Persistent path inside .openclaw (survives container restarts)
119
- const openclawPath = join(resolveOpenClawDir(), 'zalox', 'profiles', `${profile}.json`);
120
- if (existsSync(openclawPath)) return openclawPath;
80
+ // Try Context if still missing
81
+ if (!ownId || ownId === 'undefined') {
82
+ try {
83
+ const ctx = (api as any).context || (api as any).ctx || (zalo as any).context;
84
+ if (ctx && (ctx.uid || ctx.userId)) {
85
+ ownId = String(ctx.uid || ctx.userId);
86
+ }
87
+ } catch {}
88
+ }
89
+
90
+ // Try to parse from Cookie as last resort
91
+ if ((!ownId || ownId === 'undefined') && credentials.cookie) {
92
+ const c = typeof credentials.cookie === 'string' ? credentials.cookie : JSON.stringify(credentials.cookie);
93
+ const match = c.match(/uid=([0-9]+)/) || c.match(/z_uuid=([a-zA-Z0-9]+)/);
94
+ if (match) ownId = match[1];
95
+ }
121
96
 
122
- // 2. Legacy ZaloX config path
123
- const zaloxPath = join(homedir(), '.config', 'zalox', 'profiles', `${profile}.json`);
124
- if (existsSync(zaloxPath)) return zaloxPath;
97
+ // Get display name
98
+ try {
99
+ if (typeof (api as any).fetchAccountInfo === 'function') {
100
+ const info = await (api as any).fetchAccountInfo();
101
+ if (info) {
102
+ displayName = (info as any).displayName || (info as any).zaloName;
103
+ avatar = (info as any).avatar;
104
+ if (!ownId) ownId = String((info as any).userId || '');
105
+ }
106
+ }
107
+ } catch {}
125
108
 
126
- // 3. Legacy zca-cli path
127
- const zcaPath = join(homedir(), '.config', 'zca-cli-nodejs', 'profiles', `${profile}.json`);
128
- if (existsSync(zcaPath)) return zcaPath;
109
+ console.log(`[ZaloX] Logged in profile=${profile} ownId=${ownId}`);
129
110
 
130
- return openclawPath; // Default to persistent .openclaw path
131
- }
111
+ const entry: CachedApiEntry = {\n api,\n ownId,\n displayName,\n avatar,\n connectedAt: Date.now(),\n };\n\n apiCache.set(profile, entry);\n return entry;\n}\n\n/**\n * Get cached API instance (no login if not cached)\n */\nexport function getCachedApi(profile: string): CachedApiEntry | undefined {\n return apiCache.get(profile);\n}\n\n/**\n * Check if credentials exist for a profile (offline, no login)\n */\nexport function hasCredentials(profile: string): boolean {\n return existsSync(resolveCredentialsPath(profile));\n}\n\n/**\n * Clear cached API instance\n */\nexport function clearApi(profile: string): void {\n apiCache.delete(profile);\n}\n\nfunction resolveOpenClawDir(): string {\n return process.env.OPENCLAW_DIR || join(homedir(), '.openclaw');\n}\n\nexport function resolveCredentialsPath(profile: string): string {\n // 1. Persistent path inside .openclaw (survives container restarts)\n const openclawPath = join(resolveOpenClawDir(), 'zalox', 'profiles', `${profile}.json`);\n if (existsSync(openclawPath)) return openclawPath;\n\n // 2. Legacy ZaloX config path\n const zaloxPath = join(homedir(), '.config', 'zalox', 'profiles', `${profile}.json`);\n if (existsSync(zaloxPath)) return zaloxPath;\n\n // 3. Legacy zca-cli path\n const zcaPath = join(homedir(), '.config', 'zca-cli-nodejs', 'profiles', `${profile}.json`);\n if (existsSync(zcaPath)) return zcaPath;\n\n return openclawPath; // Default to persistent .openclaw path\n}\n
package/src/listener.ts CHANGED
@@ -231,190 +231,4 @@ async function processMessage(
231
231
  RawBody: rawBody,
232
232
  CommandBody: rawBody,
233
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
-
356
- console.log(`[ZaloX] Group Check Strict: ownId=${ownId} mentions=${JSON.stringify(mentions)} isMentioned=${isMentioned}`);
357
-
358
- if (content.trim() === '/whoami') {
359
- // Pass
360
- } else if (!isMentioned && !content.startsWith('/')) {
361
- return;
362
- }
363
- }
364
-
365
- statusSink?.({ lastInboundAt: Date.now() });
366
-
367
- const normalized = {
368
- threadId,
369
- msgId: String(data.msgId || data.cliMsgId || ''),
370
- content: (() => {
371
- let c = data.content || data.msg || '';
372
- if (typeof c !== 'string') return '';
373
- return c;
374
- })(),
375
- msgType,
376
- mediaUrl,
377
- timestamp: data.ts ? Math.floor(data.ts / 1000) : Math.floor(Date.now() / 1000),
378
- isGroup,
379
- senderId,
380
- senderName: data.dName || data.senderName || undefined,
381
- groupName: data.threadName || data.groupName || undefined,
382
- };
383
-
384
- if (!normalized.content?.trim() && !normalized.mediaUrl) return;
385
-
386
- try {
387
- const reactThreadType = isGroup ? ThreadType.Group : ThreadType.User;
388
- api.addReaction(Reactions.HEART, {
389
- threadId: normalized.threadId,
390
- type: reactThreadType,
391
- data: {
392
- msgId: String(data.msgId || ''),
393
- cliMsgId: String(data.cliMsgId || ''),
394
- },
395
- }).catch(() => {});
396
- } catch {}
397
-
398
- processMessage(normalized, account, config, runtime, profile, statusSink).catch((err) => {
399
- runtime.error?.(`[${account.accountId}] ZaloX process error: ${String(err)}`);
400
- });
401
- });
402
-
403
- listener.on('error', (err: any) => {
404
- runtime.error?.(`[${account.accountId}] ZaloX listener error: ${err.message || err}`);
405
- });
406
-
407
- listener.start({ retryOnClose: true });
408
- runtime.log?.(`[${account.accountId}] ZaloX: WebSocket listener started`);
409
-
410
- const stop = () => {
411
- stopped = true;
412
- try {
413
- listener.stop();
414
- } catch {}
415
- };
416
-
417
- abortSignal.addEventListener('abort', stop, { once: true });
418
-
419
- return { stop };
420
- }
234
+ 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', async (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 let isMentioned = Array.isArray(mentions)\n ? mentions.some((m: any) => String(m.uid || m.id || m) === ownId)\n : false;\n\n // FALLBACK: Text Check (Safety net for empty ownId)\n if (!isMentioned) {\n const name = account.name || 'Bot';\n // Safe string check - no regex\n if (content.includes(`@${name}`) || content.includes('@Tiệp Lê') || content.includes('@Javis')) {\n isMentioned = true;\n }\n }\n \n // DEBUG COMMAND\n if (content.trim() === '/debug') {\n try {\n await sendText(threadId, `DEBUG:\\nownId: "${ownId}"\\nmentions: ${JSON.stringify(mentions)}\\nisMentioned: ${isMentioned}`, { profile, isGroup: true });\n } catch {}\n // Don't return, let it proceed so agent can also see /debug\n }\n\n if (content.trim() === '/whoami') {\n // Pass\n } else if (!isMentioned && !content.startsWith('/') && content.trim() !== '/debug') {\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 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