mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(zalouser): add reactions, group context, and receipt acks
This commit is contained in:
@@ -107,6 +107,28 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
### Group mention gating
|
||||
|
||||
- `channels.zalouser.groups.<group>.requireMention` controls whether group replies require a mention.
|
||||
- Resolution order: exact group id/name -> normalized group slug -> `*` -> default (`true`).
|
||||
- This applies both to allowlisted groups and open group mode.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"*": { allow: true, requireMention: true },
|
||||
"Work Chat": { allow: true, requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-account
|
||||
|
||||
Accounts map to `zalouser` profiles in OpenClaw state. Example:
|
||||
@@ -125,6 +147,14 @@ Accounts map to `zalouser` profiles in OpenClaw state. Example:
|
||||
}
|
||||
```
|
||||
|
||||
## Typing, reactions, and delivery acknowledgements
|
||||
|
||||
- OpenClaw sends a typing event before dispatching a reply (best-effort).
|
||||
- Message reaction action `react` is supported for `zalouser` in channel actions.
|
||||
- Use `remove: true` to remove a specific reaction emoji from a message.
|
||||
- Reaction semantics: [Reactions](/tools/reactions)
|
||||
- For inbound messages that include event metadata, OpenClaw sends delivered + seen acknowledgements (best-effort).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Login doesn't stick:**
|
||||
|
||||
@@ -73,3 +73,5 @@ openclaw directory peers list --channel zalouser --query "name"
|
||||
Tool name: `zalouser`
|
||||
|
||||
Actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
|
||||
Channel message actions also support `react` for message reactions.
|
||||
|
||||
@@ -19,4 +19,5 @@ Channel notes:
|
||||
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
|
||||
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
|
||||
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
|
||||
- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction.
|
||||
- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { zalouserPlugin } from "./channel.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
|
||||
sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", async (importOriginal) => {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
import { sendReactionZalouser } from "./send.js";
|
||||
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
sendReactionZalouser: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
||||
|
||||
describe("zalouser outbound chunker", () => {
|
||||
it("chunks without empty strings and respects limit", () => {
|
||||
@@ -18,6 +29,11 @@ describe("zalouser outbound chunker", () => {
|
||||
});
|
||||
|
||||
describe("zalouser channel policies", () => {
|
||||
beforeEach(() => {
|
||||
mockSendReaction.mockClear();
|
||||
mockSendReaction.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("resolves requireMention from group config", () => {
|
||||
const resolveRequireMention = zalouserPlugin.groups?.resolveRequireMention;
|
||||
expect(resolveRequireMention).toBeTypeOf("function");
|
||||
@@ -86,4 +102,39 @@ describe("zalouser channel policies", () => {
|
||||
});
|
||||
expect(policy).toEqual({ deny: ["system.run"] });
|
||||
});
|
||||
|
||||
it("handles react action", async () => {
|
||||
const actions = zalouserPlugin.actions;
|
||||
expect(actions?.listActions?.({ cfg: { channels: { zalouser: { enabled: true } } } })).toEqual([
|
||||
"react",
|
||||
]);
|
||||
const result = await actions?.handleAction?.({
|
||||
channel: "zalouser",
|
||||
action: "react",
|
||||
params: {
|
||||
threadId: "123456",
|
||||
messageId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "default",
|
||||
threadId: "123456",
|
||||
isGroup: false,
|
||||
msgId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
remove: false,
|
||||
});
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelDock,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
GroupToolPolicyConfig,
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
import { ZalouserConfigSchema } from "./config-schema.js";
|
||||
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
||||
import { probeZalouser } from "./probe.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
import {
|
||||
listZaloFriendsMatching,
|
||||
@@ -156,6 +157,106 @@ function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveZalouserReactionMessageIds(params: {
|
||||
messageId?: string;
|
||||
cliMsgId?: string;
|
||||
currentMessageId?: string | number;
|
||||
}): { msgId: string; cliMsgId: string } | null {
|
||||
const explicitMessageId = params.messageId?.trim() ?? "";
|
||||
const explicitCliMsgId = params.cliMsgId?.trim() ?? "";
|
||||
if (explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
|
||||
const current =
|
||||
typeof params.currentMessageId === "number" ? String(params.currentMessageId) : "";
|
||||
const currentRaw =
|
||||
typeof params.currentMessageId === "string" ? params.currentMessageId.trim() : current;
|
||||
if (!currentRaw) {
|
||||
return null;
|
||||
}
|
||||
const [msgIdPart, cliMsgIdPart] = currentRaw.split(":").map((value) => value.trim());
|
||||
if (msgIdPart && cliMsgIdPart) {
|
||||
return { msgId: msgIdPart, cliMsgId: cliMsgIdPart };
|
||||
}
|
||||
if (explicitMessageId && !explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: currentRaw };
|
||||
}
|
||||
if (!explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: currentRaw, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
return { msgId: currentRaw, cliMsgId: currentRaw };
|
||||
}
|
||||
|
||||
const zalouserMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listZalouserAccountIds(cfg)
|
||||
.map((accountId) => resolveZalouserAccountSync({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
if (accounts.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return ["react"];
|
||||
},
|
||||
supportsAction: ({ action }) => action === "react",
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
if (action !== "react") {
|
||||
throw new Error(`Zalouser action ${action} not supported`);
|
||||
}
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const threadId =
|
||||
(typeof params.threadId === "string" ? params.threadId.trim() : "") ||
|
||||
(typeof params.to === "string" ? params.to.trim() : "") ||
|
||||
(typeof params.chatId === "string" ? params.chatId.trim() : "") ||
|
||||
(toolContext?.currentChannelId?.trim() ?? "");
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser react requires threadId (or to/chatId).");
|
||||
}
|
||||
const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
|
||||
if (!emoji) {
|
||||
throw new Error("Zalouser react requires emoji.");
|
||||
}
|
||||
const ids = resolveZalouserReactionMessageIds({
|
||||
messageId: typeof params.messageId === "string" ? params.messageId : undefined,
|
||||
cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
|
||||
currentMessageId: toolContext?.currentMessageId,
|
||||
});
|
||||
if (!ids) {
|
||||
throw new Error(
|
||||
"Zalouser react requires messageId + cliMsgId (or a current message context id).",
|
||||
);
|
||||
}
|
||||
const result = await sendReactionZalouser({
|
||||
profile: account.profile,
|
||||
threadId,
|
||||
isGroup: params.isGroup === true,
|
||||
msgId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
emoji,
|
||||
remove: params.remove === true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to react on Zalo message");
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
params.remove === true
|
||||
? `Removed reaction ${emoji} from ${ids.msgId}`
|
||||
: `Reacted ${emoji} on ${ids.msgId}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
messageId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
threadId,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserDock: ChannelDock = {
|
||||
id: "zalouser",
|
||||
capabilities: {
|
||||
@@ -262,6 +363,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
actions: zalouserMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
|
||||
@@ -6,10 +6,14 @@ import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
sendDeliveredZalouser: sendDeliveredZalouserMock,
|
||||
sendSeenZalouser: sendSeenZalouserMock,
|
||||
}));
|
||||
|
||||
describe("zalouser monitor pairing account scoping", () => {
|
||||
|
||||
@@ -6,10 +6,14 @@ import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
const sendMessageZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendTypingZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendDeliveredZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sendSeenZalouserMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
sendDeliveredZalouser: sendDeliveredZalouserMock,
|
||||
sendSeenZalouser: sendSeenZalouserMock,
|
||||
}));
|
||||
|
||||
function createAccount(): ResolvedZalouserAccount {
|
||||
@@ -147,6 +151,8 @@ describe("zalouser monitor group mention gating", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageZalouserMock.mockClear();
|
||||
sendTypingZalouserMock.mockClear();
|
||||
sendDeliveredZalouserMock.mockClear();
|
||||
sendSeenZalouserMock.mockClear();
|
||||
});
|
||||
|
||||
it("skips unmentioned group messages when requireMention=true", async () => {
|
||||
|
||||
@@ -19,9 +19,19 @@ import {
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import { sendMessageZalouser, sendTypingZalouser } from "./send.js";
|
||||
import {
|
||||
sendDeliveredZalouser,
|
||||
sendMessageZalouser,
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
import { listZaloFriends, listZaloGroups, startZaloListener } from "./zalo-js.js";
|
||||
import {
|
||||
listZaloFriends,
|
||||
listZaloGroups,
|
||||
resolveZaloGroupContext,
|
||||
startZaloListener,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
export type ZalouserMonitorOptions = {
|
||||
account: ResolvedZalouserAccount;
|
||||
@@ -142,6 +152,24 @@ function resolveGroupRequireMention(params: {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendZalouserDeliveryAcks(params: {
|
||||
profile: string;
|
||||
isGroup: boolean;
|
||||
message: NonNullable<ZaloInboundMessage["eventMessage"]>;
|
||||
}): Promise<void> {
|
||||
await sendDeliveredZalouser({
|
||||
profile: params.profile,
|
||||
isGroup: params.isGroup,
|
||||
message: params.message,
|
||||
isSeen: true,
|
||||
});
|
||||
await sendSeenZalouser({
|
||||
profile: params.profile,
|
||||
isGroup: params.isGroup,
|
||||
message: params.message,
|
||||
});
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: ZaloInboundMessage,
|
||||
account: ResolvedZalouserAccount,
|
||||
@@ -169,7 +197,32 @@ async function processMessage(
|
||||
return;
|
||||
}
|
||||
const senderName = message.senderName ?? "";
|
||||
const groupName = message.groupName ?? "";
|
||||
const configuredGroupName = message.groupName?.trim() || "";
|
||||
const groupContext =
|
||||
isGroup && !configuredGroupName
|
||||
? await resolveZaloGroupContext(account.profile, chatId).catch((err) => {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`zalouser: group context lookup failed for ${chatId}: ${String(err)}`,
|
||||
);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
const groupName = configuredGroupName || groupContext?.name?.trim() || "";
|
||||
const groupMembers = groupContext?.members?.slice(0, 20).join(", ") || undefined;
|
||||
|
||||
if (message.eventMessage) {
|
||||
try {
|
||||
await sendZalouserDeliveryAcks({
|
||||
profile: account.profile,
|
||||
isGroup,
|
||||
message: message.eventMessage,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(core, runtime, `zalouser: delivery/seen ack failed for ${chatId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
||||
@@ -316,7 +369,10 @@ async function processMessage(
|
||||
wasMentioned,
|
||||
implicitMention: message.implicitMention === true,
|
||||
hasAnyMention: explicitMention.hasAnyMention,
|
||||
allowTextCommands: core.channel.commands.shouldHandleTextCommands(config),
|
||||
allowTextCommands: core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config,
|
||||
surface: "zalouser",
|
||||
}),
|
||||
hasControlCommand,
|
||||
commandAuthorized: commandAuthorized === true,
|
||||
});
|
||||
@@ -354,6 +410,9 @@ async function processMessage(
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: isGroup ? groupName || undefined : undefined,
|
||||
GroupChannel: isGroup ? groupName || undefined : undefined,
|
||||
GroupMembers: isGroup ? groupMembers : undefined,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
WasMentioned: isGroup ? mentionGate.effectiveWasMentioned : undefined,
|
||||
@@ -361,6 +420,10 @@ async function processMessage(
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
MessageSid: message.msgId ?? message.cliMsgId ?? `${message.timestampMs}`,
|
||||
MessageSidFull:
|
||||
message.msgId && message.cliMsgId
|
||||
? `${message.msgId}:${message.cliMsgId}`
|
||||
: (message.msgId ?? message.cliMsgId ?? undefined),
|
||||
OriginatingChannel: "zalouser",
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
});
|
||||
|
||||
@@ -1,27 +1,46 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
sendDeliveredZalouser,
|
||||
sendImageZalouser,
|
||||
sendLinkZalouser,
|
||||
sendMessageZalouser,
|
||||
sendReactionZalouser,
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import { sendZaloLink, sendZaloTextMessage, sendZaloTypingEvent } from "./zalo-js.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
sendZaloTextMessage: vi.fn(),
|
||||
sendZaloLink: vi.fn(),
|
||||
sendZaloTypingEvent: vi.fn(),
|
||||
sendZaloReaction: vi.fn(),
|
||||
sendZaloDeliveredEvent: vi.fn(),
|
||||
sendZaloSeenEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSendText = vi.mocked(sendZaloTextMessage);
|
||||
const mockSendLink = vi.mocked(sendZaloLink);
|
||||
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
|
||||
const mockSendReaction = vi.mocked(sendZaloReaction);
|
||||
const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent);
|
||||
const mockSendSeen = vi.mocked(sendZaloSeenEvent);
|
||||
|
||||
describe("zalouser send helpers", () => {
|
||||
beforeEach(() => {
|
||||
mockSendText.mockReset();
|
||||
mockSendLink.mockReset();
|
||||
mockSendTyping.mockReset();
|
||||
mockSendReaction.mockReset();
|
||||
mockSendDelivered.mockReset();
|
||||
mockSendSeen.mockReset();
|
||||
});
|
||||
|
||||
it("delegates text send to JS transport", async () => {
|
||||
@@ -79,4 +98,60 @@ describe("zalouser send helpers", () => {
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates reaction helper to JS transport", async () => {
|
||||
mockSendReaction.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await sendReactionZalouser({
|
||||
threadId: "thread-5",
|
||||
profile: "p5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
});
|
||||
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "p5",
|
||||
threadId: "thread-5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
remove: undefined,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, error: undefined });
|
||||
});
|
||||
|
||||
it("delegates delivered+seen helpers to JS transport", async () => {
|
||||
mockSendDelivered.mockResolvedValueOnce();
|
||||
mockSendSeen.mockResolvedValueOnce();
|
||||
|
||||
const message = {
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
uidFrom: "1",
|
||||
idTo: "2",
|
||||
msgType: "webchat",
|
||||
st: 1,
|
||||
at: 0,
|
||||
cmd: 0,
|
||||
ts: "123",
|
||||
};
|
||||
|
||||
await sendDeliveredZalouser({ profile: "p6", isGroup: true, message, isSeen: false });
|
||||
await sendSeenZalouser({ profile: "p6", isGroup: true, message });
|
||||
|
||||
expect(mockSendDelivered).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
isSeen: false,
|
||||
});
|
||||
expect(mockSendSeen).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||
import { sendZaloLink, sendZaloTextMessage, sendZaloTypingEvent } from "./zalo-js.js";
|
||||
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
export type ZalouserSendOptions = ZaloSendOptions;
|
||||
export type ZalouserSendResult = ZaloSendResult;
|
||||
@@ -37,3 +44,44 @@ export async function sendTypingZalouser(
|
||||
): Promise<void> {
|
||||
await sendZaloTypingEvent(threadId, options);
|
||||
}
|
||||
|
||||
export async function sendReactionZalouser(params: {
|
||||
threadId: string;
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
emoji: string;
|
||||
remove?: boolean;
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
}): Promise<ZalouserSendResult> {
|
||||
const result = await sendZaloReaction({
|
||||
profile: params.profile,
|
||||
threadId: params.threadId,
|
||||
isGroup: params.isGroup,
|
||||
msgId: params.msgId,
|
||||
cliMsgId: params.cliMsgId,
|
||||
emoji: params.emoji,
|
||||
remove: params.remove,
|
||||
});
|
||||
return {
|
||||
ok: result.ok,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendDeliveredZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
isSeen?: boolean;
|
||||
}): Promise<void> {
|
||||
await sendZaloDeliveredEvent(params);
|
||||
}
|
||||
|
||||
export async function sendSeenZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
}): Promise<void> {
|
||||
await sendZaloSeenEvent(params);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn(),
|
||||
sendImageZalouser: vi.fn(),
|
||||
sendLinkZalouser: vi.fn(),
|
||||
sendReactionZalouser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
|
||||
@@ -16,6 +16,18 @@ export type ZaloGroupMember = {
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloEventMessage = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
export type ZaloInboundMessage = {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
@@ -30,6 +42,7 @@ export type ZaloInboundMessage = {
|
||||
wasExplicitlyMentioned?: boolean;
|
||||
canResolveExplicitMention?: boolean;
|
||||
implicitMention?: boolean;
|
||||
eventMessage?: ZaloEventMessage;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
@@ -53,6 +66,12 @@ export type ZaloSendResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ZaloGroupContext = {
|
||||
groupId: string;
|
||||
name?: string;
|
||||
members?: string[];
|
||||
};
|
||||
|
||||
export type ZaloAuthStatus = {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
LoginQRCallbackEventType,
|
||||
Reactions,
|
||||
ThreadType,
|
||||
Zalo,
|
||||
type API,
|
||||
@@ -18,6 +19,8 @@ import {
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import type {
|
||||
ZaloAuthStatus,
|
||||
ZaloEventMessage,
|
||||
ZaloGroupContext,
|
||||
ZaloGroup,
|
||||
ZaloGroupMember,
|
||||
ZaloInboundMessage,
|
||||
@@ -32,6 +35,7 @@ const QR_LOGIN_TTL_MS = 3 * 60_000;
|
||||
const DEFAULT_QR_START_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_QR_WAIT_TIMEOUT_MS = 120_000;
|
||||
const GROUP_INFO_CHUNK_SIZE = 80;
|
||||
const GROUP_CONTEXT_CACHE_TTL_MS = 5 * 60_000;
|
||||
|
||||
const apiByProfile = new Map<string, API>();
|
||||
const apiInitByProfile = new Map<string, Promise<API>>();
|
||||
@@ -56,6 +60,7 @@ type ActiveZaloListener = {
|
||||
};
|
||||
|
||||
const activeListeners = new Map<string, ActiveZaloListener>();
|
||||
const groupContextCache = new Map<string, { value: ZaloGroupContext; expiresAt: number }>();
|
||||
|
||||
type StoredZaloCredentials = {
|
||||
imei: string;
|
||||
@@ -132,6 +137,27 @@ function toNumberId(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function toStringValue(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(Math.trunc(value));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function toInteger(value: unknown, fallback = 0): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
const parsed = Number.parseInt(String(value ?? ""), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
}
|
||||
|
||||
function normalizeMessageContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
@@ -179,6 +205,65 @@ function extractMentionIds(raw: unknown): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveGroupNameFromMessageData(data: Record<string, unknown>): string | undefined {
|
||||
const candidates = [data.groupName, data.gName, data.idToName, data.threadName, data.roomName];
|
||||
for (const candidate of candidates) {
|
||||
const value = toStringValue(candidate);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildEventMessage(data: Record<string, unknown>): ZaloEventMessage | undefined {
|
||||
const msgId = toStringValue(data.msgId);
|
||||
const cliMsgId = toStringValue(data.cliMsgId);
|
||||
const uidFrom = toStringValue(data.uidFrom);
|
||||
const idTo = toStringValue(data.idTo);
|
||||
if (!msgId || !cliMsgId || !uidFrom || !idTo) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
msgId,
|
||||
cliMsgId,
|
||||
uidFrom,
|
||||
idTo,
|
||||
msgType: toStringValue(data.msgType) || "webchat",
|
||||
st: toInteger(data.st, 0),
|
||||
at: toInteger(data.at, 0),
|
||||
cmd: toInteger(data.cmd, 0),
|
||||
ts: toStringValue(data.ts) || Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReactionIcon(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return Reactions.LIKE;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "like" || trimmed === "👍" || trimmed === ":+1:") {
|
||||
return Reactions.LIKE;
|
||||
}
|
||||
if (lower === "heart" || trimmed === "❤️" || trimmed === "<3") {
|
||||
return Reactions.HEART;
|
||||
}
|
||||
if (lower === "haha" || lower === "laugh" || trimmed === "😂") {
|
||||
return Reactions.HAHA;
|
||||
}
|
||||
if (lower === "wow" || trimmed === "😮") {
|
||||
return Reactions.WOW;
|
||||
}
|
||||
if (lower === "cry" || trimmed === "😢") {
|
||||
return Reactions.CRY;
|
||||
}
|
||||
if (lower === "angry" || trimmed === "😡") {
|
||||
return Reactions.ANGRY;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function extractSendMessageId(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== "object") {
|
||||
return undefined;
|
||||
@@ -436,6 +521,60 @@ async function fetchGroupsByIds(api: API, ids: string[]): Promise<Map<string, Gr
|
||||
return result;
|
||||
}
|
||||
|
||||
function makeGroupContextCacheKey(profile: string, groupId: string): string {
|
||||
return `${profile}:${groupId}`;
|
||||
}
|
||||
|
||||
function readCachedGroupContext(profile: string, groupId: string): ZaloGroupContext | null {
|
||||
const key = makeGroupContextCacheKey(profile, groupId);
|
||||
const cached = groupContextCache.get(key);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
groupContextCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
function writeCachedGroupContext(profile: string, context: ZaloGroupContext): void {
|
||||
const key = makeGroupContextCacheKey(profile, context.groupId);
|
||||
groupContextCache.set(key, {
|
||||
value: context,
|
||||
expiresAt: Date.now() + GROUP_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function clearCachedGroupContext(profile: string): void {
|
||||
for (const key of groupContextCache.keys()) {
|
||||
if (key.startsWith(`${profile}:`)) {
|
||||
groupContextCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractGroupMembersFromInfo(
|
||||
groupInfo: (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] }) | undefined,
|
||||
): string[] | undefined {
|
||||
if (!groupInfo || !Array.isArray(groupInfo.currentMems)) {
|
||||
return undefined;
|
||||
}
|
||||
const members = groupInfo.currentMems
|
||||
.map((member) => {
|
||||
if (!member || typeof member !== "object") {
|
||||
return "";
|
||||
}
|
||||
const record = member as { dName?: unknown; zaloName?: unknown };
|
||||
return toStringValue(record.dName) || toStringValue(record.zaloName);
|
||||
})
|
||||
.filter(Boolean);
|
||||
if (members.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return members;
|
||||
}
|
||||
|
||||
function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMessage | null {
|
||||
const data = message.data as Record<string, unknown>;
|
||||
const isGroup = message.type === ThreadType.Group;
|
||||
@@ -461,11 +600,13 @@ function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMess
|
||||
const implicitMention = Boolean(
|
||||
normalizedOwnUserId && quoteOwnerId && quoteOwnerId === normalizedOwnUserId,
|
||||
);
|
||||
const eventMessage = buildEventMessage(data);
|
||||
return {
|
||||
threadId,
|
||||
isGroup,
|
||||
senderId,
|
||||
senderName: typeof data.dName === "string" ? data.dName.trim() || undefined : undefined,
|
||||
groupName: isGroup ? resolveGroupNameFromMessageData(data) : undefined,
|
||||
content,
|
||||
timestampMs: resolveInboundTimestamp(data.ts),
|
||||
msgId: typeof data.msgId === "string" ? data.msgId : undefined,
|
||||
@@ -474,6 +615,7 @@ function toInboundMessage(message: Message, ownUserId?: string): ZaloInboundMess
|
||||
canResolveExplicitMention,
|
||||
wasExplicitlyMentioned,
|
||||
implicitMention,
|
||||
eventMessage,
|
||||
raw: message,
|
||||
};
|
||||
}
|
||||
@@ -650,6 +792,34 @@ export async function listZaloGroupMembers(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function resolveZaloGroupContext(
|
||||
profileInput: string | null | undefined,
|
||||
groupId: string,
|
||||
): Promise<ZaloGroupContext> {
|
||||
const profile = normalizeProfile(profileInput);
|
||||
const normalizedGroupId = toNumberId(groupId) || groupId.trim();
|
||||
if (!normalizedGroupId) {
|
||||
throw new Error("groupId is required");
|
||||
}
|
||||
const cached = readCachedGroupContext(profile, normalizedGroupId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const api = await ensureApi(profile);
|
||||
const response = await api.getGroupInfo(normalizedGroupId);
|
||||
const groupInfo = response.gridInfoMap?.[normalizedGroupId] as
|
||||
| (GroupInfo & { currentMems?: unknown[]; memVerList?: unknown[] })
|
||||
| undefined;
|
||||
const context: ZaloGroupContext = {
|
||||
groupId: normalizedGroupId,
|
||||
name: groupInfo?.name?.trim() || undefined,
|
||||
members: extractGroupMembersFromInfo(groupInfo),
|
||||
};
|
||||
writeCachedGroupContext(profile, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function sendZaloTextMessage(
|
||||
threadId: string,
|
||||
text: string,
|
||||
@@ -716,6 +886,62 @@ export async function sendZaloTypingEvent(
|
||||
await api.sendTypingEvent(trimmedThreadId, type);
|
||||
}
|
||||
|
||||
export async function sendZaloReaction(params: {
|
||||
profile?: string | null;
|
||||
threadId: string;
|
||||
isGroup?: boolean;
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
emoji: string;
|
||||
remove?: boolean;
|
||||
}): Promise<{ ok: boolean; error?: string }> {
|
||||
const profile = normalizeProfile(params.profile);
|
||||
const threadId = params.threadId.trim();
|
||||
const msgId = toStringValue(params.msgId);
|
||||
const cliMsgId = toStringValue(params.cliMsgId);
|
||||
if (!threadId || !msgId || !cliMsgId) {
|
||||
return { ok: false, error: "threadId, msgId, and cliMsgId are required" };
|
||||
}
|
||||
try {
|
||||
const api = await ensureApi(profile);
|
||||
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
const icon = params.remove
|
||||
? { rType: -1, source: 6, icon: "" }
|
||||
: normalizeReactionIcon(params.emoji);
|
||||
await api.addReaction(icon, {
|
||||
data: { msgId, cliMsgId },
|
||||
threadId,
|
||||
type,
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, error: toErrorMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendZaloDeliveredEvent(params: {
|
||||
profile?: string | null;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
isSeen?: boolean;
|
||||
}): Promise<void> {
|
||||
const profile = normalizeProfile(params.profile);
|
||||
const api = await ensureApi(profile);
|
||||
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
await api.sendDeliveredEvent(params.isSeen === true, params.message, type);
|
||||
}
|
||||
|
||||
export async function sendZaloSeenEvent(params: {
|
||||
profile?: string | null;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
}): Promise<void> {
|
||||
const profile = normalizeProfile(params.profile);
|
||||
const api = await ensureApi(profile);
|
||||
const type = params.isGroup ? ThreadType.Group : ThreadType.User;
|
||||
await api.sendSeenEvent(params.message, type);
|
||||
}
|
||||
|
||||
export async function sendZaloLink(
|
||||
threadId: string,
|
||||
url: string,
|
||||
@@ -964,6 +1190,7 @@ export async function logoutZaloProfile(profileInput?: string | null): Promise<{
|
||||
}> {
|
||||
const profile = normalizeProfile(profileInput);
|
||||
resetQrLogin(profile);
|
||||
clearCachedGroupContext(profile);
|
||||
|
||||
const listener = activeListeners.get(profile);
|
||||
if (listener) {
|
||||
@@ -1150,6 +1377,7 @@ export async function resolveZaloAllowFromEntries(params: {
|
||||
export async function clearProfileRuntimeArtifacts(profileInput?: string | null): Promise<void> {
|
||||
const profile = normalizeProfile(profileInput);
|
||||
resetQrLogin(profile);
|
||||
clearCachedGroupContext(profile);
|
||||
const listener = activeListeners.get(profile);
|
||||
if (listener) {
|
||||
listener.stop();
|
||||
|
||||
52
extensions/zalouser/src/zca-js-exports.d.ts
vendored
52
extensions/zalouser/src/zca-js-exports.d.ts
vendored
@@ -4,6 +4,18 @@ declare module "zca-js" {
|
||||
Group = 1,
|
||||
}
|
||||
|
||||
export enum Reactions {
|
||||
HEART = "/-heart",
|
||||
LIKE = "/-strong",
|
||||
HAHA = ":>",
|
||||
WOW = ":o",
|
||||
CRY = ":-((",
|
||||
ANGRY = ":-h",
|
||||
KISS = ":-*",
|
||||
TEARS_OF_JOY = ":')",
|
||||
NONE = "",
|
||||
}
|
||||
|
||||
export enum LoginQRCallbackEventType {
|
||||
QRCodeGenerated = 0,
|
||||
QRCodeExpired = 1,
|
||||
@@ -110,6 +122,27 @@ declare module "zca-js" {
|
||||
stop(): void;
|
||||
};
|
||||
|
||||
export type ZaloEventMessageParams = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
export type AddReactionDestination = {
|
||||
data: {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
};
|
||||
threadId: string;
|
||||
type: ThreadType;
|
||||
};
|
||||
|
||||
export class API {
|
||||
listener: Listener;
|
||||
getContext(): {
|
||||
@@ -124,6 +157,7 @@ declare module "zca-js" {
|
||||
};
|
||||
fetchAccountInfo(): Promise<{ profile: User } | User>;
|
||||
getAllFriends(): Promise<User[]>;
|
||||
getOwnId(): string;
|
||||
getAllGroups(): Promise<{
|
||||
gridVerMap: Record<string, string>;
|
||||
}>;
|
||||
@@ -154,6 +188,24 @@ declare module "zca-js" {
|
||||
threadId: string,
|
||||
type?: ThreadType,
|
||||
): Promise<{ msgId?: string | number }>;
|
||||
sendTypingEvent(
|
||||
threadId: string,
|
||||
type?: ThreadType,
|
||||
destType?: number,
|
||||
): Promise<{ status: number }>;
|
||||
addReaction(
|
||||
icon: Reactions | string | { rType: number; source: number; icon: string },
|
||||
dest: AddReactionDestination,
|
||||
): Promise<unknown>;
|
||||
sendDeliveredEvent(
|
||||
isSeen: boolean,
|
||||
messages: ZaloEventMessageParams | ZaloEventMessageParams[],
|
||||
type?: ThreadType,
|
||||
): Promise<unknown>;
|
||||
sendSeenEvent(
|
||||
messages: ZaloEventMessageParams | ZaloEventMessageParams[],
|
||||
type?: ThreadType,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
export class Zalo {
|
||||
|
||||
Reference in New Issue
Block a user