feat(zalouser): add reactions, group context, and receipt acks

This commit is contained in:
Peter Steinberger
2026-03-02 22:08:01 +00:00
parent 317075ef3d
commit f9025c3f55
15 changed files with 692 additions and 9 deletions

View File

@@ -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:**

View File

@@ -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.

View File

@@ -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.

View File

@@ -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) => {

View File

@@ -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();
});
});

View File

@@ -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 }) =>

View File

@@ -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", () => {

View File

@@ -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 () => {

View File

@@ -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}`,
});

View File

@@ -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,
});
});
});

View File

@@ -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);
}

View File

@@ -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", () => ({

View File

@@ -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;

View File

@@ -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();

View File

@@ -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 {