diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md index f6abc4303ef..4d40c2e9b4c 100644 --- a/docs/channels/zalouser.md +++ b/docs/channels/zalouser.md @@ -107,6 +107,28 @@ Example: } ``` +### Group mention gating + +- `channels.zalouser.groups..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:** diff --git a/docs/plugins/zalouser.md b/docs/plugins/zalouser.md index 1249db78bc9..9d84ae8e6da 100644 --- a/docs/plugins/zalouser.md +++ b/docs/plugins/zalouser.md @@ -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. diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index 7a220c07645..17f9cfbb7f9 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -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. diff --git a/extensions/zalouser/src/channel.sendpayload.test.ts b/extensions/zalouser/src/channel.sendpayload.test.ts index 07a246b4957..cdf478411f0 100644 --- a/extensions/zalouser/src/channel.sendpayload.test.ts +++ b/extensions/zalouser/src/channel.sendpayload.test.ts @@ -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) => { diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts index fead528730b..231bcc8b2d3 100644 --- a/extensions/zalouser/src/channel.test.ts +++ b/extensions/zalouser/src/channel.test.ts @@ -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; + 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(); + }); }); diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index f92a174d644..93b7449e1a5 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -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 = { threading: { resolveReplyToMode: () => "off", }, + actions: zalouserMessageActions, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => diff --git a/extensions/zalouser/src/monitor.account-scope.test.ts b/extensions/zalouser/src/monitor.account-scope.test.ts index 1f1ff598e74..a5a6e8967e9 100644 --- a/extensions/zalouser/src/monitor.account-scope.test.ts +++ b/extensions/zalouser/src/monitor.account-scope.test.ts @@ -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", () => { diff --git a/extensions/zalouser/src/monitor.group-gating.test.ts b/extensions/zalouser/src/monitor.group-gating.test.ts index 7e7ddf80ab8..25ef0e54594 100644 --- a/extensions/zalouser/src/monitor.group-gating.test.ts +++ b/extensions/zalouser/src/monitor.group-gating.test.ts @@ -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 () => { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 7912fcc6ceb..d0a9b099f9c 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -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; +}): Promise { + 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}`, }); diff --git a/extensions/zalouser/src/send.test.ts b/extensions/zalouser/src/send.test.ts index 7b4fbbe7240..92b3cec25f2 100644 --- a/extensions/zalouser/src/send.test.ts +++ b/extensions/zalouser/src/send.test.ts @@ -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, + }); + }); }); diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index b8a1ddf1bc4..07ae1408bff 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -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 { await sendZaloTypingEvent(threadId, options); } + +export async function sendReactionZalouser(params: { + threadId: string; + msgId: string; + cliMsgId: string; + emoji: string; + remove?: boolean; + profile?: string; + isGroup?: boolean; +}): Promise { + 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 { + await sendZaloDeliveredEvent(params); +} + +export async function sendSeenZalouser(params: { + profile?: string; + isGroup?: boolean; + message: ZaloEventMessage; +}): Promise { + await sendZaloSeenEvent(params); +} diff --git a/extensions/zalouser/src/tool.test.ts b/extensions/zalouser/src/tool.test.ts index 77e27a6280d..3ba392668aa 100644 --- a/extensions/zalouser/src/tool.test.ts +++ b/extensions/zalouser/src/tool.test.ts @@ -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", () => ({ diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index d22626b27fd..960978fd8ba 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -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; diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts index feae4c346f1..93abe7cbfa9 100644 --- a/extensions/zalouser/src/zalo-js.ts +++ b/extensions/zalouser/src/zalo-js.ts @@ -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(); const apiInitByProfile = new Map>(); @@ -56,6 +60,7 @@ type ActiveZaloListener = { }; const activeListeners = new Map(); +const groupContextCache = new Map(); 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 | 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): 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 { + 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; 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 { + 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 { + 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 { + 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 { const profile = normalizeProfile(profileInput); resetQrLogin(profile); + clearCachedGroupContext(profile); const listener = activeListeners.get(profile); if (listener) { listener.stop(); diff --git a/extensions/zalouser/src/zca-js-exports.d.ts b/extensions/zalouser/src/zca-js-exports.d.ts index 0721cee05ee..549465e470a 100644 --- a/extensions/zalouser/src/zca-js-exports.d.ts +++ b/extensions/zalouser/src/zca-js-exports.d.ts @@ -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; + getOwnId(): string; getAllGroups(): Promise<{ gridVerMap: Record; }>; @@ -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; + sendDeliveredEvent( + isSeen: boolean, + messages: ZaloEventMessageParams | ZaloEventMessageParams[], + type?: ThreadType, + ): Promise; + sendSeenEvent( + messages: ZaloEventMessageParams | ZaloEventMessageParams[], + type?: ThreadType, + ): Promise; } export class Zalo {