@jackle.dev/zalox-plugin 1.0.20 → 1.0.22
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/listener.ts +288 -7
- package/src/tools.ts +14 -4
package/package.json
CHANGED
package/src/listener.ts
CHANGED
|
@@ -62,7 +62,6 @@ async function downloadZaloMedia(url: string, profile: string, filename?: string
|
|
|
62
62
|
const credentialsPath = resolveCredentialsPath(profile);
|
|
63
63
|
const creds = JSON.parse(readFileSync(credentialsPath, 'utf-8'));
|
|
64
64
|
|
|
65
|
-
// Zalo media URLs usually require cookies
|
|
66
65
|
const headers: Record<string, string> = {
|
|
67
66
|
'User-Agent': creds.userAgent || 'Mozilla/5.0',
|
|
68
67
|
'Cookie': typeof creds.cookie === 'object' ? JSON.stringify(creds.cookie) : String(creds.cookie),
|
|
@@ -77,10 +76,11 @@ async function downloadZaloMedia(url: string, profile: string, filename?: string
|
|
|
77
76
|
const contentType = res.headers.get('content-type');
|
|
78
77
|
const buffer = await res.arrayBuffer();
|
|
79
78
|
|
|
80
|
-
// Determine extension
|
|
81
79
|
let ext = extname(url).split('?')[0];
|
|
82
80
|
if (!ext || ext.length > 5) {
|
|
83
|
-
if (contentType?.includes('jpeg')) ext = '.jpg'
|
|
81
|
+
if (contentType?.includes('jpeg')) ext = '.jpg';
|
|
82
|
+
else if (contentType?.includes('png')) ext = '.png';
|
|
83
|
+
else ext = '.jpg';
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
const name = filename || `zalox-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
|
|
@@ -117,13 +117,11 @@ async function processMessage(
|
|
|
117
117
|
const core = getRuntime();
|
|
118
118
|
const { threadId, content, msgType, timestamp, isGroup, senderId, senderName, groupName, mediaUrl } = message;
|
|
119
119
|
|
|
120
|
-
// Allow empty content if it's an image
|
|
121
120
|
if (!content?.trim() && !mediaUrl) return;
|
|
122
121
|
|
|
123
122
|
const chatId = threadId;
|
|
124
123
|
const rawBody = content.trim();
|
|
125
124
|
|
|
126
|
-
// Handle media download
|
|
127
125
|
let mediaPath: string | undefined;
|
|
128
126
|
if (mediaUrl) {
|
|
129
127
|
const downloaded = await downloadZaloMedia(mediaUrl, profile);
|
|
@@ -135,7 +133,6 @@ async function processMessage(
|
|
|
135
133
|
}
|
|
136
134
|
}
|
|
137
135
|
|
|
138
|
-
// DM policy check
|
|
139
136
|
const configAllowFrom = (account.config.allowFrom ?? []).map(String);
|
|
140
137
|
const dmPolicy = account.config.dmPolicy ?? 'pairing';
|
|
141
138
|
|
|
@@ -149,4 +146,288 @@ async function processMessage(
|
|
|
149
146
|
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
|
150
147
|
const commandAuthorized = shouldComputeAuth
|
|
151
148
|
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
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
|
|
149
|
+
useAccessGroups,
|
|
150
|
+
authorizers: [
|
|
151
|
+
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
152
|
+
],
|
|
153
|
+
})
|
|
154
|
+
: undefined;
|
|
155
|
+
|
|
156
|
+
if (!isGroup) {
|
|
157
|
+
if (dmPolicy === 'disabled') {
|
|
158
|
+
runtime.log?.(`[zalox] Blocked DM from ${senderId} (dmPolicy=disabled)`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (dmPolicy !== 'open') {
|
|
162
|
+
if (!senderAllowedForCommands) {
|
|
163
|
+
if (dmPolicy === 'pairing') {
|
|
164
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
165
|
+
channel: 'zalox',
|
|
166
|
+
id: senderId,
|
|
167
|
+
meta: { name: senderName || undefined },
|
|
168
|
+
});
|
|
169
|
+
if (created) {
|
|
170
|
+
runtime.log?.(`[zalox] pairing request sender=${senderId}`);
|
|
171
|
+
try {
|
|
172
|
+
await sendText(
|
|
173
|
+
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
|
+
}
|
package/src/tools.ts
CHANGED
|
@@ -2,18 +2,18 @@ import { getCachedApi } from './client.js';
|
|
|
2
2
|
|
|
3
3
|
export const zaloxGroupTool = {
|
|
4
4
|
name: 'zalox_group',
|
|
5
|
-
description: 'Manage Zalo groups
|
|
5
|
+
description: 'Manage Zalo groups (kick/add/info) or check bot identity (me).',
|
|
6
6
|
parameters: {
|
|
7
7
|
type: 'object',
|
|
8
8
|
properties: {
|
|
9
9
|
action: {
|
|
10
10
|
type: 'string',
|
|
11
|
-
enum: ['kick', 'add', 'info'],
|
|
11
|
+
enum: ['kick', 'add', 'info', 'me'],
|
|
12
12
|
description: 'Action to perform'
|
|
13
13
|
},
|
|
14
14
|
groupId: {
|
|
15
15
|
type: 'string',
|
|
16
|
-
description: 'Target Group ID'
|
|
16
|
+
description: 'Target Group ID (required for kick/add/info)'
|
|
17
17
|
},
|
|
18
18
|
userId: {
|
|
19
19
|
type: 'string',
|
|
@@ -25,7 +25,7 @@ export const zaloxGroupTool = {
|
|
|
25
25
|
description: 'Zalo profile to use'
|
|
26
26
|
},
|
|
27
27
|
},
|
|
28
|
-
required: ['action'
|
|
28
|
+
required: ['action'],
|
|
29
29
|
},
|
|
30
30
|
execute: async (args: any) => {
|
|
31
31
|
const { action, groupId, userId, profile } = args;
|
|
@@ -35,6 +35,16 @@ export const zaloxGroupTool = {
|
|
|
35
35
|
const api = cached.api as any;
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
|
+
if (action === 'me') {
|
|
39
|
+
return JSON.stringify({
|
|
40
|
+
id: api.ownId || 'unknown',
|
|
41
|
+
name: api.displayName || 'unknown',
|
|
42
|
+
profile: profile || 'default'
|
|
43
|
+
}, null, 2);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!groupId && action !== 'me') throw new Error('groupId required');
|
|
47
|
+
|
|
38
48
|
if (action === 'kick') {
|
|
39
49
|
if (!userId) throw new Error('userId required for kick');
|
|
40
50
|
await api.removeUserFromGroup(userId, groupId);
|