@openclaw/zalouser 2026.2.25 → 2026.3.2

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.
@@ -0,0 +1,149 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
3
+ import { executeZalouserTool } from "./tool.js";
4
+ import {
5
+ checkZaloAuthenticated,
6
+ getZaloUserInfo,
7
+ listZaloFriendsMatching,
8
+ listZaloGroupsMatching,
9
+ } from "./zalo-js.js";
10
+
11
+ vi.mock("./send.js", () => ({
12
+ sendMessageZalouser: vi.fn(),
13
+ sendImageZalouser: vi.fn(),
14
+ sendLinkZalouser: vi.fn(),
15
+ sendReactionZalouser: vi.fn(),
16
+ }));
17
+
18
+ vi.mock("./zalo-js.js", () => ({
19
+ checkZaloAuthenticated: vi.fn(),
20
+ getZaloUserInfo: vi.fn(),
21
+ listZaloFriendsMatching: vi.fn(),
22
+ listZaloGroupsMatching: vi.fn(),
23
+ }));
24
+
25
+ const mockSendMessage = vi.mocked(sendMessageZalouser);
26
+ const mockSendImage = vi.mocked(sendImageZalouser);
27
+ const mockSendLink = vi.mocked(sendLinkZalouser);
28
+ const mockCheckAuth = vi.mocked(checkZaloAuthenticated);
29
+ const mockGetUserInfo = vi.mocked(getZaloUserInfo);
30
+ const mockListFriends = vi.mocked(listZaloFriendsMatching);
31
+ const mockListGroups = vi.mocked(listZaloGroupsMatching);
32
+
33
+ function extractDetails(result: Awaited<ReturnType<typeof executeZalouserTool>>): unknown {
34
+ const text = result.content[0]?.text ?? "{}";
35
+ return JSON.parse(text) as unknown;
36
+ }
37
+
38
+ describe("executeZalouserTool", () => {
39
+ beforeEach(() => {
40
+ mockSendMessage.mockReset();
41
+ mockSendImage.mockReset();
42
+ mockSendLink.mockReset();
43
+ mockCheckAuth.mockReset();
44
+ mockGetUserInfo.mockReset();
45
+ mockListFriends.mockReset();
46
+ mockListGroups.mockReset();
47
+ });
48
+
49
+ it("returns error when send action is missing required fields", async () => {
50
+ const result = await executeZalouserTool("tool-1", { action: "send" });
51
+ expect(extractDetails(result)).toEqual({
52
+ error: "threadId and message required for send action",
53
+ });
54
+ });
55
+
56
+ it("sends text message for send action", async () => {
57
+ mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-1" });
58
+ const result = await executeZalouserTool("tool-1", {
59
+ action: "send",
60
+ threadId: "t-1",
61
+ message: "hello",
62
+ profile: "work",
63
+ isGroup: true,
64
+ });
65
+ expect(mockSendMessage).toHaveBeenCalledWith("t-1", "hello", {
66
+ profile: "work",
67
+ isGroup: true,
68
+ });
69
+ expect(extractDetails(result)).toEqual({ success: true, messageId: "m-1" });
70
+ });
71
+
72
+ it("returns tool error when send action fails", async () => {
73
+ mockSendMessage.mockResolvedValueOnce({ ok: false, error: "blocked" });
74
+ const result = await executeZalouserTool("tool-1", {
75
+ action: "send",
76
+ threadId: "t-1",
77
+ message: "hello",
78
+ });
79
+ expect(extractDetails(result)).toEqual({ error: "blocked" });
80
+ });
81
+
82
+ it("routes image and link actions to correct helpers", async () => {
83
+ mockSendImage.mockResolvedValueOnce({ ok: true, messageId: "img-1" });
84
+ const imageResult = await executeZalouserTool("tool-1", {
85
+ action: "image",
86
+ threadId: "g-1",
87
+ url: "https://example.com/image.jpg",
88
+ message: "caption",
89
+ isGroup: true,
90
+ });
91
+ expect(mockSendImage).toHaveBeenCalledWith("g-1", "https://example.com/image.jpg", {
92
+ profile: undefined,
93
+ caption: "caption",
94
+ isGroup: true,
95
+ });
96
+ expect(extractDetails(imageResult)).toEqual({ success: true, messageId: "img-1" });
97
+
98
+ mockSendLink.mockResolvedValueOnce({ ok: true, messageId: "lnk-1" });
99
+ const linkResult = await executeZalouserTool("tool-1", {
100
+ action: "link",
101
+ threadId: "t-2",
102
+ url: "https://openclaw.ai",
103
+ message: "read this",
104
+ });
105
+ expect(mockSendLink).toHaveBeenCalledWith("t-2", "https://openclaw.ai", {
106
+ profile: undefined,
107
+ caption: "read this",
108
+ isGroup: undefined,
109
+ });
110
+ expect(extractDetails(linkResult)).toEqual({ success: true, messageId: "lnk-1" });
111
+ });
112
+
113
+ it("returns friends/groups lists", async () => {
114
+ mockListFriends.mockResolvedValueOnce([{ userId: "1", displayName: "Alice" }]);
115
+ mockListGroups.mockResolvedValueOnce([{ groupId: "2", name: "Work" }]);
116
+
117
+ const friends = await executeZalouserTool("tool-1", {
118
+ action: "friends",
119
+ profile: "work",
120
+ query: "ali",
121
+ });
122
+ expect(mockListFriends).toHaveBeenCalledWith("work", "ali");
123
+ expect(extractDetails(friends)).toEqual([{ userId: "1", displayName: "Alice" }]);
124
+
125
+ const groups = await executeZalouserTool("tool-1", {
126
+ action: "groups",
127
+ profile: "work",
128
+ query: "wrk",
129
+ });
130
+ expect(mockListGroups).toHaveBeenCalledWith("work", "wrk");
131
+ expect(extractDetails(groups)).toEqual([{ groupId: "2", name: "Work" }]);
132
+ });
133
+
134
+ it("reports me + status actions", async () => {
135
+ mockGetUserInfo.mockResolvedValueOnce({ userId: "7", displayName: "Me" });
136
+ mockCheckAuth.mockResolvedValueOnce(true);
137
+
138
+ const me = await executeZalouserTool("tool-1", { action: "me", profile: "work" });
139
+ expect(mockGetUserInfo).toHaveBeenCalledWith("work");
140
+ expect(extractDetails(me)).toEqual({ userId: "7", displayName: "Me" });
141
+
142
+ const status = await executeZalouserTool("tool-1", { action: "status", profile: "work" });
143
+ expect(mockCheckAuth).toHaveBeenCalledWith("work");
144
+ expect(extractDetails(status)).toEqual({
145
+ authenticated: true,
146
+ output: "authenticated",
147
+ });
148
+ });
149
+ });
package/src/tool.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { Type } from "@sinclair/typebox";
2
- import { runZca, parseJsonOutput } from "./zca.js";
2
+ import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
3
+ import {
4
+ checkZaloAuthenticated,
5
+ getZaloUserInfo,
6
+ listZaloFriendsMatching,
7
+ listZaloGroupsMatching,
8
+ } from "./zalo-js.js";
3
9
 
