@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.
package/src/send.test.ts CHANGED
@@ -1,156 +1,157 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
+ sendDeliveredZalouser,
3
4
  sendImageZalouser,
4
5
  sendLinkZalouser,
5
6
  sendMessageZalouser,
6
- type ZalouserSendResult,
7
+ sendReactionZalouser,
8
+ sendSeenZalouser,
9
+ sendTypingZalouser,
7
10
  } from "./send.js";
8
- import { runZca } from "./zca.js";
9
-
10
- vi.mock("./zca.js", () => ({
11
- runZca: vi.fn(),
11
+ import {
12
+ sendZaloDeliveredEvent,
13
+ sendZaloLink,
14
+ sendZaloReaction,
15
+ sendZaloSeenEvent,
16
+ sendZaloTextMessage,
17
+ sendZaloTypingEvent,
18
+ } from "./zalo-js.js";
19
+
20
+ vi.mock("./zalo-js.js", () => ({
21
+ sendZaloTextMessage: vi.fn(),
22
+ sendZaloLink: vi.fn(),
23
+ sendZaloTypingEvent: vi.fn(),
24
+ sendZaloReaction: vi.fn(),
25
+ sendZaloDeliveredEvent: vi.fn(),
26
+ sendZaloSeenEvent: vi.fn(),
12
27
  }));
13
28
 
14
- const mockRunZca = vi.mocked(runZca);
15
- const originalZcaProfile = process.env.ZCA_PROFILE;
16
-
17
- function okResult(stdout = "message_id: msg-1") {
18
- return {
19
- ok: true,
20
- stdout,
21
- stderr: "",
22
- exitCode: 0,
23
- };
24
- }
25
-
26
- function failResult(stderr = "") {
27
- return {
28
- ok: false,
29
- stdout: "",
30
- stderr,
31
- exitCode: 1,
32
- };
33
- }
29
+ const mockSendText = vi.mocked(sendZaloTextMessage);
30
+ const mockSendLink = vi.mocked(sendZaloLink);
31
+ const mockSendTyping = vi.mocked(sendZaloTypingEvent);
32
+ const mockSendReaction = vi.mocked(sendZaloReaction);
33
+ const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent);
34
+ const mockSendSeen = vi.mocked(sendZaloSeenEvent);
34
35
 
35
36
  describe("zalouser send helpers", () => {
36
37
  beforeEach(() => {
37
- mockRunZca.mockReset();
38
- delete process.env.ZCA_PROFILE;
39
- });
40
-
41
- afterEach(() => {
42
- if (originalZcaProfile) {
43
- process.env.ZCA_PROFILE = originalZcaProfile;
44
- return;
45
- }
46
- delete process.env.ZCA_PROFILE;
47
- });
48
-
49
- it("returns validation error when thread id is missing", async () => {
50
- const result = await sendMessageZalouser("", "hello");
51
- expect(result).toEqual({
52
- ok: false,
53
- error: "No threadId provided",
54
- } satisfies ZalouserSendResult);
55
- expect(mockRunZca).not.toHaveBeenCalled();
38
+ mockSendText.mockReset();
39
+ mockSendLink.mockReset();
40
+ mockSendTyping.mockReset();
41
+ mockSendReaction.mockReset();
42
+ mockSendDelivered.mockReset();
43
+ mockSendSeen.mockReset();
56
44
  });
57
45
 
58
- it("builds text send command with truncation and group flag", async () => {
59
- mockRunZca.mockResolvedValueOnce(okResult("message id: mid-123"));
46
+ it("delegates text send to JS transport", async () => {
47
+ mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
60
48
 
61
- const result = await sendMessageZalouser(" thread-1 ", "x".repeat(2200), {
62
- profile: "profile-a",
49
+ const result = await sendMessageZalouser("thread-1", "hello", {
50
+ profile: "default",
63
51
  isGroup: true,
64
52
  });
65
53
 
66
- expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], {
67
- profile: "profile-a",
54
+ expect(mockSendText).toHaveBeenCalledWith("thread-1", "hello", {
55
+ profile: "default",
56
+ isGroup: true,
68
57
  });
69
- expect(result).toEqual({ ok: true, messageId: "mid-123" });
58
+ expect(result).toEqual({ ok: true, messageId: "mid-1" });
70
59
  });
71
60
 
72
- it("routes media sends from sendMessage and keeps text as caption", async () => {
73
- mockRunZca.mockResolvedValueOnce(okResult());
61
+ it("maps image helper to media send", async () => {
62
+ mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
74
63
 
75
- await sendMessageZalouser("thread-2", "media caption", {
76
- profile: "profile-b",
77
- mediaUrl: "https://cdn.example.com/video.mp4",
78
- isGroup: true,
64
+ await sendImageZalouser("thread-2", "https://example.com/a.png", {
65
+ profile: "p2",
66
+ caption: "cap",
67
+ isGroup: false,
79
68
  });
80
69
 
81
- expect(mockRunZca).toHaveBeenCalledWith(
82
- [
83
- "msg",
84
- "video",
85
- "thread-2",
86
- "-u",
87
- "https://cdn.example.com/video.mp4",
88
- "-m",
89
- "media caption",
90
- "-g",
91
- ],
92
- { profile: "profile-b" },
93
- );
70
+ expect(mockSendText).toHaveBeenCalledWith("thread-2", "cap", {
71
+ profile: "p2",
72
+ caption: "cap",
73
+ isGroup: false,
74
+ mediaUrl: "https://example.com/a.png",
75
+ });
94
76
  });
95
77
 
96
- it("maps audio media to voice command", async () => {
97
- mockRunZca.mockResolvedValueOnce(okResult());
78
+ it("delegates link helper to JS transport", async () => {
79
+ mockSendLink.mockResolvedValueOnce({ ok: false, error: "boom" });
98
80
 
99
- await sendMessageZalouser("thread-3", "", {
100
- profile: "profile-c",
101
- mediaUrl: "https://cdn.example.com/clip.mp3",
81
+ const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", {
82
+ profile: "p3",
83
+ isGroup: true,
102
84
  });
103
85
 
104
- expect(mockRunZca).toHaveBeenCalledWith(
105
- ["msg", "voice", "thread-3", "-u", "https://cdn.example.com/clip.mp3"],
106
- { profile: "profile-c" },
107
- );
86
+ expect(mockSendLink).toHaveBeenCalledWith("thread-3", "https://openclaw.ai", {
87
+ profile: "p3",
88
+ isGroup: true,
89
+ });
90
+ expect(result).toEqual({ ok: false, error: "boom" });
108
91
  });
109
92
 
110
- it("builds image command with caption and returns fallback error", async () => {
111
- mockRunZca.mockResolvedValueOnce(failResult(""));
93
+ it("delegates typing helper to JS transport", async () => {
94
+ await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
112
95
 
113
- const result = await sendImageZalouser("thread-4", " https://cdn.example.com/img.png ", {
114
- profile: "profile-d",
115
- caption: "caption text",
96
+ expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
97
+ profile: "p4",
116
98
  isGroup: true,
117
99
  });
118
-
119
- expect(mockRunZca).toHaveBeenCalledWith(
120
- [
121
- "msg",
122
- "image",
123
- "thread-4",
124
- "-u",
125
- "https://cdn.example.com/img.png",
126
- "-m",
127
- "caption text",
128
- "-g",
129
- ],
130
- { profile: "profile-d" },
131
- );
132
- expect(result).toEqual({ ok: false, error: "Failed to send image" });
133
100
  });
134
101
 
135
- it("uses env profile fallback and builds link command", async () => {
136
- process.env.ZCA_PROFILE = "env-profile";
137
- mockRunZca.mockResolvedValueOnce(okResult("abc123"));
102
+ it("delegates reaction helper to JS transport", async () => {
103
+ mockSendReaction.mockResolvedValueOnce({ ok: true });
138
104
 
139
- const result = await sendLinkZalouser("thread-5", " https://openclaw.ai ", { isGroup: true });
105
+ const result = await sendReactionZalouser({
106
+ threadId: "thread-5",
107
+ profile: "p5",
108
+ isGroup: true,
109
+ msgId: "100",
110
+ cliMsgId: "200",
111
+ emoji: "👍",
112
+ });
140
113
 
141
- expect(mockRunZca).toHaveBeenCalledWith(
142
- ["msg", "link", "thread-5", "https://openclaw.ai", "-g"],
143
- { profile: "env-profile" },
144
- );
145
- expect(result).toEqual({ ok: true, messageId: "abc123" });
114
+ expect(mockSendReaction).toHaveBeenCalledWith({
115
+ profile: "p5",
116
+ threadId: "thread-5",
117
+ isGroup: true,
118
+ msgId: "100",
119
+ cliMsgId: "200",
120
+ emoji: "👍",
121
+ remove: undefined,
122
+ });
123
+ expect(result).toEqual({ ok: true, error: undefined });
146
124
  });
147
125
 
148
- it("returns caught command errors", async () => {
149
- mockRunZca.mockRejectedValueOnce(new Error("zca unavailable"));
150
-
151
- await expect(sendLinkZalouser("thread-6", "https://openclaw.ai")).resolves.toEqual({
152
- ok: false,
153
- error: "zca unavailable",
126
+ it("delegates delivered+seen helpers to JS transport", async () => {
127
+ mockSendDelivered.mockResolvedValueOnce();
128
+ mockSendSeen.mockResolvedValueOnce();
129
+
130
+ const message = {
131
+ msgId: "100",
132
+ cliMsgId: "200",
133
+ uidFrom: "1",
134
+ idTo: "2",
135
+ msgType: "webchat",
136
+ st: 1,
137
+ at: 0,
138
+ cmd: 0,
139
+ ts: "123",
140
+ };
141
+
142
+ await sendDeliveredZalouser({ profile: "p6", isGroup: true, message, isSeen: false });
143
+ await sendSeenZalouser({ profile: "p6", isGroup: true, message });
144
+
145
+ expect(mockSendDelivered).toHaveBeenCalledWith({
146
+ profile: "p6",
147
+ isGroup: true,
148
+ message,
149
+ isSeen: false,
150
+ });
151
+ expect(mockSendSeen).toHaveBeenCalledWith({
152
+ profile: "p6",
153
+ isGroup: true,
154
+ message,
154
155
  });
155
156
  });
156
157
  });
package/src/send.ts CHANGED
@@ -1,104 +1,22 @@
1
- import { runZca } from "./zca.js";
2
-
3
- export type ZalouserSendOptions = {
4
- profile?: string;
5
- mediaUrl?: string;
6
- caption?: string;
7
- isGroup?: boolean;
8
- };
9
-
10
- export type ZalouserSendResult = {
11
- ok: boolean;
12
- messageId?: string;
13
- error?: string;
14
- };
15
-
16
- function resolveProfile(options: ZalouserSendOptions): string {
17
- return options.profile || process.env.ZCA_PROFILE || "default";
18
- }
19
-
20
- function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void {
21
- if (options.caption) {
22
- args.push("-m", options.caption.slice(0, 2000));
23
- }
24
- if (options.isGroup) {
25
- args.push("-g");
26
- }
27
- }
28
-
29
- async function runSendCommand(
30
- args: string[],
31
- profile: string,
32
- fallbackError: string,
33
- ): Promise<ZalouserSendResult> {
34
- try {
35
- const result = await runZca(args, { profile });
36
- if (result.ok) {
37
- return { ok: true, messageId: extractMessageId(result.stdout) };
38
- }
39
- return { ok: false, error: result.stderr || fallbackError };
40
- } catch (err) {
41
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
42
- }
43
- }
1
+ import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
2
+ import {
3
+ sendZaloDeliveredEvent,
4
+ sendZaloLink,
5
+ sendZaloReaction,
6
+ sendZaloSeenEvent,
7
+ sendZaloTextMessage,
8
+ sendZaloTypingEvent,
9
+ } from "./zalo-js.js";
10
+
11
+ export type ZalouserSendOptions = ZaloSendOptions;
12
+ export type ZalouserSendResult = ZaloSendResult;
44
13
 
45
14
  export async function sendMessageZalouser(
46
15
  threadId: string,
47
16
  text: string,
48
17
  options: ZalouserSendOptions = {},
49
18
  ): Promise<ZalouserSendResult> {
50
- const profile = resolveProfile(options);
51
-
52
- if (!threadId?.trim()) {
53
- return { ok: false, error: "No threadId provided" };
54
- }
55
-
56
- // Handle media sending
57
- if (options.mediaUrl) {
58
- return sendMediaZalouser(threadId, options.mediaUrl, {
59
- ...options,
60
- caption: text || options.caption,
61
- });
62
- }
63
-
64
- // Send text message
65
- const args = ["msg", "send", threadId.trim(), text.slice(0, 2000)];
66
- if (options.isGroup) {
67
- args.push("-g");
68
- }
69
-
70
- return runSendCommand(args, profile, "Failed to send message");
71
- }
72
-
73
- async function sendMediaZalouser(
74
- threadId: string,
75
- mediaUrl: string,
76
- options: ZalouserSendOptions = {},
77
- ): Promise<ZalouserSendResult> {
78
- const profile = resolveProfile(options);
79
-
80
- if (!threadId?.trim()) {
81
- return { ok: false, error: "No threadId provided" };
82
- }
83
-
84
- if (!mediaUrl?.trim()) {
85
- return { ok: false, error: "No media URL provided" };
86
- }
87
-
88
- // Determine media type from URL
89
- const lowerUrl = mediaUrl.toLowerCase();
90
- let command: string;
91
- if (lowerUrl.match(/\.(mp4|mov|avi|webm)$/)) {
92
- command = "video";
93
- } else if (lowerUrl.match(/\.(mp3|wav|ogg|m4a)$/)) {
94
- command = "voice";
95
- } else {
96
- command = "image";
97
- }
98
-
99
- const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()];
100
- appendCaptionAndGroupFlags(args, options);
101
- return runSendCommand(args, profile, `Failed to send ${command}`);
19
+ return await sendZaloTextMessage(threadId, text, options);
102
20
  }
103
21
 
104
22
  export async function sendImageZalouser(
@@ -106,10 +24,10 @@ export async function sendImageZalouser(
106
24
  imageUrl: string,
107
25
  options: ZalouserSendOptions = {},
108
26
  ): Promise<ZalouserSendResult> {
109
- const profile = resolveProfile(options);
110
- const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
111
- appendCaptionAndGroupFlags(args, options);
112
- return runSendCommand(args, profile, "Failed to send image");
27
+ return await sendZaloTextMessage(threadId, options.caption ?? "", {
28
+ ...options,
29
+ mediaUrl: imageUrl,
30
+ });
113
31
  }
114
32
 
115
33
  export async function sendLinkZalouser(
@@ -117,25 +35,53 @@ export async function sendLinkZalouser(
117
35
  url: string,
118
36
  options: ZalouserSendOptions = {},
119
37
  ): Promise<ZalouserSendResult> {
120
- const profile = resolveProfile(options);
121
- const args = ["msg", "link", threadId.trim(), url.trim()];
122
- if (options.isGroup) {
123
- args.push("-g");
124
- }
38
+ return await sendZaloLink(threadId, url, options);
39
+ }
40
+
41
+ export async function sendTypingZalouser(
42
+ threadId: string,
43
+ options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
44
+ ): Promise<void> {
45
+ await sendZaloTypingEvent(threadId, options);
46
+ }
125
47
 
126
- return runSendCommand(args, profile, "Failed to send link");
48
+ export async function sendReactionZalouser(params: {
49
+ threadId: string;
50
+ msgId: string;
51
+ cliMsgId: string;
52
+ emoji: string;
53
+ remove?: boolean;
54
+ profile?: string;
55
+ isGroup?: boolean;
56
+ }): Promise<ZalouserSendResult> {
57
+ const result = await sendZaloReaction({
58
+ profile: params.profile,
59
+ threadId: params.threadId,
60
+ isGroup: params.isGroup,
61
+ msgId: params.msgId,
62
+ cliMsgId: params.cliMsgId,
63
+ emoji: params.emoji,
64
+ remove: params.remove,
65
+ });
66
+ return {
67
+ ok: result.ok,
68
+ error: result.error,
69
+ };
127
70
  }
128
71
 
129
- function extractMessageId(stdout: string): string | undefined {
130
- // Try to extract message ID from output
131
- const match = stdout.match(/message[_\s]?id[:\s]+(\S+)/i);
132
- if (match) {
133
- return match[1];
134
- }
135
- // Return first word if it looks like an ID
136
- const firstWord = stdout.trim().split(/\s+/)[0];
137
- if (firstWord && /^[a-zA-Z0-9_-]+$/.test(firstWord)) {
138
- return firstWord;
139
- }
140
- return undefined;
72
+ export async function sendDeliveredZalouser(params: {
73
+ profile?: string;
74
+ isGroup?: boolean;
75
+ message: ZaloEventMessage;
76
+ isSeen?: boolean;
77
+ }): Promise<void> {
78
+ await sendZaloDeliveredEvent(params);
79
+ }
80
+
81
+ export async function sendSeenZalouser(params: {
82
+ profile?: string;
83
+ isGroup?: boolean;
84
+ message: ZaloEventMessage;
85
+ }): Promise<void> {
86
+ await sendZaloSeenEvent(params);
141
87
  }
@@ -2,20 +2,6 @@ import { describe, expect, it } from "vitest";
2
2
  import { collectZalouserStatusIssues } from "./status-issues.js";
3
3
 
4
4
  describe("collectZalouserStatusIssues", () => {
5
- it("flags missing zca when configured is false", () => {
6
- const issues = collectZalouserStatusIssues([
7
- {
8
- accountId: "default",
9
- enabled: true,
10
- configured: false,
11
- lastError: "zca CLI not found in PATH",
12
- },
13
- ]);
14
- expect(issues).toHaveLength(1);
15
- expect(issues[0]?.kind).toBe("runtime");
16
- expect(issues[0]?.message).toMatch(/zca CLI not found/i);
17
- });
18
-
19
5
  it("flags missing auth when configured is false", () => {
20
6
  const issues = collectZalouserStatusIssues([
21
7
  {
@@ -49,7 +35,7 @@ describe("collectZalouserStatusIssues", () => {
49
35
  accountId: "default",
50
36
  enabled: false,
51
37
  configured: false,
52
- lastError: "zca CLI not found in PATH",
38
+ lastError: "not authenticated",
53
39
  },
54
40
  ]);
55
41
  expect(issues).toHaveLength(0);
@@ -27,14 +27,6 @@ function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccou
27
27
  };
28
28
  }
29
29
 
30
- function isMissingZca(lastError?: string): boolean {
31
- if (!lastError) {
32
- return false;
33
- }
34
- const lower = lastError.toLowerCase();
35
- return lower.includes("zca") && (lower.includes("not found") || lower.includes("enoent"));
36
- }
37
-
38
30
  export function collectZalouserStatusIssues(
39
31
  accounts: ChannelAccountSnapshot[],
40
32
  ): ChannelStatusIssue[] {
@@ -51,26 +43,15 @@ export function collectZalouserStatusIssues(
51
43
  }
52
44
 
53
45
  const configured = account.configured === true;
54
- const lastError = asString(account.lastError)?.trim();
55
46
 
56
47
  if (!configured) {
57
- if (isMissingZca(lastError)) {
58
- issues.push({
59
- channel: "zalouser",
60
- accountId,
61
- kind: "runtime",
62
- message: "zca CLI not found in PATH.",
63
- fix: "Install zca-cli and ensure it is on PATH for the Gateway process.",
64
- });
65
- } else {
66
- issues.push({
67
- channel: "zalouser",
68
- accountId,
69
- kind: "auth",
70
- message: "Not authenticated (no zca session).",
71
- fix: "Run: openclaw channels login --channel zalouser",
72
- });
73
- }
48
+ issues.push({
49
+ channel: "zalouser",
50
+ accountId,
51
+ kind: "auth",
52
+ message: "Not authenticated (no saved Zalo session).",
53
+ fix: "Run: openclaw channels login --channel zalouser",
54
+ });
74
55
  continue;
75
56
  }
76
57