@openclaw/zalo 2026.3.12 → 2026.5.1-beta.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/README.md +1 -1
- package/api.ts +9 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +5 -0
- package/index.test.ts +15 -0
- package/index.ts +16 -13
- package/openclaw.plugin.json +514 -1
- package/package.json +31 -5
- package/runtime-api.test.ts +17 -0
- package/runtime-api.ts +75 -0
- package/secret-contract-api.ts +5 -0
- package/setup-api.ts +34 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +70 -0
- package/src/accounts.ts +19 -19
- package/src/actions.runtime.ts +5 -0
- package/src/actions.test.ts +32 -0
- package/src/actions.ts +20 -14
- package/src/api.test.ts +108 -22
- package/src/api.ts +29 -2
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +25 -0
- package/src/channel.directory.test.ts +22 -16
- package/src/channel.runtime.ts +93 -0
- package/src/channel.startup.test.ts +36 -35
- package/src/channel.ts +228 -336
- package/src/config-schema.ts +3 -3
- package/src/group-access.ts +4 -3
- package/src/monitor.group-policy.test.ts +0 -12
- package/src/monitor.image.polling.test.ts +110 -0
- package/src/monitor.lifecycle.test.ts +77 -92
- package/src/monitor.pairing.lifecycle.test.ts +141 -0
- package/src/monitor.polling.media-reply.test.ts +425 -0
- package/src/monitor.reply-once.lifecycle.test.ts +171 -0
- package/src/monitor.ts +527 -304
- package/src/monitor.types.ts +4 -0
- package/src/monitor.webhook.test.ts +392 -62
- package/src/monitor.webhook.ts +73 -36
- package/src/outbound-media.test.ts +182 -0
- package/src/outbound-media.ts +241 -0
- package/src/outbound-payload.contract.test.ts +45 -0
- package/src/probe.ts +1 -1
- package/src/proxy.ts +1 -1
- package/src/runtime-api.ts +75 -0
- package/src/runtime-support.ts +91 -0
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +109 -0
- package/src/secret-input.ts +1 -9
- package/src/send.test.ts +120 -0
- package/src/send.ts +64 -40
- package/src/session-route.ts +32 -0
- package/src/setup-allow-from.ts +94 -0
- package/src/setup-core.ts +149 -0
- package/src/{onboarding.status.test.ts → setup-status.test.ts} +13 -4
- package/src/setup-surface.test.ts +175 -0
- package/src/{onboarding.ts → setup-surface.ts} +59 -177
- package/src/status-issues.test.ts +17 -0
- package/src/status-issues.ts +11 -27
- package/src/test-support/lifecycle-test-support.ts +413 -0
- package/src/test-support/monitor-mocks-test-support.ts +209 -0
- package/src/token.test.ts +15 -0
- package/src/token.ts +8 -17
- package/src/types.ts +2 -2
- package/test-api.ts +1 -0
- package/tsconfig.json +16 -0
- package/CHANGELOG.md +0 -95
- package/src/channel.sendpayload.test.ts +0 -44
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
collectConditionalChannelFieldAssignments,
|
|
3
|
+
getChannelSurface,
|
|
4
|
+
hasOwnProperty,
|
|
5
|
+
type ResolverContext,
|
|
6
|
+
type SecretDefaults,
|
|
7
|
+
type SecretTargetRegistryEntry,
|
|
8
|
+
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
|
9
|
+
|
|
10
|
+
export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [
|
|
11
|
+
{
|
|
12
|
+
id: "channels.zalo.accounts.*.botToken",
|
|
13
|
+
targetType: "channels.zalo.accounts.*.botToken",
|
|
14
|
+
configFile: "openclaw.json",
|
|
15
|
+
pathPattern: "channels.zalo.accounts.*.botToken",
|
|
16
|
+
secretShape: "secret_input",
|
|
17
|
+
expectedResolvedValue: "string",
|
|
18
|
+
includeInPlan: true,
|
|
19
|
+
includeInConfigure: true,
|
|
20
|
+
includeInAudit: true,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "channels.zalo.accounts.*.webhookSecret",
|
|
24
|
+
targetType: "channels.zalo.accounts.*.webhookSecret",
|
|
25
|
+
configFile: "openclaw.json",
|
|
26
|
+
pathPattern: "channels.zalo.accounts.*.webhookSecret",
|
|
27
|
+
secretShape: "secret_input",
|
|
28
|
+
expectedResolvedValue: "string",
|
|
29
|
+
includeInPlan: true,
|
|
30
|
+
includeInConfigure: true,
|
|
31
|
+
includeInAudit: true,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "channels.zalo.botToken",
|
|
35
|
+
targetType: "channels.zalo.botToken",
|
|
36
|
+
configFile: "openclaw.json",
|
|
37
|
+
pathPattern: "channels.zalo.botToken",
|
|
38
|
+
secretShape: "secret_input",
|
|
39
|
+
expectedResolvedValue: "string",
|
|
40
|
+
includeInPlan: true,
|
|
41
|
+
includeInConfigure: true,
|
|
42
|
+
includeInAudit: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "channels.zalo.webhookSecret",
|
|
46
|
+
targetType: "channels.zalo.webhookSecret",
|
|
47
|
+
configFile: "openclaw.json",
|
|
48
|
+
pathPattern: "channels.zalo.webhookSecret",
|
|
49
|
+
secretShape: "secret_input",
|
|
50
|
+
expectedResolvedValue: "string",
|
|
51
|
+
includeInPlan: true,
|
|
52
|
+
includeInConfigure: true,
|
|
53
|
+
includeInAudit: true,
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export function collectRuntimeConfigAssignments(params: {
|
|
58
|
+
config: { channels?: Record<string, unknown> };
|
|
59
|
+
defaults?: SecretDefaults;
|
|
60
|
+
context: ResolverContext;
|
|
61
|
+
}): void {
|
|
62
|
+
const resolved = getChannelSurface(params.config, "zalo");
|
|
63
|
+
if (!resolved) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const { channel: zalo, surface } = resolved;
|
|
67
|
+
collectConditionalChannelFieldAssignments({
|
|
68
|
+
channelKey: "zalo",
|
|
69
|
+
field: "botToken",
|
|
70
|
+
channel: zalo,
|
|
71
|
+
surface,
|
|
72
|
+
defaults: params.defaults,
|
|
73
|
+
context: params.context,
|
|
74
|
+
topLevelActiveWithoutAccounts: true,
|
|
75
|
+
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
|
76
|
+
enabled && !hasOwnProperty(account, "botToken"),
|
|
77
|
+
accountActive: ({ enabled }) => enabled,
|
|
78
|
+
topInactiveReason: "no enabled Zalo surface inherits this top-level botToken.",
|
|
79
|
+
accountInactiveReason: "Zalo account is disabled.",
|
|
80
|
+
});
|
|
81
|
+
const baseWebhookUrl = typeof zalo.webhookUrl === "string" ? zalo.webhookUrl.trim() : "";
|
|
82
|
+
const accountWebhookUrl = (account: Record<string, unknown>) =>
|
|
83
|
+
hasOwnProperty(account, "webhookUrl")
|
|
84
|
+
? typeof account.webhookUrl === "string"
|
|
85
|
+
? account.webhookUrl.trim()
|
|
86
|
+
: ""
|
|
87
|
+
: baseWebhookUrl;
|
|
88
|
+
collectConditionalChannelFieldAssignments({
|
|
89
|
+
channelKey: "zalo",
|
|
90
|
+
field: "webhookSecret",
|
|
91
|
+
channel: zalo,
|
|
92
|
+
surface,
|
|
93
|
+
defaults: params.defaults,
|
|
94
|
+
context: params.context,
|
|
95
|
+
topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0,
|
|
96
|
+
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
|
97
|
+
enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0,
|
|
98
|
+
accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0,
|
|
99
|
+
topInactiveReason:
|
|
100
|
+
"no enabled Zalo webhook surface inherits this top-level webhookSecret (webhook mode is not active).",
|
|
101
|
+
accountInactiveReason:
|
|
102
|
+
"Zalo account is disabled or webhook mode is not active for this account.",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const channelSecrets = {
|
|
107
|
+
secretTargetRegistryEntries,
|
|
108
|
+
collectRuntimeConfigAssignments,
|
|
109
|
+
};
|
package/src/secret-input.ts
CHANGED
|
@@ -1,13 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
buildSecretInputSchema,
|
|
3
|
-
hasConfiguredSecretInput,
|
|
4
|
-
normalizeResolvedSecretInputString,
|
|
5
|
-
normalizeSecretInputString,
|
|
6
|
-
} from "openclaw/plugin-sdk/zalo";
|
|
7
|
-
|
|
8
1
|
export {
|
|
9
2
|
buildSecretInputSchema,
|
|
10
|
-
hasConfiguredSecretInput,
|
|
11
3
|
normalizeResolvedSecretInputString,
|
|
12
4
|
normalizeSecretInputString,
|
|
13
|
-
};
|
|
5
|
+
} from "openclaw/plugin-sdk/secret-input";
|
package/src/send.test.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const sendMessageMock = vi.fn();
|
|
4
|
+
const sendPhotoMock = vi.fn();
|
|
5
|
+
const resolveZaloProxyFetchMock = vi.fn();
|
|
6
|
+
|
|
7
|
+
vi.mock("./api.js", () => ({
|
|
8
|
+
sendMessage: (...args: unknown[]) => sendMessageMock(...args),
|
|
9
|
+
sendPhoto: (...args: unknown[]) => sendPhotoMock(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./proxy.js", () => ({
|
|
13
|
+
resolveZaloProxyFetch: (...args: unknown[]) => resolveZaloProxyFetchMock(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import { sendMessageZalo, sendPhotoZalo } from "./send.js";
|
|
17
|
+
|
|
18
|
+
describe("zalo send", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
sendMessageMock.mockReset();
|
|
21
|
+
sendPhotoMock.mockReset();
|
|
22
|
+
resolveZaloProxyFetchMock.mockReset();
|
|
23
|
+
resolveZaloProxyFetchMock.mockReturnValue(undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("sends text messages through the message API", async () => {
|
|
27
|
+
sendMessageMock.mockResolvedValueOnce({
|
|
28
|
+
ok: true,
|
|
29
|
+
result: { message_id: "z-msg-1" },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const result = await sendMessageZalo("dm-chat-1", "hello there", {
|
|
33
|
+
token: "zalo-token",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(sendMessageMock).toHaveBeenCalledWith(
|
|
37
|
+
"zalo-token",
|
|
38
|
+
{
|
|
39
|
+
chat_id: "dm-chat-1",
|
|
40
|
+
text: "hello there",
|
|
41
|
+
},
|
|
42
|
+
undefined,
|
|
43
|
+
);
|
|
44
|
+
expect(sendPhotoMock).not.toHaveBeenCalled();
|
|
45
|
+
expect(result).toEqual({ ok: true, messageId: "z-msg-1" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("routes media-bearing sends through the photo API and uses text as caption", async () => {
|
|
49
|
+
sendPhotoMock.mockResolvedValueOnce({
|
|
50
|
+
ok: true,
|
|
51
|
+
result: { message_id: "z-photo-1" },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const result = await sendMessageZalo("dm-chat-2", "caption text", {
|
|
55
|
+
token: "zalo-token",
|
|
56
|
+
mediaUrl: "https://example.com/photo.jpg",
|
|
57
|
+
caption: "ignored fallback caption",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(sendPhotoMock).toHaveBeenCalledWith(
|
|
61
|
+
"zalo-token",
|
|
62
|
+
{
|
|
63
|
+
chat_id: "dm-chat-2",
|
|
64
|
+
photo: "https://example.com/photo.jpg",
|
|
65
|
+
caption: "caption text",
|
|
66
|
+
},
|
|
67
|
+
undefined,
|
|
68
|
+
);
|
|
69
|
+
expect(sendMessageMock).not.toHaveBeenCalled();
|
|
70
|
+
expect(result).toEqual({ ok: true, messageId: "z-photo-1" });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("fails fast for missing token or blank photo URLs", async () => {
|
|
74
|
+
await expect(sendMessageZalo("dm-chat-3", "hello", {})).resolves.toEqual({
|
|
75
|
+
ok: false,
|
|
76
|
+
error: "No Zalo bot token configured",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
sendPhotoZalo("dm-chat-4", " ", {
|
|
81
|
+
token: "zalo-token",
|
|
82
|
+
}),
|
|
83
|
+
).resolves.toEqual({
|
|
84
|
+
ok: false,
|
|
85
|
+
error: "No photo URL provided",
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(sendMessageMock).not.toHaveBeenCalled();
|
|
89
|
+
expect(sendPhotoMock).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("sends cfg-backed media directly without hosted-media rewrites", async () => {
|
|
93
|
+
sendPhotoMock.mockResolvedValueOnce({
|
|
94
|
+
ok: true,
|
|
95
|
+
result: { message_id: "z-photo-2" },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const result = await sendPhotoZalo("dm-chat-5", "https://example.com/photo.jpg", {
|
|
99
|
+
cfg: {
|
|
100
|
+
channels: {
|
|
101
|
+
zalo: {
|
|
102
|
+
botToken: "zalo-token",
|
|
103
|
+
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
} as never,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(sendPhotoMock).toHaveBeenCalledWith(
|
|
110
|
+
"zalo-token",
|
|
111
|
+
{
|
|
112
|
+
chat_id: "dm-chat-5",
|
|
113
|
+
photo: "https://example.com/photo.jpg",
|
|
114
|
+
caption: undefined,
|
|
115
|
+
},
|
|
116
|
+
undefined,
|
|
117
|
+
);
|
|
118
|
+
expect(result).toEqual({ ok: true, messageId: "z-photo-2" });
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/send.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk/
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
2
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
2
3
|
import { resolveZaloAccount } from "./accounts.js";
|
|
3
4
|
import type { ZaloFetch } from "./api.js";
|
|
4
5
|
import { sendMessage, sendPhoto } from "./api.js";
|
|
5
6
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
|
6
7
|
import { resolveZaloToken } from "./token.js";
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
type ZaloSendOptions = {
|
|
9
10
|
token?: string;
|
|
10
11
|
accountId?: string;
|
|
11
12
|
cfg?: OpenClawConfig;
|
|
@@ -15,12 +16,34 @@ export type ZaloSendOptions = {
|
|
|
15
16
|
proxy?: string;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
type ZaloSendResult = {
|
|
19
20
|
ok: boolean;
|
|
20
21
|
messageId?: string;
|
|
21
22
|
error?: string;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
function toZaloSendResult(response: {
|
|
26
|
+
ok?: boolean;
|
|
27
|
+
result?: { message_id?: string };
|
|
28
|
+
}): ZaloSendResult {
|
|
29
|
+
if (response.ok && response.result) {
|
|
30
|
+
return { ok: true, messageId: response.result.message_id };
|
|
31
|
+
}
|
|
32
|
+
return { ok: false, error: "Failed to send message" };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function runZaloSend(
|
|
36
|
+
failureMessage: string,
|
|
37
|
+
send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>,
|
|
38
|
+
): Promise<ZaloSendResult> {
|
|
39
|
+
try {
|
|
40
|
+
const result = toZaloSendResult(await send());
|
|
41
|
+
return result.ok ? result : { ok: false, error: failureMessage };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { ok: false, error: formatErrorMessage(err) };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
24
47
|
function resolveSendContext(options: ZaloSendOptions): {
|
|
25
48
|
token: string;
|
|
26
49
|
fetcher?: ZaloFetch;
|
|
@@ -55,15 +78,30 @@ function resolveValidatedSendContext(
|
|
|
55
78
|
return { ok: true, chatId: trimmedChatId, token, fetcher };
|
|
56
79
|
}
|
|
57
80
|
|
|
81
|
+
function resolveSendContextOrFailure(
|
|
82
|
+
chatId: string,
|
|
83
|
+
options: ZaloSendOptions,
|
|
84
|
+
):
|
|
85
|
+
| { context: { chatId: string; token: string; fetcher?: ZaloFetch } }
|
|
86
|
+
| { failure: ZaloSendResult } {
|
|
87
|
+
const context = resolveValidatedSendContext(chatId, options);
|
|
88
|
+
return context.ok
|
|
89
|
+
? { context }
|
|
90
|
+
: {
|
|
91
|
+
failure: { ok: false, error: context.error },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
58
95
|
export async function sendMessageZalo(
|
|
59
96
|
chatId: string,
|
|
60
97
|
text: string,
|
|
61
98
|
options: ZaloSendOptions = {},
|
|
62
99
|
): Promise<ZaloSendResult> {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
return
|
|
100
|
+
const resolved = resolveSendContextOrFailure(chatId, options);
|
|
101
|
+
if ("failure" in resolved) {
|
|
102
|
+
return resolved.failure;
|
|
66
103
|
}
|
|
104
|
+
const { context } = resolved;
|
|
67
105
|
|
|
68
106
|
if (options.mediaUrl) {
|
|
69
107
|
return sendPhotoZalo(context.chatId, options.mediaUrl, {
|
|
@@ -73,24 +111,16 @@ export async function sendMessageZalo(
|
|
|
73
111
|
});
|
|
74
112
|
}
|
|
75
113
|
|
|
76
|
-
|
|
77
|
-
|
|
114
|
+
return await runZaloSend("Failed to send message", () =>
|
|
115
|
+
sendMessage(
|
|
78
116
|
context.token,
|
|
79
117
|
{
|
|
80
118
|
chat_id: context.chatId,
|
|
81
119
|
text: text.slice(0, 2000),
|
|
82
120
|
},
|
|
83
121
|
context.fetcher,
|
|
84
|
-
)
|
|
85
|
-
|
|
86
|
-
if (response.ok && response.result) {
|
|
87
|
-
return { ok: true, messageId: response.result.message_id };
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return { ok: false, error: "Failed to send message" };
|
|
91
|
-
} catch (err) {
|
|
92
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
93
|
-
}
|
|
122
|
+
),
|
|
123
|
+
);
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
export async function sendPhotoZalo(
|
|
@@ -98,32 +128,26 @@ export async function sendPhotoZalo(
|
|
|
98
128
|
photoUrl: string,
|
|
99
129
|
options: ZaloSendOptions = {},
|
|
100
130
|
): Promise<ZaloSendResult> {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
return
|
|
131
|
+
const resolved = resolveSendContextOrFailure(chatId, options);
|
|
132
|
+
if ("failure" in resolved) {
|
|
133
|
+
return resolved.failure;
|
|
104
134
|
}
|
|
135
|
+
const { context } = resolved;
|
|
105
136
|
|
|
106
137
|
if (!photoUrl?.trim()) {
|
|
107
138
|
return { ok: false, error: "No photo URL provided" };
|
|
108
139
|
}
|
|
109
140
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return { ok: true, messageId: response.result.message_id };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
return { ok: false, error: "Failed to send photo" };
|
|
126
|
-
} catch (err) {
|
|
127
|
-
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
128
|
-
}
|
|
141
|
+
return await runZaloSend("Failed to send photo", () =>
|
|
142
|
+
(async () =>
|
|
143
|
+
sendPhoto(
|
|
144
|
+
context.token,
|
|
145
|
+
{
|
|
146
|
+
chat_id: context.chatId,
|
|
147
|
+
photo: photoUrl.trim(),
|
|
148
|
+
caption: options.caption?.slice(0, 2000),
|
|
149
|
+
},
|
|
150
|
+
context.fetcher,
|
|
151
|
+
))(),
|
|
152
|
+
);
|
|
129
153
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildChannelOutboundSessionRoute,
|
|
3
|
+
stripChannelTargetPrefix,
|
|
4
|
+
stripTargetKindPrefix,
|
|
5
|
+
type ChannelOutboundSessionRouteParams,
|
|
6
|
+
} from "openclaw/plugin-sdk/core";
|
|
7
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
8
|
+
|
|
9
|
+
export function resolveZaloOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
|
10
|
+
const trimmed = stripChannelTargetPrefix(params.target, "zalo", "zl");
|
|
11
|
+
if (!trimmed) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const isGroup = normalizeLowercaseStringOrEmpty(trimmed).startsWith("group:");
|
|
15
|
+
const peerId = stripTargetKindPrefix(trimmed);
|
|
16
|
+
if (!peerId) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return buildChannelOutboundSessionRoute({
|
|
20
|
+
cfg: params.cfg,
|
|
21
|
+
agentId: params.agentId,
|
|
22
|
+
channel: "zalo",
|
|
23
|
+
accountId: params.accountId,
|
|
24
|
+
peer: {
|
|
25
|
+
kind: isGroup ? "group" : "direct",
|
|
26
|
+
id: peerId,
|
|
27
|
+
},
|
|
28
|
+
chatType: isGroup ? "group" : "direct",
|
|
29
|
+
from: isGroup ? `zalo:group:${peerId}` : `zalo:${peerId}`,
|
|
30
|
+
to: `zalo:${peerId}`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_ACCOUNT_ID,
|
|
3
|
+
formatDocsLink,
|
|
4
|
+
mergeAllowFromEntries,
|
|
5
|
+
type ChannelSetupDmPolicy,
|
|
6
|
+
type ChannelSetupWizard,
|
|
7
|
+
type OpenClawConfig,
|
|
8
|
+
} from "openclaw/plugin-sdk/setup";
|
|
9
|
+
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
|
10
|
+
|
|
11
|
+
type ZaloAccountSetupConfig = {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function noteZaloTokenHelp(
|
|
16
|
+
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
await prompter.note(
|
|
19
|
+
[
|
|
20
|
+
"1) Open Zalo Bot Platform: https://bot.zaloplatforms.com",
|
|
21
|
+
"2) Create a bot and get the token",
|
|
22
|
+
"3) Token looks like 12345689:abc-xyz",
|
|
23
|
+
"Tip: you can also set ZALO_BOT_TOKEN in your env.",
|
|
24
|
+
`Docs: ${formatDocsLink("/channels/zalo", "zalo")}`,
|
|
25
|
+
].join("\n"),
|
|
26
|
+
"Zalo bot token",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function promptZaloAllowFrom(params: {
|
|
31
|
+
cfg: OpenClawConfig;
|
|
32
|
+
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
|
|
33
|
+
accountId?: string;
|
|
34
|
+
}): Promise<OpenClawConfig> {
|
|
35
|
+
const { cfg, prompter } = params;
|
|
36
|
+
const accountId = params.accountId ?? resolveDefaultZaloAccountId(cfg);
|
|
37
|
+
const resolved = resolveZaloAccount({ cfg, accountId });
|
|
38
|
+
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
|
39
|
+
const entry = await prompter.text({
|
|
40
|
+
message: "Zalo allowFrom (user id)",
|
|
41
|
+
placeholder: "123456789",
|
|
42
|
+
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
43
|
+
validate: (value) => {
|
|
44
|
+
const raw = (value ?? "").trim();
|
|
45
|
+
if (!raw) {
|
|
46
|
+
return "Required";
|
|
47
|
+
}
|
|
48
|
+
if (!/^\d+$/.test(raw)) {
|
|
49
|
+
return "Use a numeric Zalo user id";
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const normalized = entry.trim();
|
|
55
|
+
const unique = mergeAllowFromEntries(existingAllowFrom, [normalized]);
|
|
56
|
+
|
|
57
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
58
|
+
return {
|
|
59
|
+
...cfg,
|
|
60
|
+
channels: {
|
|
61
|
+
...cfg.channels,
|
|
62
|
+
zalo: {
|
|
63
|
+
...cfg.channels?.zalo,
|
|
64
|
+
enabled: true,
|
|
65
|
+
dmPolicy: "allowlist",
|
|
66
|
+
allowFrom: unique,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
} as OpenClawConfig;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const currentAccount = cfg.channels?.zalo?.accounts?.[accountId] as
|
|
73
|
+
| ZaloAccountSetupConfig
|
|
74
|
+
| undefined;
|
|
75
|
+
return {
|
|
76
|
+
...cfg,
|
|
77
|
+
channels: {
|
|
78
|
+
...cfg.channels,
|
|
79
|
+
zalo: {
|
|
80
|
+
...cfg.channels?.zalo,
|
|
81
|
+
enabled: true,
|
|
82
|
+
accounts: {
|
|
83
|
+
...cfg.channels?.zalo?.accounts,
|
|
84
|
+
[accountId]: {
|
|
85
|
+
...currentAccount,
|
|
86
|
+
enabled: currentAccount?.enabled ?? true,
|
|
87
|
+
dmPolicy: "allowlist",
|
|
88
|
+
allowFrom: unique,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
} as OpenClawConfig;
|
|
94
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addWildcardAllowFrom,
|
|
3
|
+
createDelegatedSetupWizardProxy,
|
|
4
|
+
createPatchedAccountSetupAdapter,
|
|
5
|
+
createSetupInputPresenceValidator,
|
|
6
|
+
DEFAULT_ACCOUNT_ID,
|
|
7
|
+
normalizeAccountId,
|
|
8
|
+
type ChannelSetupDmPolicy,
|
|
9
|
+
type ChannelSetupWizard,
|
|
10
|
+
} from "openclaw/plugin-sdk/setup";
|
|
11
|
+
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
|
12
|
+
import { promptZaloAllowFrom } from "./setup-allow-from.js";
|
|
13
|
+
|
|
14
|
+
const channel = "zalo" as const;
|
|
15
|
+
|
|
16
|
+
type ZaloAccountSetupConfig = {
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
dmPolicy?: string;
|
|
19
|
+
allowFrom?: Array<string | number> | ReadonlyArray<string | number>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const zaloSetupAdapter = createPatchedAccountSetupAdapter({
|
|
23
|
+
channelKey: channel,
|
|
24
|
+
validateInput: createSetupInputPresenceValidator({
|
|
25
|
+
defaultAccountOnlyEnvError: "ZALO_BOT_TOKEN can only be used for the default account.",
|
|
26
|
+
whenNotUseEnv: [
|
|
27
|
+
{
|
|
28
|
+
someOf: ["token", "tokenFile"],
|
|
29
|
+
message: "Zalo requires token or --token-file (or --use-env).",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
}),
|
|
33
|
+
buildPatch: (input) =>
|
|
34
|
+
input.useEnv
|
|
35
|
+
? {}
|
|
36
|
+
: input.tokenFile
|
|
37
|
+
? { tokenFile: input.tokenFile }
|
|
38
|
+
: input.token
|
|
39
|
+
? { botToken: input.token }
|
|
40
|
+
: {},
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const zaloDmPolicy: ChannelSetupDmPolicy = {
|
|
44
|
+
label: "Zalo",
|
|
45
|
+
channel,
|
|
46
|
+
policyKey: "channels.zalo.dmPolicy",
|
|
47
|
+
allowFromKey: "channels.zalo.allowFrom",
|
|
48
|
+
resolveConfigKeys: (cfg, accountId) =>
|
|
49
|
+
(accountId ?? resolveDefaultZaloAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
|
|
50
|
+
? {
|
|
51
|
+
policyKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.dmPolicy`,
|
|
52
|
+
allowFromKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.allowFrom`,
|
|
53
|
+
}
|
|
54
|
+
: {
|
|
55
|
+
policyKey: "channels.zalo.dmPolicy",
|
|
56
|
+
allowFromKey: "channels.zalo.allowFrom",
|
|
57
|
+
},
|
|
58
|
+
getCurrent: (cfg, accountId) =>
|
|
59
|
+
resolveZaloAccount({
|
|
60
|
+
cfg: cfg,
|
|
61
|
+
accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
|
|
62
|
+
}).config.dmPolicy ?? "pairing",
|
|
63
|
+
setPolicy: (cfg, policy, accountId) => {
|
|
64
|
+
const resolvedAccountId =
|
|
65
|
+
accountId && normalizeAccountId(accountId)
|
|
66
|
+
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
|
|
67
|
+
: resolveDefaultZaloAccountId(cfg);
|
|
68
|
+
const resolved = resolveZaloAccount({
|
|
69
|
+
cfg: cfg,
|
|
70
|
+
accountId: resolvedAccountId,
|
|
71
|
+
});
|
|
72
|
+
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
|
73
|
+
return {
|
|
74
|
+
...cfg,
|
|
75
|
+
channels: {
|
|
76
|
+
...cfg.channels,
|
|
77
|
+
zalo: {
|
|
78
|
+
...cfg.channels?.zalo,
|
|
79
|
+
enabled: true,
|
|
80
|
+
dmPolicy: policy,
|
|
81
|
+
...(policy === "open"
|
|
82
|
+
? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
|
|
83
|
+
: {}),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const currentAccount = cfg.channels?.zalo?.accounts?.[resolvedAccountId] as
|
|
89
|
+
| ZaloAccountSetupConfig
|
|
90
|
+
| undefined;
|
|
91
|
+
return {
|
|
92
|
+
...cfg,
|
|
93
|
+
channels: {
|
|
94
|
+
...cfg.channels,
|
|
95
|
+
zalo: {
|
|
96
|
+
...cfg.channels?.zalo,
|
|
97
|
+
enabled: true,
|
|
98
|
+
accounts: {
|
|
99
|
+
...cfg.channels?.zalo?.accounts,
|
|
100
|
+
[resolvedAccountId]: {
|
|
101
|
+
...currentAccount,
|
|
102
|
+
enabled: currentAccount?.enabled ?? true,
|
|
103
|
+
dmPolicy: policy,
|
|
104
|
+
...(policy === "open"
|
|
105
|
+
? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
|
|
106
|
+
: {}),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
promptAllowFrom: async ({ cfg, prompter, accountId }) =>
|
|
114
|
+
promptZaloAllowFrom({
|
|
115
|
+
cfg,
|
|
116
|
+
prompter,
|
|
117
|
+
accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export function createZaloSetupWizardProxy(
|
|
122
|
+
loadWizard: () => Promise<ChannelSetupWizard>,
|
|
123
|
+
): ChannelSetupWizard {
|
|
124
|
+
return createDelegatedSetupWizardProxy({
|
|
125
|
+
channel,
|
|
126
|
+
loadWizard,
|
|
127
|
+
status: {
|
|
128
|
+
configuredLabel: "configured",
|
|
129
|
+
unconfiguredLabel: "needs token",
|
|
130
|
+
configuredHint: "recommended · configured",
|
|
131
|
+
unconfiguredHint: "recommended · newcomer-friendly",
|
|
132
|
+
configuredScore: 1,
|
|
133
|
+
unconfiguredScore: 10,
|
|
134
|
+
},
|
|
135
|
+
credentials: [],
|
|
136
|
+
delegateFinalize: true,
|
|
137
|
+
dmPolicy: zaloDmPolicy,
|
|
138
|
+
disable: (cfg) => ({
|
|
139
|
+
...cfg,
|
|
140
|
+
channels: {
|
|
141
|
+
...cfg.channels,
|
|
142
|
+
zalo: {
|
|
143
|
+
...cfg.channels?.zalo,
|
|
144
|
+
enabled: false,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
}
|