4
10
  const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
5
11
 
@@ -19,7 +25,6 @@ function stringEnum<T extends readonly string[]>(
19
25
  });
20
26
  }
21
27
 
22
- // Tool schema - avoiding Type.Union per tool schema guardrails
23
28
  export const ZalouserToolSchema = Type.Object(
24
29
  {
25
30
  action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
@@ -62,15 +67,14 @@ export async function executeZalouserTool(
62
67
  if (!params.threadId || !params.message) {
63
68
  throw new Error("threadId and message required for send action");
64
69
  }
65
- const args = ["msg", "send", params.threadId, params.message];
66
- if (params.isGroup) {
67
- args.push("-g");
68
- }
69
- const result = await runZca(args, { profile: params.profile });
70
+ const result = await sendMessageZalouser(params.threadId, params.message, {
71
+ profile: params.profile,
72
+ isGroup: params.isGroup,
73
+ });
70
74
  if (!result.ok) {
71
- throw new Error(result.stderr || "Failed to send message");
75
+ throw new Error(result.error || "Failed to send message");
72
76
  }
73
- return json({ success: true, output: result.stdout });
77
+ return json({ success: true, messageId: result.messageId });
74
78
  }
75
79
 
76
80
  case "image": {
@@ -80,74 +84,52 @@ export async function executeZalouserTool(
80
84
  if (!params.url) {
81
85
  throw new Error("url required for image action");
82
86
  }
83
- const args = ["msg", "image", params.threadId, "-u", params.url];
84
- if (params.message) {
85
- args.push("-m", params.message);
86
- }
87
- if (params.isGroup) {
88
- args.push("-g");
89
- }
90
- const result = await runZca(args, { profile: params.profile });
87
+ const result = await sendImageZalouser(params.threadId, params.url, {
88
+ profile: params.profile,
89
+ caption: params.message,
90
+ isGroup: params.isGroup,
91
+ });
91
92
  if (!result.ok) {
92
- throw new Error(result.stderr || "Failed to send image");
93
+ throw new Error(result.error || "Failed to send image");
93
94
  }
94
- return json({ success: true, output: result.stdout });
95
+ return json({ success: true, messageId: result.messageId });
95
96
  }
96
97
 
97
98
  case "link": {
98
99
  if (!params.threadId || !params.url) {
99
100
  throw new Error("threadId and url required for link action");
100
101
  }
101
- const args = ["msg", "link", params.threadId, params.url];
102
- if (params.isGroup) {
103
- args.push("-g");
104
- }
105
- const result = await runZca(args, { profile: params.profile });
102
+ const result = await sendLinkZalouser(params.threadId, params.url, {
103
+ profile: params.profile,
104
+ caption: params.message,
105
+ isGroup: params.isGroup,
106
+ });
106
107
  if (!result.ok) {
107
- throw new Error(result.stderr || "Failed to send link");
108
+ throw new Error(result.error || "Failed to send link");
108
109
  }
109
- return json({ success: true, output: result.stdout });
110
+ return json({ success: true, messageId: result.messageId });
110
111
  }
111
112
 
112
113
  case "friends": {
113
- const args = params.query ? ["friend", "find", params.query] : ["friend", "list", "-j"];
114
- const result = await runZca(args, { profile: params.profile });
115
- if (!result.ok) {
116
- throw new Error(result.stderr || "Failed to get friends");
117
- }
118
- const parsed = parseJsonOutput(result.stdout);
119
- return json(parsed ?? { raw: result.stdout });
114
+ const rows = await listZaloFriendsMatching(params.profile, params.query);
115
+ return json(rows);
120
116
  }
121
117
 
122
118
  case "groups": {
123
- const result = await runZca(["group", "list", "-j"], {
124
- profile: params.profile,
125
- });
126
- if (!result.ok) {
127
- throw new Error(result.stderr || "Failed to get groups");
128
- }
129
- const parsed = parseJsonOutput(result.stdout);
130
- return json(parsed ?? { raw: result.stdout });
119
+ const rows = await listZaloGroupsMatching(params.profile, params.query);
120
+ return json(rows);
131
121
  }
132
122
 
133
123
  case "me": {
134
- const result = await runZca(["me", "info", "-j"], {
135
- profile: params.profile,
136
- });
137
- if (!result.ok) {
138
- throw new Error(result.stderr || "Failed to get profile");
139
- }
140
- const parsed = parseJsonOutput(result.stdout);
141
- return json(parsed ?? { raw: result.stdout });
124
+ const info = await getZaloUserInfo(params.profile);
125
+ return json(info ?? { error: "Not authenticated" });
142
126
  }
143
127
 
144
128
  case "status": {
145
- const result = await runZca(["auth", "status"], {
146
- profile: params.profile,
147
- });
129
+ const authenticated = await checkZaloAuthenticated(params.profile);
148
130
  return json({
149
- authenticated: result.ok,
150
- output: result.stdout || result.stderr,
131
+ authenticated,
132
+ output: authenticated ? "authenticated" : "not authenticated",
151
133
  });
152
134
  }
153
135
 
package/src/types.ts CHANGED
@@ -1,48 +1,49 @@
1
- // zca-cli wrapper types
2
- export type ZcaRunOptions = {
3
- profile?: string;
4
- cwd?: string;
5
- timeout?: number;
6
- };
7
-
8
- export type ZcaResult = {
9
- ok: boolean;
10
- stdout: string;
11
- stderr: string;
12
- exitCode: number;
1
+ export type ZcaFriend = {
2
+ userId: string;
3
+ displayName: string;
4
+ avatar?: string;
13
5
  };
14
6
 
15
- export type ZcaProfile = {
7
+ export type ZaloGroup = {
8
+ groupId: string;
16
9
  name: string;
17
- label?: string;
18
- isDefault?: boolean;
10
+ memberCount?: number;
19
11
  };
20
12
 
21
- export type ZcaFriend = {
13
+ export type ZaloGroupMember = {
22
14
  userId: string;
23
15
  displayName: string;
24
16
  avatar?: string;
25
17
  };
26
18
 
27
- export type ZcaGroup = {
28
- groupId: string;
29
- name: string;
30
- memberCount?: number;
19
+ export type ZaloEventMessage = {
20
+ msgId: string;
21
+ cliMsgId: string;
22
+ uidFrom: string;
23
+ idTo: string;
24
+ msgType: string;
25
+ st: number;
26
+ at: number;
27
+ cmd: number;
28
+ ts: string | number;
31
29
  };
32
30
 
33
- export type ZcaMessage = {
31
+ export type ZaloInboundMessage = {
34
32
  threadId: string;
33
+ isGroup: boolean;
34
+ senderId: string;
35
+ senderName?: string;
36
+ groupName?: string;
37
+ content: string;
38
+ timestampMs: number;
35
39
  msgId?: string;
36
40
  cliMsgId?: string;
37
- type: number;
38
- content: string;
39
- timestamp: number;
40
- metadata?: {
41
- isGroup: boolean;
42
- threadName?: string;
43
- senderName?: string;
44
- fromId?: string;
45
- };
41
+ hasAnyMention?: boolean;
42
+ wasExplicitlyMentioned?: boolean;
43
+ canResolveExplicitMention?: boolean;
44
+ implicitMention?: boolean;
45
+ eventMessage?: ZaloEventMessage;
46
+ raw: unknown;
46
47
  };
47
48
 
48
49
  export type ZcaUserInfo = {
@@ -51,28 +52,37 @@ export type ZcaUserInfo = {
51
52
  avatar?: string;
52
53
  };
53
54
 
54
- export type CommonOptions = {
55
+ export type ZaloSendOptions = {
55
56
  profile?: string;
56
- json?: boolean;
57
+ mediaUrl?: string;
58
+ caption?: string;
59
+ isGroup?: boolean;
60
+ mediaLocalRoots?: readonly string[];
57
61
  };
58
62
 
59
- export type SendOptions = CommonOptions & {
60
- group?: boolean;
63
+ export type ZaloSendResult = {
64
+ ok: boolean;
65
+ messageId?: string;
66
+ error?: string;
67
+ };
68
+
69
+ export type ZaloGroupContext = {
70
+ groupId: string;
71
+ name?: string;
72
+ members?: string[];
61
73
  };
62
74
 
63
- export type ListenOptions = CommonOptions & {
64
- raw?: boolean;
65
- keepAlive?: boolean;
66
- webhook?: string;
67
- echo?: boolean;
68
- prefix?: string;
75
+ export type ZaloAuthStatus = {
76
+ connected: boolean;
77
+ message: string;
69
78
  };
70
79
 
71
- type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
80
+ export type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
72
81
 
73
- type ZalouserGroupConfig = {
82
+ export type ZalouserGroupConfig = {
74
83
  allow?: boolean;
75
84
  enabled?: boolean;
85
+ requireMention?: boolean;
76
86
  tools?: ZalouserToolConfig;
77
87
  };
78
88