Zalo Personal Account Integration via zca-js

This page covers how to integrate a personal Zalo account using the native zca-js library, including installation, QR login, and configuration. It is intended for users who need to automate Zalo messaging without an external CLI binary.

Read this when

  • Setting up Zalo Personal for OpenClaw
  • Debugging Zalo Personal login or message flow

Status: experimental. This integration automates a personal Zalo account using native zca-js, running in-process without an external CLI binary.

Warning

This is an unofficial integration and could lead to account suspension or a ban. Use it at your own risk.

Install

Zalo Personal is an official external plugin, not included in the core. Install it before use:

openclaw plugins install @openclaw/zalouser
  • Pin a specific version: openclaw plugins install @openclaw/zalouser@<version>
  • From a source checkout: openclaw plugins install ./path/to/local/zalouser-plugin
  • More details: Plugins

Quick setup

  1. Install the plugin (see above).
  2. Log in using a QR code on the Gateway machine:
    • openclaw channels login --channel zalouser
    • Scan the QR code with the Zalo mobile app.
  3. Enable the channel:
{
  channels: {
    zalouser: {
      enabled: true,
      dmPolicy: "pairing",
    },
  },
}
  1. Restart the Gateway or complete the setup.
  2. DM access defaults to pairing; approve the pairing code on the first contact.

What it is

  • Runs entirely in-process using the zca-js library (no external zca/openzca binary).
  • Uses native event listeners (message, error) to receive inbound messages.
  • Sends replies directly through the JS API (text, media, link).
  • Designed for "personal account" use cases where the Zalo Bot API is not available.

Naming

The channel id is zalouser to make it clear this automates a personal Zalo user account (unofficial). zalo is reserved for a potential future official Zalo API integration.

Finding IDs (directory)

openclaw directory self --channel zalouser
openclaw directory peers list --channel zalouser --query "name"
openclaw directory groups list --channel zalouser --query "work"

Limits

  • Outbound text is split into chunks of 2000 characters (the Zalo client limit).
  • Streaming is not supported.
  • Completed inbound message ids are kept for 30 days, limited to the 1000 most recent entries per account.

Inbound durability

OpenClaw stores each raw zca-js message callback before processing it. Pending messages resume from the account queue after a Gateway restart, and processing stays serialized per direct chat or group.

The zca-js socket listener does not provide a delivery acknowledgement or automatically replay old messages after reconnecting. The durable queue therefore protects against local crashes after a callback reaches OpenClaw; it cannot recover a message the socket never delivered. Replay tombstones are mainly a safeguard against a repeated callback with the same Zalo message id.

Access control (DMs)

channels.zalouser.dmPolicy: pairing | allowlist | open | disabled (default: pairing).

channels.zalouser.allowFrom should use stable Zalo user IDs. It can also reference static sender access groups (accessGroup:<name>). During interactive setup, entered names can be resolved to IDs using the plugin's in-process contact lookup.

If a raw name remains in config, startup resolves it only when channels.zalouser.dangerouslyAllowNameMatching: true is enabled. Without that opt-in, runtime sender checks are ID-only and raw names are ignored for authorization.

Approve via:

  • openclaw pairing list zalouser
  • openclaw pairing approve zalouser <code>

Group access (optional)

  • Default: channels.zalouser.groupPolicy = "allowlist" (groups require an explicit allowlist entry).
  • Open all groups: channels.zalouser.groupPolicy = "open".
  • Block all groups: channels.zalouser.groupPolicy = "disabled".
  • With groupPolicy = "allowlist":
    • channels.zalouser.groups keys should be stable group IDs; names resolve to IDs on startup only when channels.zalouser.dangerouslyAllowNameMatching: true is enabled.
    • channels.zalouser.groupAllowFrom controls which senders in allowed groups can trigger the bot; static sender access groups can be referenced with accessGroup:<name>.
  • The configure wizard can prompt for group allowlists.
  • Group allowlist matching is ID-only by default. Unresolved names are ignored for auth unless channels.zalouser.dangerouslyAllowNameMatching: true is enabled.
  • channels.zalouser.dangerouslyAllowNameMatching: true is a break-glass compatibility mode that re-enables mutable startup name resolution and runtime group-name matching.
  • groupAllowFrom does not fall back to allowFrom for normal group messages: leaving it empty on an allowlisted group opens that group to any sender. Authorized control commands (for example /new) are the exception; command sender checks fall back to allowFrom when groupAllowFrom is empty.

Example:

{
  channels: {
    zalouser: {
      groupPolicy: "allowlist",
      groupAllowFrom: ["1471383327500481391"],
      groups: {
        "123456789": { enabled: true },
        "Work Chat": { enabled: true },
      },
    },
  },
}

Note

channels.zalouser.groups.<id>.allow is a legacy field name; current config uses enabled. openclaw doctor --fix migrates allow to enabled automatically.

Group mention gating

  • channels.zalouser.groups.<group>.requireMention controls whether group replies require a mention.
  • Resolution order: group id -> group:<id> alias -> group name/slug (name-based candidates only apply when dangerouslyAllowNameMatching: true) -> * -> default (true).
  • Applies both to allowlisted groups and open group mode.
  • Quoting a bot message counts as an implicit mention for group activation.
  • Authorized control commands (for example /new) can bypass mention gating.
  • When a group message is skipped because a mention is required, OpenClaw stores it as pending group history and includes it on the next processed group message.
  • Group history limit: channels.zalouser.historyLimit, then messages.groupChat.historyLimit, then a fallback of 50.

Example:

{
  channels: {
    zalouser: {
      groupPolicy: "allowlist",
      groups: {
        "*": { enabled: true, requireMention: true },
        "Work Chat": { enabled: true, requireMention: false },
      },
    },
  },
}

Multi-account

Accounts map to zalouser profiles in OpenClaw state. Example:

{
  channels: {
    zalouser: {
      enabled: true,
      defaultAccount: "default",
      accounts: {
        work: { enabled: true, profile: "work" },
      },
    },
  },
}

Environment variables

Profile selection can also come from environment variables:

VarPurpose
ZALOUSER_PROFILEProfile name to use when no profile is set in channel or account config.
ZCA_PROFILELegacy fallback, used only when ZALOUSER_PROFILE is not set.

Profile names select the saved Zalo login credentials in OpenClaw state. Resolution order:

  1. Explicit profile in config.
  2. ZALOUSER_PROFILE.
  3. ZCA_PROFILE.
  4. The account id for non-default accounts, or default for the default account.

For multi-account setups, prefer setting profile on each account in config so one environment variable does not make multiple accounts share the same login session.

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
  • For inbound messages that include event metadata, OpenClaw sends delivered and seen acknowledgements (best-effort).

Troubleshooting

Login doesn't stick:

  • openclaw channels status --probe
  • Re-login: openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser

Allowlist/group name didn't resolve:

  • Use numeric IDs in allowFrom/groupAllowFrom and stable group IDs in groups. If you intentionally need exact friend/group names, enable channels.zalouser.dangerouslyAllowNameMatching: true.

Upgraded from an old external zca/CLI-based setup:

  • Remove any external zca process assumptions; the channel now runs fully in-process via zca-js, with no external CLI binary.