@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackle.dev/zalox-plugin",
3
- "version": "1.0.20",
3
+ "version": "1.0.22",
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
@@ -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';\n else if (contentType?.includes('png')) ext = '.png';\n else 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: kick members, add members, or get group info.',
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', 'groupId'],
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);