@jackle.dev/zalox-plugin 1.0.30 → 1.0.31

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 +37 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackle.dev/zalox-plugin",
3
- "version": "1.0.30",
3
+ "version": "1.0.31",
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
@@ -21,6 +21,7 @@ import { sendText } from './send.js';
21
21
  import { resolveCredentialsPath } from './client.js';
22
22
 
23
23
  let pluginRuntime: any = null;
24
+ const PLUGIN_VERSION = '1.0.31';
24
25
 
25
26
  export function setPluginRuntime(rt: any): void {
26
27
  pluginRuntime = rt;
@@ -232,4 +233,39 @@ async function processMessage(
232
233
  CommandBody: rawBody,
233
234
  From: isGroup ? `zalox:group:${chatId}` : `zalox:${senderId}`,
234
235
  To: `zalox:${chatId}`,
235
- 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 let isMentioned = false;\n\n // 1. UID Check\n if (Array.isArray(mentions) && ownId) {\n isMentioned = mentions.some((m: any) => String(m.uid || m.id || m) === ownId);\n }\n\n // 2. Fallback Text Check (Crucial for old messages/clients)\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') || content.includes('@Bot')) {\n isMentioned = true;\n }\n }\n \n // DEBUG COMMAND\n if (content.trim() === '/debug') {\n try {\n await sendText(threadId, `DEBUG V1.0.30:\\nownId: "${ownId}"\\nisMentioned: ${isMentioned}`, { profile, isGroup: true });\n } catch {}\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
236
+ SessionKey: route.sessionKey,
237
+ AccountId: route.accountId,
238
+ ChatType: isGroup ? 'group' : 'direct',
239
+ ConversationLabel: fromLabel,
240
+ SenderName: senderName || undefined,
241
+ SenderId: senderId,
242
+ CommandAuthorized: commandAuthorized,
243
+ Provider: 'zalox',
244
+ Surface: 'zalox',
245
+ MessageSid: message.msgId ?? `${timestamp}`,
246
+ OriginatingChannel: 'zalox',
247
+ OriginatingTo: `zalox:${chatId}`,
248
+ MediaPath: mediaPath,
249
+ });
250
+
251
+ await core.channel.session.recordInboundSession({
252
+ storePath,
253
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
254
+ ctx: ctxPayload,
255
+ onRecordError: (err: unknown) => {
256
+ runtime.error?.(`[zalox] session meta error: ${String(err)}`);
257
+ },
258
+ });
259
+
260
+ await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
261
+ ctx: ctxPayload,
262
+ cfg: config,
263
+ dispatcherOptions: {
264
+ deliver: async (payload: any) => {
265
+ const text = payload.text ?? '';
266
+ if (!text) return;
267
+
268
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
269
+ cfg: config,
270
+ channel: 'zalox',
271
+ 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 let isMentioned = false;\n\n // 1. UID Check\n if (Array.isArray(mentions) && ownId) {\n isMentioned = mentions.some((m: any) => String(m.uid || m.id || m) === ownId);\n }\n\n // 2. Fallback Text Check\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') || content.includes('@Bot')) {\n isMentioned = true;\n }\n }\n\n // DEBUG LOGGING (Always log in group for troubleshooting)\n console.log(`[ZaloX v${PLUGIN_VERSION}] Group Msg: threadId=${threadId} content="${content.slice(0, 50)}..." mentions=${JSON.stringify(mentions)} ownId="${ownId}" isMentioned=${isMentioned}`);\n \n // DEBUG COMMAND\n if (content.trim() === '/debug') {\n try {\n await sendText(threadId, `[ZaloX v${PLUGIN_VERSION}] DEBUG:\\nownId: "${ownId}"\\nisMentioned: ${isMentioned}\\nContent: "${content.slice(0, 50)}..."\\nMentions: ${JSON.stringify(mentions)}`, { profile, isGroup: true });\n } catch {}\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