feat(channels): introduce Zalo Personal channel integration (#32)

* feat(channels): implement Zalo Personal Chat (ZCA) protocol layer

Implement complete Zalo Personal Chat integration including:
- Message protocol layer (request/response/event types)
- Connection management with auth flow
- Message sending/receiving with text and media support
- User/group management and sync
- Telegram-style contact and conversation handling
- Comprehensive unit tests with 85%+ coverage

Architecture follows existing channel patterns (Telegram, Feishu) with
raw API calls for session management and message delivery. Includes
error handling, rate limiting awareness, and logging.

* feat(channels): add Zalo Personal channel integration layer

Wire protocol package to GoClaw's channel system:
- channel.go: Channel struct, Start/Stop/Send, listenLoop, message handlers
- auth.go: credential resolution (preloaded > file > QR), persistence
- policy.go: DM/group policy, @mention gating, pairing with debounce
- factory.go: managed mode factory (requires credentials, no QR)
- cmd/gateway.go: register standalone + managed factory

* feat(ui): add Zalo Personal channel type to web dashboard

Add zalo_personal to channel type dropdown, credential fields
(IMEI, cookie, userAgent), and config schema (DM/group policy,
require_mention, allow_from).

* feat(channels): add WebSocket QR login for Zalo Personal channel

Add real-time QR code login flow for zalo_personal channel instances
in managed mode. Users create an instance without credentials, then
trigger QR login from the web dashboard.

Backend:
- New RPC method zalo.personal.qr.start with per-instance mutex
- QR PNG pushed via client-scoped WS events (not broadcast)
- Credentials encrypted and saved to DB on successful scan
- Cache invalidation triggers automatic channel reload/start
- Factory returns nil,nil for missing credentials (skip, not error)
- Instance loader handles nil-channel gracefully

Frontend:
- ZaloPersonalQRDialog with auto-start, retry, and auto-close
- QR button in channel instances table for zalo_personal type
- Credential fields no longer required (auto-populated via QR)

* fix(channels): skip redundant LoginWithCredentials after QR login

QR flow already validates session via qrCheckSession + qrGetUserInfo.
Calling LoginWithCredentials again conflicts with the active QR session
state, causing "empty response" errors. Credentials are validated when
the channel starts instead. Also rename log prefix from "zca" to
"Zalo Personal".

* fix(channels): fix Zalo Personal cookie domain for login API

BuildCookieJar only set cookies for chat.zalo.me but the login API
uses wpa.chat.zalo.me. Cookies weren't sent to the subdomain, causing
"empty response" on channel startup. Now sets cookies for both hosts.

* fix(channels): move UTF-8 check after gzip decompression in Zalo listener

The UTF-8 validity check in decryptAESGCMPayload ran on raw decrypted
bytes before gzip decompression, causing all encType=2 (AES-GCM+gzip)
messages to fail with "decrypted payload is not valid UTF-8".

Move the check to decryptEventData so it runs after all processing
(decryption + decompression) is complete.

* feat(channels): add QR-only onboarding and contacts picker for Zalo Personal

- Remove credential text fields for zalo_personal, show QR auth info banner
- Add has_credentials boolean to HTTP and WS mask functions
- Implement FetchFriends/FetchGroups protocol (encrypted Zalo API)
- Add zalo.personal.contacts WS RPC method with parallel fetch
- Create ZaloContactsPicker component with search, selection, manual entry
- Integrate picker in channel instance edit dialog for allow_from config

* refactor(channels): rename zca error prefix to zalo_personal across protocol package

* fix(channels): unwrap inner response envelope in Zalo contacts decryption

The Zalo API returns double-wrapped responses: outer envelope contains
encrypted base64 data, which when decrypted yields another Response
envelope with error_code and data fields. The decryptDataField helper
was returning the raw decrypted bytes without unwrapping the inner
envelope, causing json unmarshal failures when parsing friends/groups.

* fix(channels): pass version 0 for group details to get full data

The Zalo group info endpoint uses a version-based caching mechanism.
Passing the actual version from step 1 causes the server to return
the group in "unchangedsGroup" with empty "gridInfoMap". By passing
version 0 for all groups, we force the server to return full group
info including name, avatar, and member count.

* fix(ui): auto-load contacts on modal reopen to resolve display names

When the edit modal is reopened with already-selected contact IDs,
contacts are now auto-fetched so badges show display names instead
of raw numeric IDs.

* fix(channels): handle gzip-compressed response in Zalo SendMessage

SendMessage used io.ReadAll + json.Unmarshal directly but the response
is gzip-compressed (Accept-Encoding: gzip header). Use readJSON() which
handles gzip decompression, fixing "invalid character '\x1f'" errors.

* fix(channels): decrypt encrypted send response in Zalo SendMessage

The Zalo send message API response is encrypted like all other endpoints.
Parse outer envelope, decrypt the data field, then extract msgId from
the decrypted inner response.

* feat(channels): improve Zalo listener reliability and UI channel wizard

- Migrate WebSocket client from gorilla to coder/websocket, eliminating
  unsafe/reflect hacks for RSV1 decompression and buffer inspection
- Add channel-level restart with exponential backoff (2s→60s cap, max 10)
  so channels auto-recover instead of stopping permanently
- Reset listener retry counters after 60s stable connection to prevent
  long-lived connections from exhausting retry budget
- Add code 3000 (duplicate session) recovery with 60s initial delay
- Detect silent disconnects via read deadline (2.5x ping interval)
- Fix Stop() to always cancel context, preventing reconnect timer leaks
- Refactor UI channel form into wizard-based flow with registry pattern
- Auto-refresh channel status after create/update dialog closes

* refactor(channels): move Zalo RPC methods to zalomethods package

Move Zalo personal channel RPC handlers from internal/gateway/methods to
internal/channels/zalo/personal/zalomethods, improving code organization
and removing prefix redundancy. Rename types: ZaloPersonalQRMethods →
QRMethods, ZaloPersonalContactsMethods → ContactsMethods.

- Move zalo_personal_qr.go → zalomethods/qr.go
- Move zalo_personal_contacts.go → zalomethods/contacts.go
- Update imports in cmd/gateway.go (2 call sites)
- Update internal/channels/zalo/personal imports

* feat(channels): add typing indicator to Zalo Personal channel

Show "typing..." in Zalo while the LLM processes messages, matching
the Telegram/Discord pattern. Uses the shared typing.Controller with
4s keepalive (Zalo typing expires ~5s) and 60s TTL safety net.

* feat(channels): handle image attachments in Zalo Personal channel

- Add Raw field to Content struct to preserve non-string JSON payloads
- Add Attachment struct with IsImage() detection (ext + Zalo CDN paths)
- Add AttachmentText() for human-readable placeholders (image/file/other)
- Download image attachments to temp files for agent vision pipeline
- Non-image files get text placeholder only (no download)
- Fix URL query param stripping in file extension detection

* fix(channels): switch Zalo WS client to gorilla/websocket with cookie jar fix

coder/websocket did not propagate session cookies for wss:// URLs,
causing Zalo backend to reject connections with "zpw_sek not found".
Switch to gorilla/websocket which handles wss→https scheme conversion
natively. Add wsJar safety wrapper and fix Close() mutex consistency.

Also update Makefile `up` target to use --no-cache builds.

* fix(channels): inject cookies manually for Zalo WS connection

Replace wsJar wrapper with direct cookie injection from chat.zalo.me
base domain. Fixes host-only cookies (zpw_sek) not matching WS
subdomains (ws*-msg.chat.zalo.me) due to Go cookiejar limitations.

* fix(channels): harden Zalo Personal channel security and concurrency

- Add SSRF protection to downloadFile using CheckSSRF (URL validation,
  private IP blocking, DNS pinning) with context and 30s timeout
- Protect c.sess/c.listener with sync.RWMutex to eliminate data races
  during restart; add thread-safe session()/getListener() accessors
- Add stopped flag + reconnTimer to Listener to prevent zombie reconnects
  after Stop(); timer cancelled on Stop(), checked before Start()
- Fix QR flow using context.Background() detached from WS client; now
  derives from parent ctx so flow cancels on client disconnect
- Set initial 30s read deadline for cipher key handshake to prevent
  indefinite blocking before ping loop starts
- Use defer in WSClient.Close() to prevent connection leak on panic
- Document ReadMessage ctx limitation and two-layer reconnect design

* chore: remove unused gobwas/ws dependency from go.mod

gobwas/ws was a leftover from the previous coder/websocket usage,
no longer imported by any Go source files.

* fix(channels): align Zalo Personal policy defaults across UI and backend

Policy defaults were inconsistent across three layers causing group/DM
allowlist enforcement to silently fail. New() applied "allowlist" default
to local vars but never wrote back to config; checkGroupPolicy() then
read empty string and defaulted to "open", bypassing the allowlist.
UI Select components displayed schema defaults visually without
persisting them to configValues, so DB config never stored the policy.
This commit is contained in:
Duc Nguyen authored and GitHub committed 2026-03-03 14:21:07 +07:00
1 parent de284733f0
commit 0f5dd08f76
45 files changed
+5762 -167

No files matched your search

+2 -1
View File
@@ -19,7 +19,8 @@ version:
COMPOSE = docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.selfservice.yml
up:
$(COMPOSE) up -d --build
$(COMPOSE) build --no-cache
$(COMPOSE) up -d
down:
$(COMPOSE) down
+15
View File
@@ -18,6 +18,8 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/channels/telegram"
"github.com/nextlevelbuilder/goclaw/internal/channels/whatsapp"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo"
zalopersonal "github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/zalomethods"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/cron"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
@@ -704,6 +706,7 @@ func runGateway() {
instanceLoader.RegisterFactory("discord", discord.Factory)
instanceLoader.RegisterFactory("feishu", feishu.Factory)
instanceLoader.RegisterFactory("zalo_oa", zalo.Factory)
instanceLoader.RegisterFactory("zalo_personal", zalopersonal.Factory)
instanceLoader.RegisterFactory("whatsapp", whatsapp.Factory)
if err := instanceLoader.LoadAll(context.Background()); err != nil {
slog.Error("failed to load channel instances from DB", "error", err)
@@ -752,6 +755,16 @@ func runGateway() {
}
}
if cfg.Channels.ZaloPersonal.Enabled && instanceLoader == nil {
zp, err := zalopersonal.New(cfg.Channels.ZaloPersonal, msgBus, pairingStore)
if err != nil {
slog.Error("failed to initialize zca channel", "error", err)
} else {
channelMgr.RegisterChannel("zalo_personal", zp)
slog.Info("zca (zalo personal) channel enabled (config)")
}
}
if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppID != "" && instanceLoader == nil {
f, err := feishu.New(cfg.Channels.Feishu, msgBus, pairingStore)
if err != nil {
@@ -778,6 +791,8 @@ func runGateway() {
// Register channel instances WS RPC methods (managed mode only)
if managedStores != nil && managedStores.ChannelInstances != nil {
methods.NewChannelInstancesMethods(managedStores.ChannelInstances, msgBus).Register(server.Router())
zalomethods.NewQRMethods(managedStores.ChannelInstances, msgBus).Register(server.Router())
zalomethods.NewContactsMethods(managedStores.ChannelInstances).Register(server.Router())
}
// Register agent links WS RPC methods (managed mode only)
+1 -1
View File
@@ -209,7 +209,7 @@ func consumeInboundMessages(ctx context.Context, msgBus *bus.MessageBus, agents
if mid := msg.Metadata["message_id"]; mid != "" {
outMeta["reply_to_message_id"] = mid
}
for _, k := range []string{"message_thread_id", "local_key", "placeholder_key"} {
for _, k := range []string{"message_thread_id", "local_key", "placeholder_key", "group_id"} {
if v := msg.Metadata[k]; v != "" {
outMeta[k] = v
}
+3 -3
View File
@@ -6,6 +6,7 @@ require (
github.com/adhocore/gronx v1.19.6
github.com/bwmarrin/discordgo v0.29.0
github.com/charmbracelet/huh v0.8.0
github.com/disintegration/imaging v1.6.2
github.com/fsnotify/fsnotify v1.9.0
github.com/go-rod/rod v0.116.2
github.com/golang-migrate/migrate/v4 v4.19.1
@@ -59,7 +60,6 @@ require (
github.com/coder/websocket v1.8.12 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gaissmai/bart v0.18.0 // indirect
@@ -126,7 +126,7 @@ require (
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/lib/pq v1.10.9
github.com/mark3labs/mcp-go v0.44.0
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
@@ -150,7 +150,7 @@ require (
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sync v0.19.0
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
+4
View File
@@ -166,6 +166,10 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns
if err != nil {
return err
}
if ch == nil {
slog.Info("channel instance not ready (missing credentials)", "name", inst.Name, "type", inst.ChannelType)
return nil
}
// Resolve agent_key from UUID — the routing system (Router, session keys) uses agent_key, not UUID.
if base, ok := ch.(interface{ SetAgentID(string) }); ok {
+103
View File
@@ -0,0 +1,103 @@
package personal
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// authenticate resolves credentials and returns an authenticated session.
// Priority: preloaded (DB) > saved file > QR login.
func (c *Channel) authenticate(ctx context.Context) (*protocol.Session, error) {
sess := protocol.NewSession()
// 1. Preloaded credentials (managed mode: from factory).
if c.preloadedCreds != nil {
slog.Info("zalo_personal: attempting login with preloaded credentials")
if err := protocol.LoginWithCredentials(ctx, sess, *c.preloadedCreds); err != nil {
return nil, fmt.Errorf("preloaded credentials failed: %w", err)
}
return sess, nil
}
// 2. Saved file credentials (standalone).
credPath := c.resolveCredentialsPath()
if cred := loadCredentials(credPath); cred != nil {
slog.Info("zalo_personal: attempting login with saved credentials", "path", credPath)
if err := protocol.LoginWithCredentials(ctx, sess, *cred); err != nil {
slog.Warn("zalo_personal: saved credentials failed, falling back to QR", "error", err)
} else {
return sess, nil
}
}
// 3. QR login (interactive).
slog.Info("zalo_personal: starting QR login. Scan the QR code with your Zalo app.")
cred, err := protocol.LoginQR(ctx, sess, func(qrPNG []byte) {
slog.Info("zalo_personal: QR code generated. Scan with Zalo app.", "size", len(qrPNG))
})
if err != nil {
return nil, fmt.Errorf("QR login failed: %w", err)
}
// Save credentials for future re-login.
if err := saveCredentials(credPath, cred); err != nil {
slog.Warn("zalo_personal: failed to save credentials", "error", err, "path", credPath)
} else {
slog.Info("zalo_personal: credentials saved", "path", credPath)
}
return sess, nil
}
// SetPreloadedCredentials sets credentials from DB (managed mode factory).
func (c *Channel) SetPreloadedCredentials(cred *protocol.Credentials) {
c.preloadedCreds = cred
}
func (c *Channel) resolveCredentialsPath() string {
if c.config.CredentialsPath != "" {
return config.ExpandHome(c.config.CredentialsPath)
}
dataDir := os.Getenv("GOCLAW_DATA_DIR")
if dataDir == "" {
dataDir = config.ExpandHome("~/.goclaw/data")
}
return filepath.Join(dataDir, "zalo-personal-credentials.json")
}
func loadCredentials(path string) *protocol.Credentials {
data, err := os.ReadFile(path)
if err != nil {
return nil
}
var cred protocol.Credentials
if err := json.Unmarshal(data, &cred); err != nil {
slog.Warn("zalo_personal: invalid credentials file", "path", path, "error", err)
return nil
}
if !cred.IsValid() {
return nil
}
return &cred
}
func saveCredentials(path string, cred *protocol.Credentials) error {
if cred == nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
data, err := json.MarshalIndent(cred, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
+462
View File
@@ -0,0 +1,462 @@
package personal
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/typing"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
const (
maxTextLength = 2000
maxChannelRestarts = 10
maxChannelBackoff = 60 * time.Second
code3000InitialDelay = 60 * time.Second
)
// Channel connects to Zalo Personal Chat via the internal protocol port (from zcago, MIT).
// WARNING: Zalo Personal is an unofficial, reverse-engineered integration. Account may be locked/banned.
type Channel struct {
*channels.BaseChannel
config config.ZaloPersonalConfig
pairingService store.PairingStore
pairingDebounce sync.Map // senderID -> time.Time
typingCtrls sync.Map // threadID → *typing.Controller
mu sync.RWMutex // protects sess and listener
sess *protocol.Session
listener *protocol.Listener
// Pre-loaded credentials (managed mode: from DB, standalone: from file or QR).
preloadedCreds *protocol.Credentials
requireMention bool
stopCh chan struct{}
stopOnce sync.Once
}
// New creates a new Zalo Personal channel from config.
func New(cfg config.ZaloPersonalConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) {
base := channels.NewBaseChannel("zalo_personal", msgBus, cfg.AllowFrom)
if cfg.DMPolicy == "" {
cfg.DMPolicy = "allowlist"
}
if cfg.GroupPolicy == "" {
cfg.GroupPolicy = "allowlist"
}
base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy)
requireMention := true
if cfg.RequireMention != nil {
requireMention = *cfg.RequireMention
}
return &Channel{
BaseChannel: base,
config: cfg,
pairingService: pairingSvc,
requireMention: requireMention,
stopCh: make(chan struct{}),
}, nil
}
// session returns the current session snapshot (thread-safe).
func (c *Channel) session() *protocol.Session {
c.mu.RLock()
defer c.mu.RUnlock()
return c.sess
}
// getListener returns the current listener snapshot (thread-safe).
func (c *Channel) getListener() *protocol.Listener {
c.mu.RLock()
defer c.mu.RUnlock()
return c.listener
}
// Start authenticates and begins listening for Zalo messages.
func (c *Channel) Start(ctx context.Context) error {
slog.Warn("security.unofficial_api",
"channel", "zalo_personal",
"msg", "Zalo Personal is unofficial and reverse-engineered. Account may be locked/banned. Use at own risk.",
)
sess, err := c.authenticate(ctx)
if err != nil {
return fmt.Errorf("zalo_personal auth: %w", err)
}
ln, err := protocol.NewListener(sess)
if err != nil {
return fmt.Errorf("zalo_personal listener: %w", err)
}
if err := ln.Start(ctx); err != nil {
return fmt.Errorf("zalo_personal listener start: %w", err)
}
c.mu.Lock()
c.sess = sess
c.listener = ln
c.mu.Unlock()
slog.Info("zalo_personal connected", "uid", sess.UID)
c.SetRunning(true)
go c.listenLoop(ctx)
slog.Info("zalo_personal listener loop started")
return nil
}
// Stop gracefully shuts down the Zalo Personal channel.
func (c *Channel) Stop(_ context.Context) error {
slog.Info("stopping zalo_personal channel")
c.stopOnce.Do(func() { close(c.stopCh) })
c.typingCtrls.Range(func(key, val any) bool {
val.(*typing.Controller).Stop()
c.typingCtrls.Delete(key)
return true
})
if ln := c.getListener(); ln != nil {
ln.Stop()
}
c.SetRunning(false)
return nil
}
// Send delivers an outbound message to a Zalo chat.
func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error {
sess := c.session()
if !c.IsRunning() || sess == nil {
return fmt.Errorf("zalo_personal channel not running")
}
// Stop typing indicator before sending response
if ctrl, ok := c.typingCtrls.LoadAndDelete(msg.ChatID); ok {
ctrl.(*typing.Controller).Stop()
}
threadType := protocol.ThreadTypeUser
if msg.Metadata != nil {
if _, ok := msg.Metadata["group_id"]; ok {
threadType = protocol.ThreadTypeGroup
}
}
return c.sendChunkedText(ctx, sess, msg.ChatID, threadType, msg.Content)
}
func (c *Channel) listenLoop(ctx context.Context) {
defer c.SetRunning(false)
for {
if !c.runListenerLoop(ctx) {
return
}
}
}
// runListenerLoop reads from the current listener until it closes.
// Returns true if the channel restarted and the outer loop should continue,
// false if the channel should stop permanently.
func (c *Channel) runListenerLoop(ctx context.Context) bool {
ln := c.getListener()
if ln == nil {
return false
}
for {
select {
case <-ctx.Done():
slog.Info("zalo_personal listener loop stopped (context)")
return false
case <-c.stopCh:
slog.Info("zalo_personal listener loop stopped")
return false
case msg, ok := <-ln.Messages():
if !ok {
return false
}
c.handleMessage(msg)
case ci := <-ln.Disconnected():
slog.Warn("zalo_personal disconnected", "code", ci.Code, "reason", ci.Reason)
case ci := <-ln.Closed():
slog.Warn("zalo_personal connection closed", "code", ci.Code, "reason", ci.Reason)
// Code 3000: wait 60s before retry (duplicate session may be transient)
if ci.Code == protocol.CloseCodeDuplicate {
slog.Warn("zalo_personal duplicate session (code 3000), waiting before retry", "channel", c.Name())
select {
case <-ctx.Done():
return false
case <-c.stopCh:
return false
case <-time.After(code3000InitialDelay):
}
}
return c.restartWithBackoff(ctx)
case err := <-ln.Errors():
slog.Warn("zalo_personal listener error", "error", err)
}
}
}
// restartWithBackoff attempts to restart the channel with exponential backoff.
// This is the channel-level retry layer for auth/session failures. The listener
// has its own internal retry for transient WS disconnects (endpoint rotation).
// When the listener exhausts its retries, it emits to closedCh which triggers this.
// Returns true if restart succeeded and the listen loop should continue.
func (c *Channel) restartWithBackoff(ctx context.Context) bool {
for attempt := range maxChannelRestarts {
delay := min(time.Duration(1<<uint(attempt+1))*time.Second, maxChannelBackoff)
slog.Info("zalo_personal restarting channel", "attempt", attempt+1, "delay", delay, "channel", c.Name())
select {
case <-ctx.Done():
return false
case <-c.stopCh:
return false
case <-time.After(delay):
}
if err := c.restart(ctx); err != nil {
slog.Warn("zalo_personal restart failed", "attempt", attempt+1, "error", err)
continue
}
return true
}
slog.Error("zalo_personal channel gave up after max restart attempts", "channel", c.Name())
return false
}
// restart performs a full re-authentication and listener restart.
// Called from the listenLoop goroutine when the listener exhausts retries.
func (c *Channel) restart(ctx context.Context) error {
if ln := c.getListener(); ln != nil {
ln.Stop()
}
sess, err := c.authenticate(ctx)
if err != nil {
return fmt.Errorf("re-auth: %w", err)
}
ln, err := protocol.NewListener(sess)
if err != nil {
return fmt.Errorf("new listener: %w", err)
}
if err := ln.Start(ctx); err != nil {
return fmt.Errorf("start listener: %w", err)
}
c.mu.Lock()
c.sess = sess
c.listener = ln
c.mu.Unlock()
return nil
}
func (c *Channel) handleMessage(msg protocol.Message) {
if msg.IsSelf() {
return
}
switch m := msg.(type) {
case protocol.UserMessage:
c.handleDM(m)
case protocol.GroupMessage:
c.handleGroupMessage(m)
}
}
func (c *Channel) handleDM(msg protocol.UserMessage) {
senderID := msg.Data.UIDFrom
threadID := msg.ThreadID()
content, media := extractContentAndMedia(msg.Data.Content)
if content == "" {
return
}
if !c.checkDMPolicy(senderID, threadID) {
return
}
slog.Debug("zalo_personal DM received",
"sender", senderID,
"thread", threadID,
"preview", channels.Truncate(content, 50),
)
c.startTyping(threadID, protocol.ThreadTypeUser)
metadata := map[string]string{
"message_id": msg.Data.MsgID,
"platform": "zalo_personal",
}
c.HandleMessage(senderID, threadID, content, media, metadata, "direct")
}
func (c *Channel) handleGroupMessage(msg protocol.GroupMessage) {
senderID := msg.Data.UIDFrom
threadID := msg.ThreadID()
content, media := extractContentAndMedia(msg.Data.Content)
if content == "" {
return
}
if !c.checkGroupPolicy(senderID, threadID, msg.Data.Mentions) {
return
}
slog.Debug("zalo_personal group message received",
"sender", senderID,
"group", threadID,
"preview", channels.Truncate(content, 50),
)
c.startTyping(threadID, protocol.ThreadTypeGroup)
metadata := map[string]string{
"message_id": msg.Data.MsgID,
"platform": "zalo_personal",
"group_id": threadID,
}
c.HandleMessage(senderID, threadID, content, media, metadata, "group")
}
// startTyping starts a typing indicator with keepalive for the given thread.
// Zalo typing expires after ~5s, so keepalive fires every 3s.
func (c *Channel) startTyping(threadID string, threadType protocol.ThreadType) {
sess := c.session()
ctrl := typing.New(typing.Options{
MaxDuration: 60 * time.Second,
KeepaliveInterval: 4 * time.Second,
StartFn: func() error {
return protocol.SendTypingEvent(context.Background(), sess, threadID, threadType)
},
})
if prev, ok := c.typingCtrls.Load(threadID); ok {
prev.(*typing.Controller).Stop()
}
c.typingCtrls.Store(threadID, ctrl)
ctrl.Start()
}
// extractContentAndMedia returns text content and optional local media paths from a message.
// For text messages, media is nil. For image attachments, the image is downloaded to a temp file.
func extractContentAndMedia(content protocol.Content) (string, []string) {
if text := content.Text(); text != "" {
return text, nil
}
att := content.ParseAttachment()
if att == nil {
return "", nil
}
text := content.AttachmentText()
if text == "" {
return "", nil
}
var media []string
if att.IsImage() {
if path, err := downloadFile(context.Background(), att.Href); err != nil {
slog.Warn("zalo_personal: failed to download image", "url", att.Href, "error", err)
} else {
media = []string{path}
}
}
return text, media
}
const maxImageBytes = 10 * 1024 * 1024 // 10MB
// downloadFile downloads an image URL to a temp file and returns the local path.
// Validates against SSRF and enforces HTTPS-only, timeout, and size limits.
func downloadFile(ctx context.Context, imageURL string) (string, error) {
if err := tools.CheckSSRF(imageURL); err != nil {
return "", fmt.Errorf("ssrf check: %w", err)
}
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return "", fmt.Errorf("download: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download status %d", resp.StatusCode)
}
// Strip query params before extracting extension
path := imageURL
if i := strings.IndexByte(path, '?'); i >= 0 {
path = path[:i]
}
ext := filepath.Ext(path)
if ext == "" || len(ext) > 5 {
ext = ".jpg"
}
tmpFile, err := os.CreateTemp("", "goclaw_zca_*"+ext)
if err != nil {
return "", fmt.Errorf("create temp: %w", err)
}
defer tmpFile.Close()
written, err := io.Copy(tmpFile, io.LimitReader(resp.Body, maxImageBytes+1))
if err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("save: %w", err)
}
if written > maxImageBytes {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("image too large: %d bytes", written)
}
return tmpFile.Name(), nil
}
func (c *Channel) sendChunkedText(ctx context.Context, sess *protocol.Session, chatID string, threadType protocol.ThreadType, text string) error {
for len(text) > 0 {
chunk := text
if len(chunk) > maxTextLength {
cutAt := maxTextLength
if idx := strings.LastIndex(text[:maxTextLength], "\n"); idx > maxTextLength/2 {
cutAt = idx + 1
}
chunk = text[:cutAt]
text = text[cutAt:]
} else {
text = ""
}
if _, err := protocol.SendMessage(ctx, sess, chatID, threadType, chunk); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,78 @@
package personal
import (
"encoding/json"
"fmt"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// zaloCreds maps the credentials JSON from the channel_instances table.
type zaloCreds struct {
IMEI string `json:"imei"`
Cookie *protocol.CookieUnion `json:"cookie"`
UserAgent string `json:"userAgent"`
Language *string `json:"language,omitempty"`
}
// zaloInstanceConfig maps the config JSONB from the channel_instances table.
type zaloInstanceConfig struct {
DMPolicy string `json:"dm_policy,omitempty"`
GroupPolicy string `json:"group_policy,omitempty"`
RequireMention *bool `json:"require_mention,omitempty"`
AllowFrom []string `json:"allow_from,omitempty"`
}
// Factory creates a Zalo Personal channel from DB instance data (managed mode).
// Does NOT trigger QR login — credentials must be provided.
func Factory(name string, creds json.RawMessage, cfg json.RawMessage,
msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) {
var c zaloCreds
if len(creds) > 0 {
if err := json.Unmarshal(creds, &c); err != nil {
return nil, fmt.Errorf("decode zalo_personal credentials: %w", err)
}
}
// No credentials yet — return nil,nil to signal "not ready" to instanceLoader.
// The channel will be created via Reload() after QR login saves creds.
if c.IMEI == "" || c.Cookie == nil {
return nil, nil
}
var ic zaloInstanceConfig
if len(cfg) > 0 {
if err := json.Unmarshal(cfg, &ic); err != nil {
return nil, fmt.Errorf("decode zalo_personal config: %w", err)
}
}
zaloCfg := config.ZaloPersonalConfig{
Enabled: true,
AllowFrom: ic.AllowFrom,
DMPolicy: ic.DMPolicy,
GroupPolicy: ic.GroupPolicy,
RequireMention: ic.RequireMention,
}
ch, err := New(zaloCfg, msgBus, pairingSvc)
if err != nil {
return nil, err
}
protoCred := &protocol.Credentials{
IMEI: c.IMEI,
Cookie: c.Cookie,
UserAgent: c.UserAgent,
Language: c.Language,
}
ch.SetPreloadedCredentials(protoCred)
ch.SetName(name)
return ch, nil
}
+137
View File
@@ -0,0 +1,137 @@
package personal
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
)
const pairingDebounce = 60 * time.Second
// checkDMPolicy enforces DM policy for incoming messages.
func (c *Channel) checkDMPolicy(senderID, chatID string) bool {
dmPolicy := c.config.DMPolicy
if dmPolicy == "" {
dmPolicy = "allowlist"
}
switch dmPolicy {
case "disabled":
slog.Debug("zalo_personal DM rejected: DMs disabled", "sender_id", senderID)
return false
case "open":
return true
case "allowlist":
if !c.IsAllowed(senderID) {
slog.Debug("zalo_personal DM rejected by allowlist", "sender_id", senderID)
return false
}
return true
default: // "pairing"
paired := false
if c.pairingService != nil {
paired = c.pairingService.IsPaired(senderID, c.Name())
}
inAllowList := c.HasAllowList() && c.IsAllowed(senderID)
if paired || inAllowList {
return true
}
c.sendPairingReply(senderID, chatID)
return false
}
}
func (c *Channel) sendPairingReply(senderID, chatID string) {
if c.pairingService == nil || c.sess == nil {
return
}
// Debounce: one reply per sender per 60s.
if lastSent, ok := c.pairingDebounce.Load(senderID); ok {
if time.Since(lastSent.(time.Time)) < pairingDebounce {
return
}
}
code, err := c.pairingService.RequestPairing(senderID, c.Name(), chatID, "default")
if err != nil {
slog.Debug("zalo_personal pairing request failed", "sender_id", senderID, "error", err)
return
}
replyText := fmt.Sprintf(
"GoClaw: access not configured.\n\nYour Zalo user id: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s",
senderID, code, code,
)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := protocol.SendMessage(ctx, c.sess, chatID, protocol.ThreadTypeUser, replyText); err != nil {
slog.Warn("zalo_personal: failed to send pairing reply", "error", err)
} else {
c.pairingDebounce.Store(senderID, time.Now())
slog.Info("zalo_personal pairing reply sent", "sender_id", senderID, "code", code)
}
}
// checkGroupPolicy enforces group policy and @mention gating.
func (c *Channel) checkGroupPolicy(senderID, groupID string, mentions []*protocol.TMention) bool {
groupPolicy := c.config.GroupPolicy
if groupPolicy == "" {
groupPolicy = "allowlist"
}
switch groupPolicy {
case "disabled":
slog.Debug("zalo_personal group message rejected: groups disabled", "group_id", groupID)
return false
case "allowlist":
if !c.IsAllowed(groupID) {
slog.Debug("zalo_personal group message rejected by allowlist", "group_id", groupID)
return false
}
}
// @mention gating: only process group messages that @mention the bot.
if c.requireMention {
if !isBotMentioned(c.sess.UID, mentions) {
slog.Debug("zalo_personal group message skipped: not mentioned",
"group_id", groupID,
"sender_id", senderID,
)
return false
}
}
return true
}
// isBotMentioned checks if the bot's UID is @mentioned in the message.
// Filters out @all mentions (Type=1, UID="-1") — only targeted @bot counts.
func isBotMentioned(botUID string, mentions []*protocol.TMention) bool {
if botUID == "" {
return false
}
for _, m := range mentions {
if m == nil {
continue
}
if m.Type == protocol.MentionAll || m.UID == protocol.MentionAllUID {
continue
}
if m.UID == botUID {
return true
}
}
return false
}
@@ -0,0 +1,481 @@
package protocol
import (
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"golang.org/x/sync/errgroup"
)
// LoginWithCredentials performs cookie-based re-login using saved credentials.
// Concurrent: fetches login info (encrypted) + server info in parallel.
func LoginWithCredentials(ctx context.Context, sess *Session, cred Credentials) error {
if !cred.IsValid() {
return fmt.Errorf("zalo_personal: invalid credentials")
}
lang := DefaultLanguage
if cred.Language != nil && *cred.Language != "" {
lang = *cred.Language
}
sess.IMEI = cred.IMEI
sess.UserAgent = cred.UserAgent
sess.Language = lang
if cred.Cookie != nil && cred.Cookie.IsValid() {
cred.Cookie.BuildCookieJar(&DefaultBaseURL, sess.CookieJar)
}
var (
loginInfo *LoginInfo
serverInfo *ServerInfo
)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
li, err := fetchLoginInfo(gctx, sess)
if err != nil {
return fmt.Errorf("zalo_personal: login: %w", err)
}
loginInfo = li
return nil
})
g.Go(func() error {
si, err := fetchServerInfo(gctx, sess)
if err != nil {
return fmt.Errorf("zalo_personal: server info: %w", err)
}
serverInfo = si
return nil
})
if err := g.Wait(); err != nil {
return err
}
if loginInfo == nil || serverInfo == nil {
return fmt.Errorf("zalo_personal: login failed (empty response)")
}
sess.UID = loginInfo.UID
sess.SecretKey = loginInfo.ZPWEnk
sess.LoginInfo = loginInfo
sess.Settings = serverInfo.Settings
// Seed cookies for every service-map host so that API calls to
// subdomains like tt-group-poll-wpa.chat.zalo.me carry zpw_sek.
// Go's net/http/cookiejar does not propagate cookies across subdomains.
if cred.Cookie != nil && cred.Cookie.IsValid() {
seedServiceMapCookies(sess, cred)
}
return nil
}
// seedServiceMapCookies seeds the session cookie jar for every distinct host
// found in the service map. This is necessary because Go's net/http/cookiejar
// does not propagate cookies across subdomains (e.g. chat.zalo.me cookies are
// not sent to tt-group-poll-wpa.chat.zalo.me).
func seedServiceMapCookies(sess *Session, cred Credentials) {
if sess.LoginInfo == nil || cred.Cookie == nil {
return
}
sm := sess.LoginInfo.ZpwServiceMapV3
allURLs := make([]string, 0, 16)
allURLs = append(allURLs, sm.Chat...)
allURLs = append(allURLs, sm.Group...)
allURLs = append(allURLs, sm.File...)
allURLs = append(allURLs, sm.Profile...)
allURLs = append(allURLs, sm.GroupPoll...)
seen := make(map[string]struct{}, len(allURLs))
for _, raw := range allURLs {
if raw == "" {
continue
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
continue
}
host := parsed.Scheme + "://" + parsed.Host
if _, ok := seen[host]; ok {
continue
}
seen[host] = struct{}{}
u := &url.URL{Scheme: parsed.Scheme, Host: parsed.Host}
cred.Cookie.BuildCookieJar(u, sess.CookieJar)
}
}
// LoginQR performs interactive QR code login.
// qrCallback is called with QR image bytes (PNG) for display.
// Returns credentials for persistence on success.
func LoginQR(ctx context.Context, sess *Session, qrCallback func(qrPNG []byte)) (*Credentials, error) {
ver, err := loadLoginPage(ctx, sess)
if err != nil {
return nil, fmt.Errorf("zalo_personal: load login page: %w", err)
}
qrGetLoginInfo(ctx, sess, ver)
qrVerifyClient(ctx, sess, ver)
qrData, imgBytes, err := qrGenerateCode(ctx, sess, ver)
if err != nil {
return nil, fmt.Errorf("zalo_personal: generate qr: %w", err)
}
if qrCallback != nil {
qrCallback(imgBytes)
}
// Wait for scan (long-poll, retry on error_code=8)
timeoutCtx, cancel := context.WithTimeout(ctx, 100*time.Second)
defer cancel()
if err := qrWaitingScan(timeoutCtx, sess, ver, qrData.Code); err != nil {
return nil, fmt.Errorf("zalo_personal: waiting scan: %w", err)
}
// Wait for confirm (long-poll)
if err := qrWaitingConfirm(timeoutCtx, sess, ver, qrData.Code); err != nil {
return nil, fmt.Errorf("zalo_personal: waiting confirm: %w", err)
}
// Validate session
if err := qrCheckSession(ctx, sess); err != nil {
return nil, fmt.Errorf("zalo_personal: check session: %w", err)
}
userInfo, err := qrGetUserInfo(ctx, sess)
if err != nil || !userInfo.Logged {
return nil, fmt.Errorf("zalo_personal: get user info failed or not logged in")
}
// Build credentials from session cookies for persistence
imei := GenerateIMEI(sess.UserAgent)
lang := sess.Language
cookies := NewHTTPCookies(sess.CookieJar.Cookies(&DefaultBaseURL))
cred := &Credentials{
IMEI: imei,
UserAgent: sess.UserAgent,
Cookie: &cookies,
Language: &lang,
}
// Credentials are validated when the channel starts via LoginWithCredentials.
// Calling it here would conflict with the active QR session state.
return cred, nil
}
// --- Cookie login helpers ---
func fetchLoginInfo(ctx context.Context, sess *Session) (*LoginInfo, error) {
params, enk, err := getEncryptParam(sess, "getlogininfo")
if err != nil {
return nil, err
}
params["nretry"] = 0
u := makeURL(sess, "https://wpa.chat.zalo.me/api/login/getLoginInfo", params, true)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
setDefaultHeaders(req, sess)
resp, err := sess.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var base Response[*string]
if err := readJSON(resp, &base); err != nil {
return nil, fmt.Errorf("parse login response: %w", err)
}
if enk == nil || base.Data == nil {
return nil, fmt.Errorf("no encrypted data in response")
}
// Decrypt response
unescaped, err := url.PathUnescape(*base.Data)
if err != nil {
return nil, err
}
plain, err := DecodeAESCBC([]byte(*enk), unescaped)
if err != nil {
return nil, fmt.Errorf("decrypt login data: %w", err)
}
var obj Response[*LoginInfo]
if err := json.Unmarshal(plain, &obj); err != nil {
return nil, err
}
return obj.Data, nil
}
func fetchServerInfo(ctx context.Context, sess *Session) (*ServerInfo, error) {
params, _, err := getEncryptParam(sess, "getserverinfo")
if err != nil {
return nil, err
}
// For getserverinfo, only pass signkey + basic params (no encrypted body)
signkey, _ := params["signkey"].(string)
siParams := map[string]any{
"signkey": signkey,
"imei": sess.IMEI,
"type": DefaultAPIType,
"client_version": DefaultAPIVersion,
"computer_name": DefaultComputerName,
}
u := makeURL(sess, "https://wpa.chat.zalo.me/api/login/getServerInfo", siParams, false)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
setDefaultHeaders(req, sess)
resp, err := sess.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// ServerInfo uses a different response shape
var result struct {
Data *ServerInfo `json:"data"`
}
if err := readJSON(resp, &result); err != nil {
return nil, fmt.Errorf("parse server info: %w", err)
}
return result.Data, nil
}
// --- QR login helpers ---
func loadLoginPage(ctx context.Context, sess *Session) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://id.zalo.me/account?continue=https%3A%2F%2Fchat.zalo.me%2F", nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", sess.UserAgent)
resp, err := sess.Client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
re := regexp.MustCompile(`https:\/\/stc-zlogin\.zdn\.vn\/main-([\d.]+)\.js`)
match := re.FindSubmatch(body)
if len(match) < 2 {
return "", fmt.Errorf("zalo_personal: version not found in login page HTML")
}
return string(match[1]), nil
}
var qrHeaders = http.Header{
"Accept": {"*/*"},
"Content-Type": {"application/x-www-form-urlencoded"},
"Sec-Fetch-Dest": {"empty"},
"Sec-Fetch-Mode": {"cors"},
"Sec-Fetch-Site": {"same-origin"},
"Referer": {"https://id.zalo.me/account?continue=https%3A%2F%2Fzalo.me%2Fpc"},
"Referrer-Policy": {"strict-origin-when-cross-origin"},
}
func qrPost(ctx context.Context, sess *Session, endpoint string, formData map[string]string) (*http.Response, error) {
body := buildFormBody(formData)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, body)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", sess.UserAgent)
for k, v := range qrHeaders {
req.Header[k] = v
}
return sess.Client.Do(req)
}
func qrGetLoginInfo(ctx context.Context, sess *Session, ver string) {
resp, err := qrPost(ctx, sess, "https://id.zalo.me/account/logininfo", map[string]string{
"v": ver, "continue": "https://zalo.me/pc",
})
if err == nil {
resp.Body.Close()
}
}
func qrVerifyClient(ctx context.Context, sess *Session, ver string) {
resp, err := qrPost(ctx, sess, "https://id.zalo.me/account/verify-client", map[string]string{
"v": ver, "type": "device", "continue": "https://zalo.me/pc",
})
if err == nil {
resp.Body.Close()
}
}
func qrGenerateCode(ctx context.Context, sess *Session, ver string) (*QRGeneratedData, []byte, error) {
resp, err := qrPost(ctx, sess, "https://id.zalo.me/account/authen/qr/generate", map[string]string{
"v": ver, "continue": "https://zalo.me/pc",
})
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
var body Response[QRGeneratedData]
if err := readJSON(resp, &body); err != nil {
return nil, nil, err
}
b64 := strings.TrimPrefix(body.Data.Image, "data:image/png;base64,")
imgBytes, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, nil, err
}
return &body.Data, imgBytes, nil
}
func qrWaitingScan(ctx context.Context, sess *Session, ver, code string) error {
for {
if ctx.Err() != nil {
return ctx.Err()
}
resp, err := qrPost(ctx, sess, "https://id.zalo.me/account/authen/qr/waiting-scan", map[string]string{
"v": ver, "code": code, "continue": "https://zalo.me/pc",
})
if err != nil {
return err
}
var body Response[QRScannedData]
if err := readJSON(resp, &body); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if body.ErrorCode == 8 {
continue // not ready yet, retry
}
if body.ErrorCode != 0 {
return fmt.Errorf("zalo_personal: scan error code %d: %s", body.ErrorCode, body.ErrorMessage)
}
return nil
}
}
func qrWaitingConfirm(ctx context.Context, sess *Session, ver, code string) error {
for {
if ctx.Err() != nil {
return ctx.Err()
}
resp, err := qrPost(ctx, sess, "https://id.zalo.me/account/authen/qr/waiting-confirm", map[string]string{
"v": ver, "code": code, "gToken": "", "gAction": "CONFIRM_QR", "continue": "https://zalo.me/pc",
})
if err != nil {
return err
}
var body Response[struct{}]
if err := readJSON(resp, &body); err != nil {
resp.Body.Close()
return err
}
resp.Body.Close()
if body.ErrorCode == 8 {
continue // not ready yet, retry
}
if body.ErrorCode == -13 {
return fmt.Errorf("zalo_personal: QR login declined by user")
}
if body.ErrorCode != 0 {
return fmt.Errorf("zalo_personal: confirm error code %d: %s", body.ErrorCode, body.ErrorMessage)
}
return nil
}
}
func qrCheckSession(ctx context.Context, sess *Session) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://id.zalo.me/account/checksession?continue=https%3A%2F%2Fchat.zalo.me%2Findex.html", nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", sess.UserAgent)
resp, err := sess.Client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func qrGetUserInfo(ctx context.Context, sess *Session) (*QRUserInfo, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://jr.chat.zalo.me/jr/userinfo", nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", sess.UserAgent)
req.Header.Set("Referer", DefaultBaseURL.String()+"/")
resp, err := sess.Client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var body Response[QRUserInfo]
if err := readJSON(resp, &body); err != nil {
return nil, err
}
return &body.Data, nil
}
// --- Shared helpers ---
func setDefaultHeaders(req *http.Request, sess *Session) {
h := defaultHeaders(sess)
for k, v := range h {
req.Header[k] = v
}
}
// readJSON decodes a potentially gzip/deflate-encoded JSON response.
func readJSON(resp *http.Response, target any) error {
var reader io.ReadCloser
var err error
switch strings.ToLower(resp.Header.Get("Content-Encoding")) {
case "gzip":
reader, err = gzip.NewReader(resp.Body)
if err != nil {
return err
}
defer reader.Close()
default:
reader = resp.Body
}
return json.NewDecoder(reader).Decode(target)
}
@@ -0,0 +1,289 @@
package protocol
import (
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/cookiejar"
"net/url"
"reflect"
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
// Session holds authenticated Zalo session state.
type Session struct {
UID string
IMEI string
UserAgent string
Language string
SecretKey string // base64-encoded zpw_enk
LoginInfo *LoginInfo
Settings *Settings
CookieJar http.CookieJar
Client *http.Client
}
// NewSession creates a fresh unauthenticated session.
func NewSession() *Session {
jar, _ := cookiejar.New(nil)
return &Session{
UserAgent: DefaultUserAgent,
Language: DefaultLanguage,
CookieJar: jar,
Client: &http.Client{
Jar: jar,
Timeout: 60 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= MaxRedirects {
return fmt.Errorf("zalo_personal: too many redirects")
}
return nil
},
},
}
}
// GenerateIMEI creates a Zalo-style IMEI from user agent.
// Format: uuid-md5(userAgent) (matching zcago generateZaloUUID).
func GenerateIMEI(userAgent string) string {
u := uuid.New().String()
hash := md5.Sum([]byte(userAgent))
return u + "-" + hex.EncodeToString(hash[:])
}
// --- HTTP helpers (ported from zcago/internal/httpx) ---
// makeURL builds a Zalo API URL with query params and optional defaults.
func makeURL(sess *Session, baseURL string, params map[string]any, includeDefaults bool) string {
u, err := url.Parse(baseURL)
if err != nil {
return ""
}
query := u.Query()
for key, value := range params {
if !query.Has(key) {
query.Set(key, convertToString(value))
}
}
if includeDefaults {
if !query.Has("zpw_ver") {
query.Set("zpw_ver", convertToString(DefaultAPIVersion))
}
if !query.Has("zpw_type") {
query.Set("zpw_type", convertToString(DefaultAPIType))
}
}
u.RawQuery = query.Encode()
return u.String()
}
// buildFormBody creates url-encoded form body from params.
func buildFormBody(data map[string]string) *strings.Reader {
form := url.Values{}
for k, v := range data {
form.Set(k, v)
}
return strings.NewReader(form.Encode())
}
// generateSignKey computes md5 hash of sorted params for Zalo API signing.
func generateSignKey(typeStr string, params map[string]any) string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
signStr := "zsecure" + typeStr
for _, k := range keys {
if v := params[k]; v != nil {
signStr += convertToString(v)
}
}
hash := md5.Sum([]byte(signStr))
return hex.EncodeToString(hash[:])
}
// --- Encrypt param generation (ported from zcago/internal/httpx/encryption.go) ---
// getEncryptParam generates encrypted request parameters for Zalo API.
func getEncryptParam(sess *Session, typeStr string) (params map[string]any, enk *string, err error) {
data := map[string]any{
"computer_name": DefaultComputerName,
"imei": sess.IMEI,
"language": sess.Language,
"ts": time.Now().UnixMilli(),
}
zcid, zcidExt, encKey, err := encryptParams(sess.IMEI, data)
if err != nil {
return nil, nil, err
}
params = map[string]any{
"zcid": zcid,
"enc_ver": DefaultEncryptVer,
"zcid_ext": zcidExt,
"params": encKey.encData,
"type": DefaultAPIType,
"client_version": DefaultAPIVersion,
}
if typeStr == "getserverinfo" {
params["signkey"] = generateSignKey(typeStr, map[string]any{
"imei": sess.IMEI,
"type": DefaultAPIType,
"client_version": DefaultAPIVersion,
"computer_name": DefaultComputerName,
})
} else {
params["signkey"] = generateSignKey(typeStr, params)
}
return params, &encKey.key, nil
}
type encryptResult struct {
key string // encrypt key for response decryption
encData string // encrypted request data (base64)
}
// encryptParams performs the ZCID + key derivation + AES encryption.
func encryptParams(imei string, data map[string]any) (zcid, zcidExt string, result *encryptResult, err error) {
ts := time.Now().UnixMilli()
zcidExt = randomHexString(6, 12)
// Create ZCID
zcidData := fmt.Sprintf("%d,%s,%d", DefaultAPIType, imei, ts)
zcidRaw, err := EncodeAESCBC([]byte(DefaultZCIDKey), zcidData, true)
if err != nil {
return "", "", nil, fmt.Errorf("zalo_personal: create zcid: %w", err)
}
zcid = strings.ToUpper(zcidRaw)
// Derive encrypt key
encKey, err := deriveEncryptKey(zcidExt, zcid)
if err != nil {
return "", "", nil, fmt.Errorf("zalo_personal: derive key: %w", err)
}
// Encrypt data
blob, err := json.Marshal(data)
if err != nil {
return "", "", nil, err
}
encData, err := EncodeAESCBC([]byte(encKey), string(blob), false)
if err != nil {
return "", "", nil, fmt.Errorf("zalo_personal: encrypt data: %w", err)
}
return zcid, zcidExt, &encryptResult{key: encKey, encData: encData}, nil
}
// deriveEncryptKey derives the AES key from zcid_ext and ZCID.
func deriveEncryptKey(ext, id string) (string, error) {
sum := md5.Sum([]byte(ext))
nUpper := strings.ToUpper(hex.EncodeToString(sum[:]))
evenE, _ := processStr(nUpper)
evenI, oddI := processStr(id)
if len(evenE) == 0 || len(evenI) == 0 || len(oddI) == 0 {
return "", fmt.Errorf("zalo_personal: invalid key derivation params")
}
var b strings.Builder
b.WriteString(joinFirst(evenE, 8))
b.WriteString(joinFirst(evenI, 12))
b.WriteString(joinFirst(reverseCopy(oddI), 12))
return b.String(), nil
}
// processStr splits string into even-index and odd-index character slices.
func processStr(s string) (even, odd []string) {
for i, r := range s {
if i%2 == 0 {
even = append(even, string(r))
} else {
odd = append(odd, string(r))
}
}
return
}
// joinFirst joins the first n elements of a string slice.
func joinFirst(parts []string, n int) string {
if n > len(parts) {
n = len(parts)
}
return strings.Join(parts[:n], "")
}
// reverseCopy returns a reversed copy of the slice.
func reverseCopy(in []string) []string {
out := make([]string, len(in))
copy(out, in)
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
// randomHexString generates a random hex string of length between min and max.
func randomHexString(minLen, maxLen int) string {
length := minLen
if maxLen > minLen {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(maxLen-minLen+1)))
length = minLen + int(n.Int64())
}
byteLen := (length + 1) / 2
buf := make([]byte, byteLen)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)[:length]
}
// convertToString converts any value to its string representation.
func convertToString(v any) string {
switch val := v.(type) {
case string:
return val
case []byte:
return string(val)
case int:
return strconv.Itoa(val)
case int8, int16, int32, int64:
return strconv.FormatInt(reflect.ValueOf(val).Int(), 10)
case uint, uint8, uint16, uint32, uint64:
return strconv.FormatUint(reflect.ValueOf(val).Uint(), 10)
case float32:
return strconv.FormatFloat(float64(val), 'f', -1, 32)
case float64:
return strconv.FormatFloat(val, 'f', -1, 64)
case bool:
return strconv.FormatBool(val)
default:
return fmt.Sprint(val)
}
}
// defaultHeaders returns standard Zalo API request headers.
func defaultHeaders(sess *Session) http.Header {
h := make(http.Header, 8)
h.Set("Accept", "application/json, text/plain, */*")
h.Set("Accept-Encoding", "gzip")
h.Set("Accept-Language", "en-US,en;q=0.9")
h.Set("Content-Type", "application/x-www-form-urlencoded")
h.Set("Origin", DefaultBaseURL.String())
h.Set("Referer", DefaultBaseURL.String()+"/")
h.Set("User-Agent", sess.UserAgent)
return h
}
@@ -0,0 +1,224 @@
package protocol
import (
"strings"
"testing"
)
func TestMakeURL(t *testing.T) {
sess := &Session{Language: "vi", IMEI: "test-imei", UserAgent: DefaultUserAgent}
t.Run("with defaults", func(t *testing.T) {
u := makeURL(sess, "https://api.zalo.me/path", map[string]any{"foo": "bar"}, true)
if !strings.Contains(u, "foo=bar") {
t.Error("missing param foo=bar")
}
if !strings.Contains(u, "zpw_ver=") {
t.Error("missing zpw_ver default")
}
if !strings.Contains(u, "zpw_type=") {
t.Error("missing zpw_type default")
}
})
t.Run("without defaults", func(t *testing.T) {
u := makeURL(sess, "https://api.zalo.me/path", map[string]any{"key": "val"}, false)
if !strings.Contains(u, "key=val") {
t.Error("missing param")
}
if strings.Contains(u, "zpw_ver") {
t.Error("should not have zpw_ver without defaults")
}
})
t.Run("does not overwrite existing params", func(t *testing.T) {
u := makeURL(sess, "https://api.zalo.me/path?foo=existing", map[string]any{"foo": "new"}, false)
if strings.Contains(u, "foo=new") {
t.Error("should not overwrite existing param")
}
if !strings.Contains(u, "foo=existing") {
t.Error("should keep existing param")
}
})
t.Run("invalid URL returns empty", func(t *testing.T) {
u := makeURL(sess, "://invalid", nil, false)
if u != "" {
t.Errorf("expected empty for invalid URL, got %q", u)
}
})
}
func TestConvertToString(t *testing.T) {
tests := []struct {
name string
val any
want string
}{
{"string", "hello", "hello"},
{"int", 42, "42"},
{"int64", int64(100), "100"},
{"uint", uint(7), "7"},
{"float64", 3.14, "3.14"},
{"bool", true, "true"},
{"bytes", []byte("abc"), "abc"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertToString(tt.val)
if got != tt.want {
t.Errorf("convertToString(%v) = %q, want %q", tt.val, got, tt.want)
}
})
}
}
func TestGenerateSignKey(t *testing.T) {
// Verify determinism: same inputs = same output
params := map[string]any{
"imei": "test-imei",
"type": 30,
"client_version": 665,
}
key1 := generateSignKey("getserverinfo", params)
key2 := generateSignKey("getserverinfo", params)
if key1 != key2 {
t.Error("generateSignKey should be deterministic")
}
if len(key1) != 32 {
t.Errorf("expected 32-char md5 hex, got len %d", len(key1))
}
// Different type string = different key
key3 := generateSignKey("getlogininfo", params)
if key1 == key3 {
t.Error("different type strings should produce different keys")
}
}
func TestProcessStr(t *testing.T) {
even, odd := processStr("ABCDEF")
if strings.Join(even, "") != "ACE" {
t.Errorf("even = %v, want [A C E]", even)
}
if strings.Join(odd, "") != "BDF" {
t.Errorf("odd = %v, want [B D F]", odd)
}
}
func TestProcessStr_Empty(t *testing.T) {
even, odd := processStr("")
if len(even) != 0 || len(odd) != 0 {
t.Error("expected empty slices for empty string")
}
}
func TestJoinFirst(t *testing.T) {
parts := []string{"A", "B", "C", "D", "E"}
if got := joinFirst(parts, 3); got != "ABC" {
t.Errorf("joinFirst(5, 3) = %q, want 'ABC'", got)
}
if got := joinFirst(parts, 10); got != "ABCDE" {
t.Errorf("joinFirst(5, 10) = %q, want 'ABCDE'", got)
}
if got := joinFirst(parts, 0); got != "" {
t.Errorf("joinFirst(5, 0) = %q, want ''", got)
}
}
func TestReverseCopy(t *testing.T) {
in := []string{"A", "B", "C"}
out := reverseCopy(in)
if strings.Join(out, "") != "CBA" {
t.Errorf("reverseCopy = %v, want [C B A]", out)
}
// Verify original is not mutated
if strings.Join(in, "") != "ABC" {
t.Error("original was mutated")
}
}
func TestRandomHexString(t *testing.T) {
for i := 0; i < 10; i++ {
s := randomHexString(6, 12)
if len(s) < 6 || len(s) > 12 {
t.Errorf("randomHexString(6,12) = len %d, want 6-12", len(s))
}
}
// Fixed length
s := randomHexString(8, 8)
if len(s) != 8 {
t.Errorf("randomHexString(8,8) = len %d, want 8", len(s))
}
}
func TestGenerateIMEI(t *testing.T) {
imei := GenerateIMEI("test-agent")
parts := strings.Split(imei, "-")
// UUID has 5 parts, then a dash, then md5 hash (32 chars)
// Format: uuid-md5hash = 5 uuid parts + 1 md5 part = at least 6 parts
if len(parts) < 6 {
t.Errorf("IMEI has %d parts, expected at least 6", len(parts))
}
// Last part should be 32-char hex (md5)
lastPart := parts[len(parts)-1]
if len(lastPart) != 32 {
t.Errorf("md5 part len = %d, want 32", len(lastPart))
}
// Deterministic md5 for same user agent
imei2 := GenerateIMEI("test-agent")
md5_1 := imei[strings.LastIndex(imei, "-")+1:]
md5_2 := imei2[strings.LastIndex(imei2, "-")+1:]
if md5_1 != md5_2 {
t.Error("md5 hash should be deterministic for same user agent")
}
// Different UUID each time
if imei == imei2 {
t.Error("full IMEI should differ (random UUID)")
}
}
func TestNewSession(t *testing.T) {
sess := NewSession()
if sess.UserAgent != DefaultUserAgent {
t.Errorf("UserAgent = %q", sess.UserAgent)
}
if sess.Language != DefaultLanguage {
t.Errorf("Language = %q", sess.Language)
}
if sess.CookieJar == nil {
t.Error("CookieJar is nil")
}
if sess.Client == nil {
t.Error("Client is nil")
}
}
func TestBuildFormBody(t *testing.T) {
body := buildFormBody(map[string]string{"key": "value", "foo": "bar"})
content := body.Len()
if content == 0 {
t.Error("form body is empty")
}
s := make([]byte, content)
body.Read(s)
str := string(s)
if !strings.Contains(str, "key=value") {
t.Error("missing key=value")
}
if !strings.Contains(str, "foo=bar") {
t.Error("missing foo=bar")
}
}
@@ -0,0 +1,253 @@
package protocol
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/cookiejar"
"net/url"
"time"
)
const (
DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0"
DefaultLanguage = "vi"
DefaultAPIType = 30
DefaultAPIVersion = 665
DefaultComputerName = "Web"
DefaultUIDSelf = "0"
DefaultEncryptVer = "v2"
DefaultZCIDKey = "3FC4F0D2AB50057BCE0D90D9187A22B1"
MaxRedirects = 10
)
// DefaultBaseURL is the Zalo chat base URL.
var DefaultBaseURL = url.URL{Scheme: "https", Host: "chat.zalo.me"}
// ThreadType distinguishes DM vs group.
type ThreadType uint8
const (
ThreadTypeUser ThreadType = 0
ThreadTypeGroup ThreadType = 1
)
// Credentials holds Zalo login data (IMEI + cookies + user agent).
// Serializable for credential persistence.
type Credentials struct {
IMEI string `json:"imei"`
Cookie *CookieUnion `json:"cookie"`
UserAgent string `json:"userAgent"`
Language *string `json:"language,omitempty"`
}
// IsValid checks if credentials have minimum required fields.
func (c Credentials) IsValid() bool {
return len(c.IMEI) > 0 && (c.Cookie == nil || c.Cookie.IsValid()) && len(c.UserAgent) > 0
}
// --- Cookie types (ported from zcago, MIT) ---
// SameSite represents cookie SameSite attribute.
type SameSite string
const (
SameSiteDefault SameSite = ""
SameSiteLax SameSite = "lax"
SameSiteStrict SameSite = "strict"
SameSiteNone SameSite = "none"
)
func (s SameSite) MarshalJSON() ([]byte, error) {
if s == "" {
return []byte("null"), nil
}
return json.Marshal(string(s))
}
func (s *SameSite) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, []byte("null")) {
*s = ""
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
return err
}
*s = SameSite(str)
return nil
}
// Cookie represents a Zalo authentication cookie.
type Cookie struct {
Domain string `json:"domain"`
ExpirationDate float64 `json:"expirationDate"`
HostOnly bool `json:"hostOnly"`
HTTPOnly bool `json:"httpOnly"`
Name string `json:"name"`
Path string `json:"path"`
SameSite SameSite `json:"sameSite"`
Secure bool `json:"secure"`
Session bool `json:"session"`
StoreID *string `json:"storeId,omitempty"`
Value string `json:"value"`
}
// ToHTTPCookie converts to standard http.Cookie.
func (c Cookie) ToHTTPCookie() *http.Cookie {
hc := &http.Cookie{
Domain: c.Domain,
Name: c.Name,
Value: c.Value,
Path: c.Path,
HttpOnly: c.HTTPOnly,
Secure: c.Secure,
}
switch c.SameSite {
case SameSiteStrict:
hc.SameSite = http.SameSiteStrictMode
case SameSiteLax:
hc.SameSite = http.SameSiteLaxMode
case SameSiteNone:
hc.SameSite = http.SameSiteNoneMode
default:
hc.SameSite = http.SameSiteDefaultMode
}
if !c.Session && c.ExpirationDate > 0 {
sec := int64(c.ExpirationDate)
nsec := int64((c.ExpirationDate - float64(sec)) * 1e9)
hc.Expires = time.Unix(sec, nsec)
}
return hc
}
// FromHTTPCookie populates Cookie from a standard http.Cookie.
func (c *Cookie) FromHTTPCookie(hc *http.Cookie) {
c.Domain = hc.Domain
c.Name = hc.Name
c.Value = hc.Value
c.Path = hc.Path
c.HTTPOnly = hc.HttpOnly
c.Secure = hc.Secure
c.HostOnly = false
c.StoreID = nil
switch hc.SameSite {
case http.SameSiteStrictMode:
c.SameSite = SameSiteStrict
case http.SameSiteLaxMode:
c.SameSite = SameSiteLax
case http.SameSiteNoneMode:
c.SameSite = SameSiteNone
default:
c.SameSite = SameSiteDefault
}
switch {
case hc.MaxAge > 0:
exp := time.Now().Add(time.Duration(hc.MaxAge) * time.Second)
c.Session = false
c.ExpirationDate = float64(exp.UnixNano()) / 1e9
case hc.MaxAge == 0 && !hc.Expires.IsZero():
c.Session = false
c.ExpirationDate = float64(hc.Expires.UnixNano()) / 1e9
default:
c.Session = true
c.ExpirationDate = 0
}
}
// J2Cookie represents cookies in the J2Cookie format.
type J2Cookie struct {
URL string `json:"url"`
Cookies []Cookie `json:"cookies"`
}
// CookieUnion represents cookies in multiple formats (array or J2Cookie).
type CookieUnion struct {
cookies []Cookie
j2cookie *J2Cookie
}
// NewHTTPCookies creates a CookieUnion from standard http.Cookies.
func NewHTTPCookies(hc []*http.Cookie) CookieUnion {
if hc == nil {
return CookieUnion{}
}
cookies := make([]Cookie, len(hc))
for i, c := range hc {
cookies[i].FromHTTPCookie(c)
}
return CookieUnion{cookies: cookies}
}
func (cu *CookieUnion) IsValid() bool { return cu.cookies != nil || cu.j2cookie != nil }
func (cu *CookieUnion) GetCookies() []Cookie {
if cu.cookies != nil {
return cu.cookies
}
if cu.j2cookie != nil {
return cu.j2cookie.Cookies
}
return nil
}
// BuildCookieJar populates a cookie jar from the stored cookies.
// Strips leading "." from domains (Zalo protocol requirement).
// Sets cookies for both the base URL and wpa.chat.zalo.me (used by login API).
func (cu *CookieUnion) BuildCookieJar(u *url.URL, jar http.CookieJar) {
src := cu.GetCookies()
cookieArr := make([]Cookie, len(src))
copy(cookieArr, src)
for i := range cookieArr {
if len(cookieArr[i].Domain) > 0 && cookieArr[i].Domain[0] == '.' {
cookieArr[i].Domain = cookieArr[i].Domain[1:]
}
}
if jar == nil {
jar, _ = cookiejar.New(nil)
}
cookies := make([]*http.Cookie, len(cookieArr))
for i, c := range cookieArr {
cookies[i] = c.ToHTTPCookie()
}
jar.SetCookies(u, cookies)
// Also set cookies for wpa.chat.zalo.me — the login API uses this host.
wpaURL := &url.URL{Scheme: "https", Host: "wpa.chat.zalo.me"}
jar.SetCookies(wpaURL, cookies)
}
func (cu CookieUnion) MarshalJSON() ([]byte, error) {
switch {
case cu.cookies != nil && cu.j2cookie != nil:
return nil, errors.New("invariant: both cookies and j2cookie should not be set")
case cu.cookies != nil:
return json.Marshal(cu.cookies)
case cu.j2cookie != nil:
return json.Marshal(cu.j2cookie)
default:
return []byte("null"), nil
}
}
func (cu *CookieUnion) UnmarshalJSON(b []byte) error {
trim := bytes.TrimSpace(b)
if len(trim) == 0 || bytes.Equal(trim, []byte("null")) {
*cu = CookieUnion{}
return nil
}
if trim[0] == '[' {
var arr []Cookie
if err := json.Unmarshal(trim, &arr); err != nil {
return err
}
*cu = CookieUnion{cookies: arr}
return nil
}
var j J2Cookie
if err := json.Unmarshal(trim, &j); err != nil {
return err
}
*cu = CookieUnion{j2cookie: &j}
return nil
}
@@ -0,0 +1,271 @@
package protocol
import (
"encoding/json"
"net/http"
"net/http/cookiejar"
"net/url"
"testing"
"time"
)
func TestCredentials_IsValid(t *testing.T) {
tests := []struct {
name string
cred Credentials
valid bool
}{
{"valid with cookies", Credentials{IMEI: "abc", UserAgent: "ua", Cookie: &CookieUnion{cookies: []Cookie{{Name: "a"}}}}, true},
{"valid without cookies", Credentials{IMEI: "abc", UserAgent: "ua"}, true},
{"missing IMEI", Credentials{UserAgent: "ua"}, false},
{"missing UserAgent", Credentials{IMEI: "abc"}, false},
{"empty", Credentials{}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.cred.IsValid(); got != tt.valid {
t.Errorf("IsValid() = %v, want %v", got, tt.valid)
}
})
}
}
func TestCookieUnion_MarshalUnmarshal_Array(t *testing.T) {
cookies := []Cookie{
{Name: "zpw_sek", Value: "abc123", Domain: "chat.zalo.me", Path: "/"},
{Name: "zpw_enk", Value: "def456", Domain: "chat.zalo.me", Path: "/"},
}
cu := CookieUnion{cookies: cookies}
b, err := cu.MarshalJSON()
if err != nil {
t.Fatal(err)
}
var cu2 CookieUnion
if err := cu2.UnmarshalJSON(b); err != nil {
t.Fatal(err)
}
got := cu2.GetCookies()
if len(got) != 2 {
t.Fatalf("got %d cookies, want 2", len(got))
}
if got[0].Name != "zpw_sek" || got[1].Name != "zpw_enk" {
t.Errorf("cookie names mismatch: %q, %q", got[0].Name, got[1].Name)
}
}
func TestCookieUnion_MarshalUnmarshal_J2Cookie(t *testing.T) {
j2 := &J2Cookie{
URL: "https://chat.zalo.me",
Cookies: []Cookie{{Name: "test", Value: "val"}},
}
cu := CookieUnion{j2cookie: j2}
b, err := cu.MarshalJSON()
if err != nil {
t.Fatal(err)
}
var cu2 CookieUnion
if err := cu2.UnmarshalJSON(b); err != nil {
t.Fatal(err)
}
got := cu2.GetCookies()
if len(got) != 1 || got[0].Name != "test" {
t.Errorf("j2cookie roundtrip failed: %+v", got)
}
}
func TestCookieUnion_Null(t *testing.T) {
var cu CookieUnion
if err := cu.UnmarshalJSON([]byte("null")); err != nil {
t.Fatal(err)
}
if cu.IsValid() {
t.Error("null CookieUnion should not be valid")
}
b, err := cu.MarshalJSON()
if err != nil {
t.Fatal(err)
}
if string(b) != "null" {
t.Errorf("got %s, want null", b)
}
}
func TestCookieUnion_MarshalJSON_BothSet(t *testing.T) {
cu := CookieUnion{
cookies: []Cookie{{Name: "a"}},
j2cookie: &J2Cookie{URL: "test"},
}
_, err := cu.MarshalJSON()
if err == nil {
t.Fatal("expected error when both cookies and j2cookie are set")
}
}
func TestCookie_ToHTTPCookie(t *testing.T) {
c := Cookie{
Domain: "chat.zalo.me",
Name: "test",
Value: "val",
Path: "/",
HTTPOnly: true,
Secure: true,
SameSite: SameSiteNone,
ExpirationDate: float64(time.Now().Add(time.Hour).Unix()),
}
hc := c.ToHTTPCookie()
if hc.Name != "test" || hc.Value != "val" {
t.Error("name/value mismatch")
}
if !hc.HttpOnly || !hc.Secure {
t.Error("HttpOnly/Secure not set")
}
if hc.SameSite != http.SameSiteNoneMode {
t.Errorf("SameSite = %d, want %d", hc.SameSite, http.SameSiteNoneMode)
}
if hc.Expires.IsZero() {
t.Error("Expires should be set for non-session cookie")
}
}
func TestCookie_FromHTTPCookie(t *testing.T) {
hc := &http.Cookie{
Domain: "chat.zalo.me",
Name: "zpw_sek",
Value: "secret",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: 3600,
}
var c Cookie
c.FromHTTPCookie(hc)
if c.Name != "zpw_sek" || c.Value != "secret" {
t.Error("name/value mismatch")
}
if !c.HTTPOnly {
t.Error("HTTPOnly should be true (from hc.HttpOnly)")
}
if c.SameSite != SameSiteLax {
t.Errorf("SameSite = %q, want %q", c.SameSite, SameSiteLax)
}
if c.Session {
t.Error("Session should be false when MaxAge > 0")
}
if c.ExpirationDate == 0 {
t.Error("ExpirationDate should be set when MaxAge > 0")
}
}
func TestBuildCookieJar_DoesNotMutateOriginal(t *testing.T) {
cookies := []Cookie{
{Domain: ".chat.zalo.me", Name: "test", Value: "val", Path: "/"},
}
cu := CookieUnion{cookies: cookies}
u, _ := url.Parse("https://chat.zalo.me")
jar, _ := cookiejar.New(nil)
// Save original domain
origDomain := cu.GetCookies()[0].Domain
cu.BuildCookieJar(u, jar)
// Original should not be mutated
if cu.GetCookies()[0].Domain != origDomain {
t.Errorf("BuildCookieJar mutated original: got %q, want %q", cu.GetCookies()[0].Domain, origDomain)
}
}
func TestBuildCookieJar_Idempotent(t *testing.T) {
cookies := []Cookie{
{Domain: ".chat.zalo.me", Name: "test", Value: "val", Path: "/"},
}
cu := CookieUnion{cookies: cookies}
u, _ := url.Parse("https://chat.zalo.me")
jar1, _ := cookiejar.New(nil)
jar2, _ := cookiejar.New(nil)
cu.BuildCookieJar(u, jar1)
cu.BuildCookieJar(u, jar2)
c1 := jar1.Cookies(u)
c2 := jar2.Cookies(u)
if len(c1) != len(c2) {
t.Errorf("non-idempotent: first call got %d cookies, second got %d", len(c1), len(c2))
}
}
func TestSameSite_MarshalUnmarshal(t *testing.T) {
tests := []struct {
val SameSite
json string
}{
{SameSiteDefault, "null"},
{SameSiteLax, `"lax"`},
{SameSiteStrict, `"strict"`},
{SameSiteNone, `"none"`},
}
for _, tt := range tests {
b, err := tt.val.MarshalJSON()
if err != nil {
t.Fatal(err)
}
if string(b) != tt.json {
t.Errorf("MarshalJSON(%q) = %s, want %s", tt.val, b, tt.json)
}
var got SameSite
if err := got.UnmarshalJSON(b); err != nil {
t.Fatal(err)
}
if got != tt.val {
t.Errorf("UnmarshalJSON(%s) = %q, want %q", tt.json, got, tt.val)
}
}
}
func TestCredentials_JSON_Roundtrip(t *testing.T) {
lang := "vi"
cred := Credentials{
IMEI: "test-imei-123",
UserAgent: "Mozilla/5.0",
Language: &lang,
Cookie: &CookieUnion{cookies: []Cookie{
{Name: "zpw_sek", Value: "abc", Domain: "chat.zalo.me"},
}},
}
b, err := json.Marshal(cred)
if err != nil {
t.Fatal(err)
}
var got Credentials
if err := json.Unmarshal(b, &got); err != nil {
t.Fatal(err)
}
if got.IMEI != cred.IMEI || got.UserAgent != cred.UserAgent {
t.Error("IMEI/UserAgent mismatch")
}
if got.Language == nil || *got.Language != "vi" {
t.Error("Language mismatch")
}
if got.Cookie == nil || len(got.Cookie.GetCookies()) != 1 {
t.Error("Cookie mismatch")
}
}
@@ -0,0 +1,255 @@
package protocol
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"sort"
)
// FriendInfo is a minimal friend/contact record for the picker UI.
type FriendInfo struct {
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
ZaloName string `json:"zaloName,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
// GroupListInfo is a minimal group record for the picker UI.
type GroupListInfo struct {
GroupID string `json:"groupId"`
Name string `json:"name"`
Avatar string `json:"avatar,omitempty"`
TotalMember int `json:"totalMember"`
}
// FetchFriends fetches the authenticated user's friend list from Zalo.
func FetchFriends(ctx context.Context, sess *Session) ([]FriendInfo, error) {
baseURL := getServiceURL(sess, "profile")
if baseURL == "" {
return nil, fmt.Errorf("zalo_personal: no profile service URL")
}
payload := map[string]any{
"page": 1,
"count": 20000,
"incInvalid": 1,
"avatar_size": 120,
"actiontime": 0,
"imei": sess.IMEI,
}
encData, err := encryptPayload(sess, payload)
if err != nil {
return nil, fmt.Errorf("zalo_personal: encrypt friends payload: %w", err)
}
reqURL := makeURL(sess, baseURL+"/api/social/friend/getfriends",
map[string]any{"params": encData}, true)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
setDefaultHeaders(req, sess)
resp, err := sess.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("zalo_personal: fetch friends: %w", err)
}
defer resp.Body.Close()
// Response envelope: {"error_code":0, "data":"<encrypted_base64>"}
var envelope Response[*string]
if err := readJSON(resp, &envelope); err != nil {
return nil, fmt.Errorf("zalo_personal: parse friends response: %w", err)
}
if envelope.ErrorCode != 0 {
return nil, fmt.Errorf("zalo_personal: friends error code %d: %s", envelope.ErrorCode, envelope.ErrorMessage)
}
if envelope.Data == nil {
return nil, fmt.Errorf("zalo_personal: empty friends data")
}
plain, err := decryptDataField(sess, *envelope.Data)
if err != nil {
return nil, fmt.Errorf("zalo_personal: decrypt friends: %w", err)
}
var friends []FriendInfo
if err := json.Unmarshal(plain, &friends); err != nil {
return nil, fmt.Errorf("zalo_personal: parse friends list: %w", err)
}
return friends, nil
}
// FetchGroups fetches the authenticated user's group list from Zalo (two-step).
func FetchGroups(ctx context.Context, sess *Session) ([]GroupListInfo, error) {
// Step 1: Get group IDs from group_poll service
gridVerMap, err := fetchGroupIDs(ctx, sess)
if err != nil {
return nil, err
}
if len(gridVerMap) == 0 {
return nil, nil
}
// Step 2: Get group details from group service
return fetchGroupDetails(ctx, sess, gridVerMap)
}
// fetchGroupIDs gets group ID -> version map from group_poll service.
func fetchGroupIDs(ctx context.Context, sess *Session) (map[string]string, error) {
baseURL := getServiceURL(sess, "group_poll")
if baseURL == "" {
return nil, fmt.Errorf("zalo_personal: no group_poll service URL")
}
reqURL := makeURL(sess, baseURL+"/api/group/getlg/v4", nil, true)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
setDefaultHeaders(req, sess)
resp, err := sess.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("zalo_personal: fetch group IDs: %w", err)
}
defer resp.Body.Close()
var envelope Response[*string]
if err := readJSON(resp, &envelope); err != nil {
return nil, fmt.Errorf("zalo_personal: parse group IDs response: %w", err)
}
if envelope.ErrorCode != 0 {
return nil, fmt.Errorf("zalo_personal: group IDs error code %d: %s", envelope.ErrorCode, envelope.ErrorMessage)
}
if envelope.Data == nil {
return nil, nil
}
plain, err := decryptDataField(sess, *envelope.Data)
if err != nil {
return nil, fmt.Errorf("zalo_personal: decrypt group IDs: %w", err)
}
var result struct {
GridVerMap map[string]string `json:"gridVerMap"`
}
if err := json.Unmarshal(plain, &result); err != nil {
return nil, fmt.Errorf("zalo_personal: parse group IDs: %w", err)
}
return result.GridVerMap, nil
}
// fetchGroupDetails gets group info for given group IDs.
func fetchGroupDetails(ctx context.Context, sess *Session, gridVerMap map[string]string) ([]GroupListInfo, error) {
baseURL := getServiceURL(sess, "group")
if baseURL == "" {
return nil, fmt.Errorf("zalo_personal: no group service URL")
}
// Build payload with version 0 to force full data retrieval.
// Passing the actual version causes the server to return the group
// in "unchangedsGroup" with no info in "gridInfoMap".
zeroVerMap := make(map[string]int, len(gridVerMap))
for id := range gridVerMap {
zeroVerMap[id] = 0
}
gridVerJSON, err := json.Marshal(zeroVerMap)
if err != nil {
return nil, err
}
payload := map[string]any{
"gridVerMap": string(gridVerJSON),
}
encData, err := encryptPayload(sess, payload)
if err != nil {
return nil, fmt.Errorf("zalo_personal: encrypt group details payload: %w", err)
}
reqURL := makeURL(sess, baseURL+"/api/group/getmg-v2", nil, true)
form := buildFormBody(map[string]string{"params": encData})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, form)
if err != nil {
return nil, err
}
setDefaultHeaders(req, sess)
resp, err := sess.Client.Do(req)
if err != nil {
return nil, fmt.Errorf("zalo_personal: fetch group details: %w", err)
}
defer resp.Body.Close()
var envelope Response[*string]
if err := readJSON(resp, &envelope); err != nil {
return nil, fmt.Errorf("zalo_personal: parse group details response: %w", err)
}
if envelope.ErrorCode != 0 {
return nil, fmt.Errorf("zalo_personal: group details error code %d: %s", envelope.ErrorCode, envelope.ErrorMessage)
}
if envelope.Data == nil {
return nil, nil
}
plain, err := decryptDataField(sess, *envelope.Data)
if err != nil {
return nil, fmt.Errorf("zalo_personal: decrypt group details: %w", err)
}
var result struct {
GridInfoMap map[string]struct {
Name string `json:"name"`
Avatar string `json:"avt"`
TotalMember int `json:"totalMember"`
} `json:"gridInfoMap"`
}
if err := json.Unmarshal(plain, &result); err != nil {
return nil, fmt.Errorf("zalo_personal: parse group details: %w", err)
}
groups := make([]GroupListInfo, 0, len(result.GridInfoMap))
for id, info := range result.GridInfoMap {
groups = append(groups, GroupListInfo{
GroupID: id,
Name: info.Name,
Avatar: info.Avatar,
TotalMember: info.TotalMember,
})
}
sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name })
return groups, nil
}
// decryptDataField decrypts an encrypted base64 data string from Zalo API response.
// The decrypted payload is itself a Response envelope: {"error_code":0, "data":...},
// so this function unwraps the inner envelope and returns the raw data field.
func decryptDataField(sess *Session, data string) ([]byte, error) {
key := SecretKey(sess.SecretKey).Bytes()
if key == nil {
return nil, fmt.Errorf("zalo_personal: invalid session secret key")
}
unescaped, err := url.PathUnescape(data)
if err != nil {
return nil, err
}
plain, err := DecodeAESCBC(key, unescaped)
if err != nil {
return nil, err
}
// Unwrap inner response envelope
var inner Response[json.RawMessage]
if err := json.Unmarshal(plain, &inner); err != nil {
return nil, fmt.Errorf("zalo_personal: unwrap inner response: %w", err)
}
if inner.ErrorCode != 0 {
return nil, fmt.Errorf("zalo_personal: inner error code %d: %s", inner.ErrorCode, inner.ErrorMessage)
}
return inner.Data, nil
}
@@ -0,0 +1,115 @@
// Package protocol implements the Zalo personal chat protocol.
// Ported from zcago (MIT license): https://github.com/amrakk/zcago
package protocol
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
)
var (
errInvalidBlockSize = errors.New("zalo_personal: invalid block size")
errInvalidPKCS7Data = errors.New("zalo_personal: invalid PKCS#7 data")
errInvalidPKCS7Padding = errors.New("zalo_personal: invalid PKCS#7 padding")
)
// EncodeAESCBC encrypts data with AES-CBC using a zero IV (Zalo protocol quirk).
// encHex=true returns hex-encoded ciphertext, false returns base64.
func EncodeAESCBC(key []byte, data string, encHex bool) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("zalo_personal crypto: new cipher: %w", err)
}
plain, err := pkcs7Pad([]byte(data), aes.BlockSize)
if err != nil {
return "", fmt.Errorf("zalo_personal crypto: pkcs7 pad: %w", err)
}
iv := make([]byte, aes.BlockSize) // zero IV
ct := make([]byte, len(plain))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, plain)
if encHex {
return hex.EncodeToString(ct), nil
}
return base64.StdEncoding.EncodeToString(ct), nil
}
// DecodeAESCBC decrypts base64-encoded AES-CBC ciphertext with zero IV.
func DecodeAESCBC(key []byte, data string) ([]byte, error) {
ciphertext, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return nil, fmt.Errorf("zalo_personal crypto: base64 decode: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("zalo_personal crypto: new cipher: %w", err)
}
iv := make([]byte, aes.BlockSize) // zero IV
plain := make([]byte, len(ciphertext))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ciphertext)
plain, err = pkcs7Unpad(plain, aes.BlockSize)
if err != nil {
return nil, fmt.Errorf("zalo_personal crypto: pkcs7 unpad: %w", err)
}
return plain, nil
}
// DecodeAESGCM decrypts with AES-GCM using a 16-byte nonce (non-standard).
// Zalo uses cipher.NewGCMWithNonceSize(block, 16) instead of standard 12.
func DecodeAESGCM(key, iv, aad, ct []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("zalo_personal crypto: new cipher: %w", err)
}
gcm, err := cipher.NewGCMWithNonceSize(block, 16) // non-standard 16-byte nonce
if err != nil {
return nil, fmt.Errorf("zalo_personal crypto: new gcm: %w", err)
}
plain, err := gcm.Open(nil, iv, ct, aad)
if err != nil {
return nil, fmt.Errorf("zalo_personal crypto: gcm open: %w", err)
}
return plain, nil
}
// --- PKCS#7 padding ---
func pkcs7Pad(data []byte, blockSize int) ([]byte, error) {
if blockSize <= 0 {
return nil, errInvalidBlockSize
}
padLen := blockSize - (len(data) % blockSize)
if padLen == 0 {
padLen = blockSize
}
return append(data, bytes.Repeat([]byte{byte(padLen)}, padLen)...), nil
}
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if blockSize <= 0 {
return nil, errInvalidBlockSize
}
if len(data) == 0 || len(data)%blockSize != 0 {
return nil, errInvalidPKCS7Data
}
padLen := int(data[len(data)-1])
if padLen == 0 || padLen > blockSize || padLen > len(data) {
return nil, errInvalidPKCS7Padding
}
if !bytes.Equal(bytes.Repeat([]byte{byte(padLen)}, padLen), data[len(data)-padLen:]) {
return nil, errInvalidPKCS7Padding
}
return data[:len(data)-padLen], nil
}
@@ -0,0 +1,227 @@
package protocol
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"testing"
)
func TestEncodeDecodeAESCBC_Roundtrip(t *testing.T) {
key := []byte("0123456789ABCDEF") // 16 bytes = AES-128
tests := []struct {
name string
data string
}{
{"short text", "hello"},
{"exact block size", "0123456789ABCDEF"},
{"multi block", "this is a longer test string that spans multiple AES blocks"},
{"json payload", `{"imei":"abc","ts":12345}`},
{"empty string", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
encoded, err := EncodeAESCBC(key, tt.data, false)
if err != nil {
t.Fatalf("EncodeAESCBC: %v", err)
}
decoded, err := DecodeAESCBC(key, encoded)
if err != nil {
t.Fatalf("DecodeAESCBC: %v", err)
}
if string(decoded) != tt.data {
t.Errorf("roundtrip mismatch: got %q, want %q", decoded, tt.data)
}
})
}
}
func TestEncodeAESCBC_ZeroIV_Deterministic(t *testing.T) {
key := []byte("0123456789ABCDEF")
data := "deterministic test"
enc1, _ := EncodeAESCBC(key, data, false)
enc2, _ := EncodeAESCBC(key, data, false)
if enc1 != enc2 {
t.Error("zero-IV AES-CBC should be deterministic")
}
}
func TestEncodeAESCBC_HexVsBase64(t *testing.T) {
key := []byte("0123456789ABCDEF")
data := "test data"
hexEnc, _ := EncodeAESCBC(key, data, true)
b64Enc, _ := EncodeAESCBC(key, data, false)
if hexEnc == b64Enc {
t.Error("hex and base64 encodings should differ")
}
b64Dec, err := base64.StdEncoding.DecodeString(b64Enc)
if err != nil || len(b64Dec) == 0 {
t.Fatal("base64 decode failed")
}
}
func TestEncodeAESCBC_InvalidKey(t *testing.T) {
_, err := EncodeAESCBC([]byte("short"), "data", false)
if err == nil {
t.Fatal("expected error for invalid key size")
}
}
func TestDecodeAESCBC_InvalidBase64(t *testing.T) {
key := []byte("0123456789ABCDEF")
_, err := DecodeAESCBC(key, "not-valid-base64!!!")
if err == nil {
t.Fatal("expected error for invalid base64")
}
}
func TestDecodeAESCBC_WrongKey(t *testing.T) {
key1 := []byte("0123456789ABCDEF")
key2 := []byte("FEDCBA9876543210")
encoded, err := EncodeAESCBC(key1, "secret data", false)
if err != nil {
t.Fatal(err)
}
_, err = DecodeAESCBC(key2, encoded)
// Wrong key should produce padding error in most cases
if err == nil {
t.Log("note: no error with wrong key (padding happened to be valid)")
}
}
func TestDecodeAESGCM(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
iv := make([]byte, 16)
for i := range iv {
iv[i] = byte(i + 0x10)
}
aad := make([]byte, 16)
for i := range aad {
aad[i] = byte(i + 0x20)
}
// Encrypt with 16-byte nonce GCM (same as Zalo uses)
block, err := aes.NewCipher(key)
if err != nil {
t.Fatal(err)
}
gcm, err := cipher.NewGCMWithNonceSize(block, 16)
if err != nil {
t.Fatal(err)
}
plaintext := []byte("hello from AES-GCM test")
ct := gcm.Seal(nil, iv, plaintext, aad)
// Decrypt with our function
decrypted, err := DecodeAESGCM(key, iv, aad, ct)
if err != nil {
t.Fatalf("DecodeAESGCM: %v", err)
}
if string(decrypted) != string(plaintext) {
t.Errorf("decrypted = %q, want %q", decrypted, plaintext)
}
}
func TestDecodeAESGCM_InvalidKey(t *testing.T) {
_, err := DecodeAESGCM([]byte("short"), make([]byte, 16), make([]byte, 16), make([]byte, 32))
if err == nil {
t.Fatal("expected error for invalid key size")
}
}
func TestDecodeAESGCM_TamperedCiphertext(t *testing.T) {
key := make([]byte, 32)
iv := make([]byte, 16)
aad := make([]byte, 16)
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCMWithNonceSize(block, 16)
ct := gcm.Seal(nil, iv, []byte("test"), aad)
ct[0] ^= 0xFF // tamper
_, err := DecodeAESGCM(key, iv, aad, ct)
if err == nil {
t.Fatal("expected error for tampered ciphertext")
}
}
func TestPKCS7PadUnpad_Roundtrip(t *testing.T) {
tests := []struct {
name string
dataLen int
}{
{"empty", 0},
{"1 byte", 1},
{"15 bytes", 15},
{"16 bytes (full block)", 16},
{"17 bytes", 17},
{"31 bytes", 31},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data := make([]byte, tt.dataLen)
for i := range data {
data[i] = byte(i)
}
padded, err := pkcs7Pad(data, aes.BlockSize)
if err != nil {
t.Fatalf("pkcs7Pad: %v", err)
}
if len(padded)%aes.BlockSize != 0 {
t.Errorf("padded len %d not multiple of block size", len(padded))
}
unpadded, err := pkcs7Unpad(padded, aes.BlockSize)
if err != nil {
t.Fatalf("pkcs7Unpad: %v", err)
}
if len(unpadded) != tt.dataLen {
t.Errorf("unpadded len = %d, want %d", len(unpadded), tt.dataLen)
}
})
}
}
func TestPKCS7Unpad_InvalidPadding(t *testing.T) {
tests := []struct {
name string
data []byte
}{
{"empty", []byte{}},
{"wrong length", []byte{1, 2, 3}},
{"zero pad byte", make([]byte, 16)},
{"pad too large", append(make([]byte, 15), 17)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := pkcs7Unpad(tt.data, 16)
if err == nil {
t.Fatal("expected error")
}
})
}
}
func TestPKCS7Pad_InvalidBlockSize(t *testing.T) {
if _, err := pkcs7Pad([]byte("test"), 0); err == nil {
t.Fatal("expected error for blockSize 0")
}
if _, err := pkcs7Pad([]byte("test"), -1); err == nil {
t.Fatal("expected error for negative blockSize")
}
}
@@ -0,0 +1,268 @@
package protocol
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"sync"
"time"
)
const (
CloseCodeDuplicate = 3000 // another Zalo session opened — never reconnect
msgBufferSize = 64
minEncDataLen = 48
)
// Listener connects to Zalo's WebSocket and dispatches messages.
type Listener struct {
mu sync.RWMutex
sess *Session
wsURLs []string
wsURL string
rotateCount int
client *WSClient
cipherKey string
connectedAt time.Time
stopped bool // prevents reconnect after Stop()
reconnTimer *time.Timer // pending reconnect timer, cancelled on Stop()
retryStates map[string]*retryState
messageCh chan Message
disconnectedCh chan CloseInfo
closedCh chan CloseInfo
errorCh chan error
pingCancel context.CancelFunc
cancel context.CancelFunc
wg sync.WaitGroup
}
// CloseInfo carries the WebSocket close code and reason.
type CloseInfo struct {
Code int
Reason string
}
type retryState struct {
count int
max int
times []int // delay in ms per retry attempt
}
// NewListener creates a listener from an authenticated session.
func NewListener(sess *Session) (*Listener, error) {
if sess.LoginInfo == nil || len(sess.LoginInfo.ZpwWebsocket) == 0 {
return nil, fmt.Errorf("zalo_personal: no websocket URLs in session")
}
wsURL := buildWSURL(sess, sess.LoginInfo.ZpwWebsocket[0])
return &Listener{
sess: sess,
wsURLs: sess.LoginInfo.ZpwWebsocket,
wsURL: wsURL,
retryStates: buildListenerRetryStates(sess.Settings),
messageCh: make(chan Message, msgBufferSize),
disconnectedCh: make(chan CloseInfo, 4),
closedCh: make(chan CloseInfo, 1),
errorCh: make(chan error, 16),
}, nil
}
// Channel accessors.
func (ln *Listener) Messages() <-chan Message { return ln.messageCh }
func (ln *Listener) Disconnected() <-chan CloseInfo { return ln.disconnectedCh }
func (ln *Listener) Closed() <-chan CloseInfo { return ln.closedCh }
func (ln *Listener) Errors() <-chan error { return ln.errorCh }
// Start connects to WebSocket and begins reading messages.
func (ln *Listener) Start(ctx context.Context) error {
ln.mu.Lock()
defer ln.mu.Unlock()
if ln.client != nil {
return fmt.Errorf("zalo_personal: listener already started")
}
ln.stopped = false
lctx, cancel := context.WithCancel(ctx)
ln.cancel = cancel
u, _ := url.Parse(ln.wsURL)
h := http.Header{}
h.Set("Accept-Language", "en-US,en;q=0.9")
h.Set("Cache-Control", "no-cache")
h.Set("Host", u.Host)
h.Set("Origin", DefaultBaseURL.String())
h.Set("Pragma", "no-cache")
h.Set("User-Agent", ln.sess.UserAgent)
client, err := DialWS(lctx, ln.wsURL, h, ln.sess.CookieJar)
if err != nil {
cancel()
return err
}
slog.Debug("zalo websocket connected", "url", ln.wsURL)
// Set initial read deadline for the cipher key handshake message.
// Without this, ReadMessage blocks indefinitely if the server never responds.
// The deadline is cleared once the ping loop starts (cipher key received).
client.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
ln.client = client
ln.connectedAt = time.Now()
ln.wg.Add(1)
go ln.run(lctx)
return nil
}
// Stop gracefully closes the WebSocket connection and cancels any pending reconnect.
func (ln *Listener) Stop() {
ln.mu.Lock()
ln.stopped = true
client := ln.client
cancel := ln.cancel
timer := ln.reconnTimer
ln.reconnTimer = nil
ln.mu.Unlock()
if timer != nil {
timer.Stop()
}
// Always cancel context to prevent pending reconnect timers from firing.
if cancel != nil {
cancel()
}
if client != nil {
client.Close(1000, "")
}
ln.wg.Wait()
}
func (ln *Listener) run(ctx context.Context) {
defer ln.wg.Done()
for {
if ctx.Err() != nil {
return
}
var data []byte
var err error
// Apply read deadline if ping loop is active (cipher key received).
// Detects silent disconnects where Zalo stops sending without closing.
ln.mu.RLock()
hasPing := ln.pingCancel != nil
ln.mu.RUnlock()
if hasPing {
readCtx, rcancel := context.WithTimeout(ctx, ln.readDeadline())
data, err = ln.client.ReadMessage(readCtx)
rcancel()
} else {
data, err = ln.client.ReadMessage(ctx)
}
if err != nil {
ci := parseWSCloseInfo(err)
// Distinguish read timeout (silent disconnect) from real close.
// If the parent ctx is still alive but we got a deadline/cancel error,
// it was the per-read timeout that expired.
if ctx.Err() == nil && errors.Is(err, context.DeadlineExceeded) {
ci = CloseInfo{Code: 1006, Reason: "read timeout (silent disconnect)"}
slog.Warn("zalo_personal silent disconnect detected")
}
ln.handleDisconnect(ctx, ci)
return
}
ln.handleFrame(ctx, data)
}
}
const defaultReadDeadline = 3 * time.Minute
// readDeadline returns 2.5× the ping interval, or 3 minutes as fallback.
func (ln *Listener) readDeadline() time.Duration {
if ln.sess.Settings != nil {
interval := ln.sess.Settings.Features.Socket.PingInterval
if interval > 0 {
return time.Duration(interval) * time.Millisecond * 5 / 2
}
}
return defaultReadDeadline
}
func (ln *Listener) handleFrame(ctx context.Context, data []byte) {
if len(data) < 4 {
return
}
version := data[0]
cmd := binary.LittleEndian.Uint16(data[1:3])
subCmd := data[3]
body := data[4:]
var envelope struct {
Key *string `json:"key"`
Encrypt uint `json:"encrypt"`
Data string `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
emit(ctx, ln.errorCh, fmt.Errorf("zalo_personal: parse ws frame: %w", err))
return
}
key := fmt.Sprintf("%d_%d_%d", version, cmd, subCmd)
switch key {
case "1_1_1":
ln.handleCipherKey(ctx, envelope.Key)
case "1_501_0":
ln.handleUserMessages(ctx, envelope.Data, envelope.Encrypt)
case "1_521_0":
ln.handleGroupMessages(ctx, envelope.Data, envelope.Encrypt)
case "1_3000_0":
slog.Warn("zalo_personal: duplicate connection detected, closing")
ln.mu.RLock()
client := ln.client
ln.mu.RUnlock()
if client != nil {
client.Close(CloseCodeDuplicate, "duplicate")
}
}
}
func (ln *Listener) handleCipherKey(ctx context.Context, key *string) {
if key == nil || *key == "" {
return
}
ln.mu.Lock()
ln.cipherKey = *key
// Clear initial handshake deadline; ping loop manages deadlines from here.
if ln.client != nil {
ln.client.conn.SetReadDeadline(time.Time{})
}
ln.mu.Unlock()
// Start ping loop
if ln.sess.Settings != nil {
interval := ln.sess.Settings.Features.Socket.PingInterval
if interval > 0 {
pctx, pcancel := context.WithCancel(ctx)
ln.mu.Lock()
ln.pingCancel = pcancel
ln.mu.Unlock()
go ln.pingLoop(pctx, time.Duration(interval)*time.Millisecond)
}
}
}
@@ -0,0 +1,352 @@
package protocol
import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/url"
"slices"
"time"
"unicode/utf8"
)
// --- Message handlers ---
func (ln *Listener) handleUserMessages(ctx context.Context, data string, encType uint) {
ln.mu.RLock()
ck := ln.cipherKey
ln.mu.RUnlock()
payload, err := ln.decryptEventData(data, encType, ck)
if err != nil {
emit(ctx, ln.errorCh, fmt.Errorf("zalo_personal: decrypt user msg: %w", err))
return
}
var envelope struct {
Data struct {
Msgs []json.RawMessage `json:"msgs"`
} `json:"data"`
}
if err := json.Unmarshal(payload, &envelope); err != nil {
emit(ctx, ln.errorCh, fmt.Errorf("zalo_personal: parse user msgs: %w", err))
return
}
for _, raw := range envelope.Data.Msgs {
var msg TMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
um := NewUserMessage(ln.sess.UID, msg)
if um.IsSelf() {
continue // skip self-sent messages
}
emit(ctx, ln.messageCh, Message(um))
}
}
func (ln *Listener) handleGroupMessages(ctx context.Context, data string, encType uint) {
ln.mu.RLock()
ck := ln.cipherKey
ln.mu.RUnlock()
payload, err := ln.decryptEventData(data, encType, ck)
if err != nil {
emit(ctx, ln.errorCh, fmt.Errorf("zalo_personal: decrypt group msg: %w", err))
return
}
var envelope struct {
Data struct {
GroupMsgs []json.RawMessage `json:"groupMsgs"`
} `json:"data"`
}
if err := json.Unmarshal(payload, &envelope); err != nil {
emit(ctx, ln.errorCh, fmt.Errorf("zalo_personal: parse group msgs: %w", err))
return
}
for _, raw := range envelope.Data.GroupMsgs {
var msg TGroupMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
gm := NewGroupMessage(ln.sess.UID, msg)
if gm.IsSelf() {
continue
}
emit(ctx, ln.messageCh, Message(gm))
}
}
// --- Decryption ---
func (ln *Listener) decryptEventData(data string, encType uint, cipherKey string) ([]byte, error) {
var result []byte
var err error
switch encType {
case 0: // plaintext
result = []byte(data)
case 1: // base64 only
result, err = base64.StdEncoding.DecodeString(data)
case 2: // AES-GCM + gzip
raw, e := ln.decryptAESGCMPayload(data, cipherKey)
if e != nil {
return nil, e
}
result, err = decompressGzip(raw)
case 3: // AES-GCM raw (no gzip)
result, err = ln.decryptAESGCMPayload(data, cipherKey)
default:
return nil, fmt.Errorf("unknown encryption type %d", encType)
}
if err != nil {
return nil, err
}
if !utf8.Valid(result) {
return nil, fmt.Errorf("decrypted payload is not valid UTF-8")
}
return result, nil
}
func (ln *Listener) decryptAESGCMPayload(data, cipherKey string) ([]byte, error) {
if cipherKey == "" {
return nil, fmt.Errorf("cipher key required for encrypted data")
}
unescaped, err := url.PathUnescape(data)
if err != nil {
return nil, err
}
decoded, err := base64.StdEncoding.DecodeString(unescaped)
if err != nil {
return nil, err
}
if len(decoded) < minEncDataLen {
return nil, fmt.Errorf("encrypted data too short (%d bytes)", len(decoded))
}
key, err := base64.StdEncoding.DecodeString(cipherKey)
if err != nil {
return nil, fmt.Errorf("decode cipher key: %w", err)
}
iv := decoded[0:16]
aad := decoded[16:32]
ct := decoded[32:]
return DecodeAESGCM(key, iv, aad, ct)
}
func decompressGzip(data []byte) ([]byte, error) {
r, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("gzip reader: %w", err)
}
defer r.Close()
return io.ReadAll(r)
}
// --- Ping loop ---
func (ln *Listener) pingLoop(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
ln.sendPing(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
ln.sendPing(ctx)
}
}
}
func (ln *Listener) sendPing(ctx context.Context) {
data := map[string]any{"eventId": time.Now().UnixMilli()}
body, _ := json.Marshal(data)
buf := make([]byte, 4+len(body))
buf[0] = 1 // version
binary.LittleEndian.PutUint16(buf[1:3], 2) // cmd=2
buf[3] = 1 // subCmd=1
copy(buf[4:], body)
ln.mu.RLock()
client := ln.client
ln.mu.RUnlock()
if client != nil {
_ = client.WriteMessage(ctx, buf)
}
}
// --- Reconnect ---
// Two reconnect layers:
// 1. Listener-level (here): retries transient WS disconnects with endpoint rotation.
// Controlled by server-provided retry config per close code.
// 2. Channel-level (channel.go restartWithBackoff): full re-auth when listener gives up.
// Triggers when listener emits to closedCh after exhausting retries.
// The stopped flag prevents zombie reconnects after Stop() is called.
const stableThreshold = 60 * time.Second
func (ln *Listener) handleDisconnect(ctx context.Context, ci CloseInfo) {
// Reset retry counters if connection was stable (>60s uptime)
if !ln.connectedAt.IsZero() && time.Since(ln.connectedAt) > stableThreshold {
ln.resetRetryCounters()
}
ln.reset()
select {
case ln.disconnectedCh <- ci:
default:
}
// Code 3000 = duplicate — let channel-level handle it
if ci.Code == CloseCodeDuplicate {
ln.emitClosed(ci)
return
}
delay, ok := ln.canRetry(ci.Code)
if !ok {
ln.emitClosed(ci)
return
}
// Store timer so Stop() can cancel it to prevent zombie reconnects.
ln.mu.Lock()
ln.reconnTimer = time.AfterFunc(time.Duration(delay)*time.Millisecond, func() {
ln.mu.RLock()
stopped := ln.stopped
ln.mu.RUnlock()
if stopped || ctx.Err() != nil {
return
}
// Rotate endpoint if applicable
ln.tryRotateEndpoint(ci.Code)
if err := ln.Start(ctx); err != nil {
ln.emitClosed(ci)
}
})
ln.mu.Unlock()
}
func (ln *Listener) reset() {
ln.mu.Lock()
defer ln.mu.Unlock()
if ln.pingCancel != nil {
ln.pingCancel()
ln.pingCancel = nil
}
ln.client = nil
ln.cipherKey = ""
ln.connectedAt = time.Time{}
}
func (ln *Listener) resetRetryCounters() {
ln.mu.Lock()
defer ln.mu.Unlock()
for _, st := range ln.retryStates {
st.count = 0
}
ln.rotateCount = 0
slog.Debug("zalo_personal retry counters reset after stable connection")
}
func (ln *Listener) canRetry(code int) (int, bool) {
ln.mu.Lock()
defer ln.mu.Unlock()
st, ok := ln.retryStates[fmt.Sprint(code)]
if !ok || st == nil || st.max == 0 || len(st.times) == 0 {
return 0, false
}
if st.count >= st.max {
return 0, false
}
idx := st.count
st.count++
delay := st.times[len(st.times)-1]
if idx < len(st.times) {
delay = st.times[idx]
}
return delay, true
}
// tryRotateEndpoint atomically checks and rotates the WS endpoint.
func (ln *Listener) tryRotateEndpoint(code int) {
ln.mu.Lock()
defer ln.mu.Unlock()
if ln.sess.Settings == nil {
return
}
codes := ln.sess.Settings.Features.Socket.RotateErrorCodes
if slices.Contains(codes, code) && ln.rotateCount < len(ln.wsURLs)-1 {
ln.rotateCount++
ln.wsURL = buildWSURL(ln.sess, ln.wsURLs[ln.rotateCount])
}
}
func (ln *Listener) emitClosed(ci CloseInfo) {
select {
case ln.closedCh <- ci:
default:
}
}
// --- Helpers ---
func buildWSURL(sess *Session, base string) string {
return makeURL(sess, base, map[string]any{"t": time.Now().UnixMilli()}, true)
}
func buildListenerRetryStates(settings *Settings) map[string]*retryState {
states := make(map[string]*retryState, 8)
if settings == nil {
return states
}
for reason, cfg := range settings.Features.Socket.Retries {
maxRetries := 0
if cfg.Max != nil {
maxRetries = *cfg.Max
}
if len(cfg.Times) == 0 {
continue
}
states[reason] = &retryState{count: 0, max: maxRetries, times: cfg.Times}
}
return states
}
// emit sends to a buffered channel; drops oldest if full.
func emit[T any](ctx context.Context, ch chan T, val T) {
select {
case <-ctx.Done():
return
case ch <- val:
default:
select {
case <-ch:
default:
}
select {
case ch <- val:
default:
}
}
}
@@ -0,0 +1,193 @@
package protocol
import (
"encoding/json"
"fmt"
"path/filepath"
"slices"
"strings"
)
// Message is the interface for incoming Zalo messages (DM or group).
type Message interface {
Type() ThreadType
ThreadID() string
IsSelf() bool
}
// TMessage is the raw JSON message payload from Zalo WebSocket.
type TMessage struct {
MsgID string `json:"msgId"`
UIDFrom string `json:"uidFrom"`
IDTo string `json:"idTo"`
DName string `json:"dName"`
TS string `json:"ts"`
Content Content `json:"content"`
MsgType string `json:"msgType"`
CMD int `json:"cmd"`
ST int `json:"st"`
AT int `json:"at"`
}
// TGroupMessage extends TMessage with group-specific fields.
type TGroupMessage struct {
TMessage
Mentions []*TMention `json:"mentions,omitempty"`
}
// TMention represents an @mention in a group message.
type TMention struct {
UID string `json:"uid"` // user ID or "-1" for @all
Pos int `json:"pos"`
Len int `json:"len"`
Type MentionType `json:"type"` // 0=individual, 1=all
}
// MentionType distinguishes individual vs @all mentions.
type MentionType int
const (
MentionEach MentionType = 0
MentionAll MentionType = 1
MentionAllUID = "-1"
)
// Content is a union type: can be a plain string or an attachment object.
// String is set for text messages; Raw is set for non-text (images, stickers, files).
type Content struct {
String *string
Raw json.RawMessage // non-nil when content is a JSON object (attachment)
}
func (c *Content) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err == nil {
c.String = &s
return nil
}
c.Raw = slices.Clone(data) // preserve raw attachment payload
return nil
}
func (c Content) MarshalJSON() ([]byte, error) {
if c.String != nil {
return json.Marshal(c.String)
}
return []byte("null"), nil
}
// Text returns the plain text content, or empty string for non-text.
func (c Content) Text() string {
if c.String != nil {
return *c.String
}
return ""
}
// Attachment holds parsed fields from a non-text content object.
type Attachment struct {
Title string `json:"title"`
Href string `json:"href"`
}
// ParseAttachment extracts attachment metadata from non-text content.
// Returns nil if content is plain text or unrecognized.
func (c Content) ParseAttachment() *Attachment {
if c.Raw == nil {
return nil
}
var att Attachment
if json.Unmarshal(c.Raw, &att) != nil {
return &Attachment{} // unrecognized but non-text
}
return &att
}
// imageExts lists file extensions recognized as images by the agent's vision pipeline.
var imageExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
}
// IsImage reports whether the attachment href points to an image file.
// Checks both file extension and Zalo CDN path patterns (e.g. /jpg/, /png/).
func (a *Attachment) IsImage() bool {
if a == nil || a.Href == "" {
return false
}
path := strings.SplitN(a.Href, "?", 2)[0]
if imageExts[strings.ToLower(filepath.Ext(path))] {
return true
}
// Zalo CDN paths like https://f20-zpc.zdn.vn/jpg/...
lower := strings.ToLower(path)
return strings.Contains(lower, "/jpg/") || strings.Contains(lower, "/png/") ||
strings.Contains(lower, "/gif/") || strings.Contains(lower, "/webp/")
}
// AttachmentText returns a human-readable placeholder for non-text content.
func (c Content) AttachmentText() string {
att := c.ParseAttachment()
if att == nil {
return ""
}
if att.IsImage() {
if att.Title != "" {
return fmt.Sprintf("[User sent an image: %s]", att.Title)
}
return "[User sent an image]"
}
if att.Href != "" {
if att.Title != "" {
return fmt.Sprintf("[User sent a file: %s]", att.Title)
}
return "[User sent a file]"
}
return "[User sent a non-text message]"
}
// UserMessage represents a DM (type=0).
type UserMessage struct {
Data TMessage
threadID string
isSelf bool
}
// NewUserMessage creates a UserMessage, resolving self-sent messages.
func NewUserMessage(selfUID string, data TMessage) UserMessage {
msg := UserMessage{Data: data, threadID: data.UIDFrom}
msg.isSelf = data.UIDFrom == DefaultUIDSelf
if data.UIDFrom == DefaultUIDSelf {
msg.threadID = data.IDTo
msg.Data.UIDFrom = selfUID
}
if data.IDTo == DefaultUIDSelf {
msg.Data.IDTo = selfUID
}
return msg
}
func (m UserMessage) Type() ThreadType { return ThreadTypeUser }
func (m UserMessage) ThreadID() string { return m.threadID }
func (m UserMessage) IsSelf() bool { return m.isSelf }
// GroupMessage represents a group message (type=1).
type GroupMessage struct {
Data TGroupMessage
threadID string
isSelf bool
}
// NewGroupMessage creates a GroupMessage, resolving self-sent messages.
func NewGroupMessage(selfUID string, data TGroupMessage) GroupMessage {
g := GroupMessage{Data: data, threadID: data.IDTo}
g.isSelf = data.UIDFrom == DefaultUIDSelf
if data.UIDFrom == DefaultUIDSelf {
g.Data.UIDFrom = selfUID
}
return g
}
func (m GroupMessage) Type() ThreadType { return ThreadTypeGroup }
func (m GroupMessage) ThreadID() string { return m.threadID }
func (m GroupMessage) IsSelf() bool { return m.isSelf }
@@ -0,0 +1,324 @@
package protocol
import (
"encoding/json"
"testing"
)
func TestContent_UnmarshalJSON_String(t *testing.T) {
var c Content
if err := json.Unmarshal([]byte(`"hello world"`), &c); err != nil {
t.Fatal(err)
}
if c.String == nil || *c.String != "hello world" {
t.Errorf("expected 'hello world', got %v", c.String)
}
if c.Text() != "hello world" {
t.Errorf("Text() = %q, want 'hello world'", c.Text())
}
}
func TestContent_UnmarshalJSON_Object(t *testing.T) {
// Non-string content (attachment) should preserve raw JSON
var c Content
if err := json.Unmarshal([]byte(`{"type":"sticker","id":123}`), &c); err != nil {
t.Fatal(err)
}
if c.String != nil {
t.Errorf("expected nil String for object content, got %q", *c.String)
}
if c.Text() != "" {
t.Errorf("Text() = %q, want empty", c.Text())
}
if c.Raw == nil {
t.Error("expected Raw to be preserved for object content")
}
}
func TestContent_AttachmentText(t *testing.T) {
t.Run("image with caption", func(t *testing.T) {
var c Content
raw := `{"title":"what do you see","href":"https://example.com/photo.jpg","thumb":"https://example.com/thumb.jpg"}`
if err := json.Unmarshal([]byte(raw), &c); err != nil {
t.Fatal(err)
}
got := c.AttachmentText()
want := "[User sent an image: what do you see]"
if got != want {
t.Errorf("AttachmentText() = %q, want %q", got, want)
}
})
t.Run("image without caption", func(t *testing.T) {
var c Content
raw := `{"title":"","href":"https://example.com/photo.jpg"}`
if err := json.Unmarshal([]byte(raw), &c); err != nil {
t.Fatal(err)
}
got := c.AttachmentText()
if got != "[User sent an image]" {
t.Errorf("AttachmentText() = %q, want '[User sent an image]'", got)
}
})
t.Run("file with caption", func(t *testing.T) {
var c Content
raw := `{"title":"report.pdf","href":"https://example.com/report.pdf"}`
if err := json.Unmarshal([]byte(raw), &c); err != nil {
t.Fatal(err)
}
got := c.AttachmentText()
want := "[User sent a file: report.pdf]"
if got != want {
t.Errorf("AttachmentText() = %q, want %q", got, want)
}
})
t.Run("file without caption", func(t *testing.T) {
var c Content
raw := `{"title":"","href":"https://example.com/doc.docx"}`
if err := json.Unmarshal([]byte(raw), &c); err != nil {
t.Fatal(err)
}
got := c.AttachmentText()
if got != "[User sent a file]" {
t.Errorf("AttachmentText() = %q, want '[User sent a file]'", got)
}
})
t.Run("non-image attachment", func(t *testing.T) {
var c Content
raw := `{"type":"sticker","id":123}`
if err := json.Unmarshal([]byte(raw), &c); err != nil {
t.Fatal(err)
}
got := c.AttachmentText()
if got != "[User sent a non-text message]" {
t.Errorf("AttachmentText() = %q, want '[User sent a non-text message]'", got)
}
})
t.Run("text content returns empty", func(t *testing.T) {
var c Content
if err := json.Unmarshal([]byte(`"hello"`), &c); err != nil {
t.Fatal(err)
}
if c.AttachmentText() != "" {
t.Errorf("AttachmentText() should be empty for text content")
}
})
}
func TestContent_MarshalJSON(t *testing.T) {
s := "test message"
c := Content{String: &s}
b, err := c.MarshalJSON()
if err != nil {
t.Fatal(err)
}
if string(b) != `"test message"` {
t.Errorf("got %s, want %q", b, `"test message"`)
}
// Nil content marshals as null
c2 := Content{}
b2, err := c2.MarshalJSON()
if err != nil {
t.Fatal(err)
}
if string(b2) != "null" {
t.Errorf("got %s, want null", b2)
}
}
func TestNewUserMessage(t *testing.T) {
selfUID := "12345"
text := "hello"
t.Run("incoming message", func(t *testing.T) {
msg := TMessage{
MsgID: "m1",
UIDFrom: "67890",
IDTo: selfUID,
Content: Content{String: &text},
}
um := NewUserMessage(selfUID, msg)
if um.Type() != ThreadTypeUser {
t.Errorf("Type = %d, want %d", um.Type(), ThreadTypeUser)
}
if um.ThreadID() != "67890" {
t.Errorf("ThreadID = %q, want '67890'", um.ThreadID())
}
if um.IsSelf() {
t.Error("IsSelf should be false for incoming message")
}
})
t.Run("self-sent message", func(t *testing.T) {
msg := TMessage{
MsgID: "m2",
UIDFrom: DefaultUIDSelf,
IDTo: "67890",
Content: Content{String: &text},
}
um := NewUserMessage(selfUID, msg)
if !um.IsSelf() {
t.Error("IsSelf should be true for self-sent")
}
if um.ThreadID() != "67890" {
t.Errorf("ThreadID = %q, want '67890' (should use IDTo for self-sent)", um.ThreadID())
}
if um.Data.UIDFrom != selfUID {
t.Errorf("UIDFrom = %q, want %q (should resolve '0' to selfUID)", um.Data.UIDFrom, selfUID)
}
})
t.Run("IDTo is self", func(t *testing.T) {
msg := TMessage{
MsgID: "m3",
UIDFrom: "67890",
IDTo: DefaultUIDSelf,
}
um := NewUserMessage(selfUID, msg)
if um.Data.IDTo != selfUID {
t.Errorf("IDTo = %q, want %q (should resolve '0' to selfUID)", um.Data.IDTo, selfUID)
}
})
}
func TestNewGroupMessage(t *testing.T) {
selfUID := "12345"
text := "group msg"
t.Run("incoming group message", func(t *testing.T) {
msg := TGroupMessage{
TMessage: TMessage{
MsgID: "gm1",
UIDFrom: "67890",
IDTo: "group_abc",
Content: Content{String: &text},
},
}
gm := NewGroupMessage(selfUID, msg)
if gm.Type() != ThreadTypeGroup {
t.Errorf("Type = %d, want %d", gm.Type(), ThreadTypeGroup)
}
if gm.ThreadID() != "group_abc" {
t.Errorf("ThreadID = %q, want 'group_abc'", gm.ThreadID())
}
if gm.IsSelf() {
t.Error("IsSelf should be false")
}
})
t.Run("self-sent group message", func(t *testing.T) {
msg := TGroupMessage{
TMessage: TMessage{
MsgID: "gm2",
UIDFrom: DefaultUIDSelf,
IDTo: "group_abc",
},
}
gm := NewGroupMessage(selfUID, msg)
if !gm.IsSelf() {
t.Error("IsSelf should be true for self-sent")
}
if gm.Data.UIDFrom != selfUID {
t.Errorf("UIDFrom = %q, want %q", gm.Data.UIDFrom, selfUID)
}
if gm.ThreadID() != "group_abc" {
t.Errorf("ThreadID = %q, want 'group_abc'", gm.ThreadID())
}
})
}
func TestAttachment_IsImage(t *testing.T) {
tests := []struct {
name string
att *Attachment
want bool
}{
{"nil attachment", nil, false},
{"no href", &Attachment{Title: "test"}, false},
{"jpg", &Attachment{Href: "https://cdn.example.com/photo.jpg"}, true},
{"png with query", &Attachment{Href: "https://cdn.example.com/img.png?w=100"}, true},
{"webp", &Attachment{Href: "https://cdn.example.com/sticker.webp"}, true},
{"pdf", &Attachment{Href: "https://cdn.example.com/doc.pdf"}, false},
{"docx", &Attachment{Href: "https://cdn.example.com/report.docx"}, false},
{"zalo cdn jpg path", &Attachment{Href: "https://f20-zpc.zdn.vn/jpg/abc123.jpg"}, true},
{"zalo cdn path no ext", &Attachment{Href: "https://f20-zpc.zdn.vn/jpg/abc123"}, true},
{"no extension", &Attachment{Href: "https://cdn.example.com/file"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.att.IsImage(); got != tt.want {
t.Errorf("IsImage() = %v, want %v", got, tt.want)
}
})
}
}
func TestTMessage_UnmarshalJSON(t *testing.T) {
raw := `{
"msgId": "123",
"uidFrom": "456",
"idTo": "789",
"dName": "Test User",
"ts": "1709300000",
"content": "hello",
"msgType": "chat.message",
"cmd": 501,
"st": 1,
"at": 0
}`
var msg TMessage
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
t.Fatal(err)
}
if msg.MsgID != "123" {
t.Errorf("MsgID = %q", msg.MsgID)
}
if msg.Content.Text() != "hello" {
t.Errorf("Content = %q", msg.Content.Text())
}
if msg.CMD != 501 {
t.Errorf("CMD = %d", msg.CMD)
}
}
func TestTGroupMessage_WithMentions(t *testing.T) {
raw := `{
"msgId": "gm1",
"uidFrom": "111",
"idTo": "group1",
"content": "@all hello",
"msgType": "chat.message",
"cmd": 521,
"st": 1,
"at": 0,
"mentions": [
{"uid": "-1", "pos": 0, "len": 4, "type": 1}
]
}`
var msg TGroupMessage
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
t.Fatal(err)
}
if len(msg.Mentions) != 1 {
t.Fatalf("expected 1 mention, got %d", len(msg.Mentions))
}
m := msg.Mentions[0]
if m.UID != MentionAllUID {
t.Errorf("mention UID = %q, want %q", m.UID, MentionAllUID)
}
if m.Type != MentionAll {
t.Errorf("mention Type = %d, want %d", m.Type, MentionAll)
}
}
@@ -0,0 +1,137 @@
package protocol
import (
"encoding/base64"
"encoding/json"
)
// SecretKey is a base64-encoded secret key from Zalo login.
type SecretKey string
func (s SecretKey) Bytes() []byte {
decoded, err := base64.StdEncoding.DecodeString(string(s))
if err != nil {
return nil
}
return decoded
}
// LoginInfo from getLoginInfo response (AES-CBC encrypted).
type LoginInfo struct {
UID string `json:"uid"`
ZPWEnk string `json:"zpw_enk"`
ZpwWebsocket []string `json:"zpw_ws"`
ZpwServiceMapV3 ZpwServiceMapV3 `json:"zpw_service_map_v3"`
}
// ZpwServiceMapV3 holds Zalo service endpoint URLs.
type ZpwServiceMapV3 struct {
Chat []string `json:"chat"`
Group []string `json:"group"`
File []string `json:"file"`
Profile []string `json:"profile"`
GroupPoll []string `json:"group_poll"`
// Only fields needed for GoClaw; Zalo returns many more.
}
// ServerInfo from getServerInfo response.
type ServerInfo struct {
Settings *Settings `json:"settings"`
}
// UnmarshalJSON handles Zalo's typo: "setttings" (3 t's) vs "settings".
func (s *ServerInfo) UnmarshalJSON(data []byte) error {
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
for _, k := range []string{"settings", "setttings"} {
if v, ok := raw[k]; ok {
return json.Unmarshal(v, &s.Settings)
}
}
return nil
}
// Settings holds server-provided configuration.
type Settings struct {
Features Features `json:"features"`
Keepalive KeepaliveSettings `json:"keepalive"`
}
type Features struct {
Socket SocketSettings `json:"socket"`
}
type SocketSettings struct {
PingInterval int `json:"ping_interval"`
Retries map[string]SocketRetryConfig `json:"retries"`
CloseAndRetry []int `json:"close_and_retry_codes"`
RotateErrorCodes []int `json:"rotate_error_codes"`
}
type SocketRetryConfig struct {
Max *int `json:"max,omitempty"`
Times []int `json:"times"`
}
// UnmarshalJSON handles Zalo's OneOrMany pattern: value can be int or []int.
func (r *SocketRetryConfig) UnmarshalJSON(data []byte) error {
type Alias struct {
Max *int `json:"max,omitempty"`
Times json.RawMessage `json:"times"`
}
var a Alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
r.Max = a.Max
// Try []int first
if err := json.Unmarshal(a.Times, &r.Times); err != nil {
// Try single int
var single int
if err2 := json.Unmarshal(a.Times, &single); err2 != nil {
return err
}
r.Times = []int{single}
}
return nil
}
type KeepaliveSettings struct {
AlwaysKeepalive uint `json:"alway_keepalive"`
KeepaliveDuration uint `json:"keepalive_duration"`
}
// --- Zalo API response types ---
// Response is the generic Zalo API response envelope.
type Response[T any] struct {
ErrorCode int `json:"error_code"`
ErrorMessage string `json:"error_message"`
Data T `json:"data"`
}
// QRGeneratedData from QR code generation response.
type QRGeneratedData struct {
Code string `json:"code"`
Image string `json:"image"` // base64-encoded PNG with data URI prefix
}
// QRScannedData from QR waiting-scan response.
type QRScannedData struct {
Avatar string `json:"avatar"`
DisplayName string `json:"display_name"`
}
// QRUserInfo from getUserInfo response.
type QRUserInfo struct {
Logged bool `json:"logged"`
Info UserInfo `json:"info"`
}
// UserInfo holds basic Zalo user info.
type UserInfo struct {
Name string `json:"name"`
Avatar string `json:"avatar"`
}
@@ -0,0 +1,169 @@
package protocol
import (
"encoding/json"
"testing"
)
func TestServerInfo_UnmarshalJSON_CorrectSpelling(t *testing.T) {
raw := `{"settings":{"features":{"socket":{"ping_interval":30000}},"keepalive":{"alway_keepalive":1}}}`
var si ServerInfo
if err := json.Unmarshal([]byte(raw), &si); err != nil {
t.Fatal(err)
}
if si.Settings == nil {
t.Fatal("Settings is nil")
}
if si.Settings.Features.Socket.PingInterval != 30000 {
t.Errorf("PingInterval = %d, want 30000", si.Settings.Features.Socket.PingInterval)
}
}
func TestServerInfo_UnmarshalJSON_ZaloTypo(t *testing.T) {
// Zalo sometimes sends "setttings" (3 t's)
raw := `{"setttings":{"features":{"socket":{"ping_interval":15000}},"keepalive":{"alway_keepalive":0}}}`
var si ServerInfo
if err := json.Unmarshal([]byte(raw), &si); err != nil {
t.Fatal(err)
}
if si.Settings == nil {
t.Fatal("Settings is nil with Zalo typo")
}
if si.Settings.Features.Socket.PingInterval != 15000 {
t.Errorf("PingInterval = %d, want 15000", si.Settings.Features.Socket.PingInterval)
}
}
func TestServerInfo_UnmarshalJSON_NoSettings(t *testing.T) {
raw := `{"other_field": "value"}`
var si ServerInfo
if err := json.Unmarshal([]byte(raw), &si); err != nil {
t.Fatal(err)
}
if si.Settings != nil {
t.Error("Settings should be nil when neither key is present")
}
}
func TestSocketRetryConfig_ArrayTimes(t *testing.T) {
raw := `{"max": 5, "times": [1000, 2000, 5000]}`
var cfg SocketRetryConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Max == nil || *cfg.Max != 5 {
t.Errorf("Max = %v, want 5", cfg.Max)
}
if len(cfg.Times) != 3 || cfg.Times[0] != 1000 || cfg.Times[2] != 5000 {
t.Errorf("Times = %v, want [1000, 2000, 5000]", cfg.Times)
}
}
func TestSocketRetryConfig_SingleTime(t *testing.T) {
// Zalo's OneOrMany: "times" can be a single int
raw := `{"max": 3, "times": 2000}`
var cfg SocketRetryConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatal(err)
}
if len(cfg.Times) != 1 || cfg.Times[0] != 2000 {
t.Errorf("Times = %v, want [2000]", cfg.Times)
}
}
func TestSocketRetryConfig_NoMax(t *testing.T) {
raw := `{"times": [1000]}`
var cfg SocketRetryConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Max != nil {
t.Errorf("Max = %v, want nil", cfg.Max)
}
if len(cfg.Times) != 1 {
t.Errorf("Times = %v", cfg.Times)
}
}
func TestResponse_Generic(t *testing.T) {
raw := `{"error_code": 0, "error_message": "", "data": {"uid": "123"}}`
var resp Response[struct {
UID string `json:"uid"`
}]
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if resp.ErrorCode != 0 {
t.Errorf("ErrorCode = %d", resp.ErrorCode)
}
if resp.Data.UID != "123" {
t.Errorf("Data.UID = %q", resp.Data.UID)
}
}
func TestResponse_ErrorCode(t *testing.T) {
raw := `{"error_code": -13, "error_message": "QR login declined", "data": null}`
var resp Response[*struct{}]
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
t.Fatal(err)
}
if resp.ErrorCode != -13 {
t.Errorf("ErrorCode = %d, want -13", resp.ErrorCode)
}
if resp.ErrorMessage != "QR login declined" {
t.Errorf("ErrorMessage = %q", resp.ErrorMessage)
}
}
func TestLoginInfo_Unmarshal(t *testing.T) {
raw := `{
"uid": "12345",
"zpw_enk": "base64key==",
"zpw_ws": ["wss://ws1.zalo.me", "wss://ws2.zalo.me"],
"zpw_service_map_v3": {
"chat": ["https://chat1.zalo.me"],
"group": ["https://group1.zalo.me"],
"file": ["https://file1.zalo.me"]
}
}`
var li LoginInfo
if err := json.Unmarshal([]byte(raw), &li); err != nil {
t.Fatal(err)
}
if li.UID != "12345" {
t.Errorf("UID = %q", li.UID)
}
if li.ZPWEnk != "base64key==" {
t.Errorf("ZPWEnk = %q", li.ZPWEnk)
}
if len(li.ZpwWebsocket) != 2 {
t.Errorf("ZpwWebsocket len = %d", len(li.ZpwWebsocket))
}
if len(li.ZpwServiceMapV3.Chat) != 1 {
t.Errorf("Chat URLs len = %d", len(li.ZpwServiceMapV3.Chat))
}
}
func TestQRGeneratedData_Unmarshal(t *testing.T) {
raw := `{"code": "abc123", "image": "data:image/png;base64,iVBOR..."}`
var qr QRGeneratedData
if err := json.Unmarshal([]byte(raw), &qr); err != nil {
t.Fatal(err)
}
if qr.Code != "abc123" {
t.Errorf("Code = %q", qr.Code)
}
if qr.Image == "" {
t.Error("Image is empty")
}
}
@@ -0,0 +1,182 @@
package protocol
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"time"
)
// SendMessage sends a text message to a user or group.
// threadID: user UID (DM) or group ID (group).
func SendMessage(ctx context.Context, sess *Session, threadID string, threadType ThreadType, text string) (string, error) {
if text == "" {
return "", fmt.Errorf("zalo_personal: message text cannot be empty")
}
serviceKey := "chat"
apiPath := "/api/message/sms"
if threadType == ThreadTypeGroup {
serviceKey = "group"
apiPath = "/api/group/sendmsg"
}
baseURL := getServiceURL(sess, serviceKey)
if baseURL == "" {
return "", fmt.Errorf("zalo_personal: no service URL for %s", serviceKey)
}
// Build payload
payload := map[string]any{
"message": text,
"clientId": time.Now().UnixMilli(),
"ttl": 0,
}
if threadType == ThreadTypeGroup {
payload["grid"] = threadID
payload["visibility"] = 0
} else {
payload["toid"] = threadID
payload["imei"] = sess.IMEI
}
// Encrypt payload with session secret key
encData, err := encryptPayload(sess, payload)
if err != nil {
return "", fmt.Errorf("zalo_personal: encrypt send payload: %w", err)
}
// Build URL with standard params
sendURL := makeURL(sess, baseURL+apiPath, map[string]any{"nretry": 0}, true)
// POST form-encoded
form := buildFormBody(map[string]string{"params": encData})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, sendURL, form)
if err != nil {
return "", err
}
setDefaultHeaders(req, sess)
resp, err := sess.Client.Do(req)
if err != nil {
return "", fmt.Errorf("zalo_personal: send message: %w", err)
}
defer resp.Body.Close()
// Send response is encrypted: {"error_code":0, "data":"<encrypted>"}
var envelope Response[*string]
if err := readJSON(resp, &envelope); err != nil {
return "", fmt.Errorf("zalo_personal: parse send response: %w", err)
}
if envelope.ErrorCode != 0 {
return "", fmt.Errorf("zalo_personal: send error code %d", envelope.ErrorCode)
}
if envelope.Data == nil {
return "", nil
}
plain, err := decryptDataField(sess, *envelope.Data)
if err != nil {
return "", fmt.Errorf("zalo_personal: decrypt send response: %w", err)
}
var result struct {
MsgID json.Number `json:"msgId"`
}
if err := json.Unmarshal(plain, &result); err != nil {
return "", fmt.Errorf("zalo_personal: parse send result: %w", err)
}
return result.MsgID.String(), nil
}
// SendTypingEvent sends a typing indicator to a user or group.
// Zalo typing lasts ~5s server-side; callers should re-send every 3-4s.
func SendTypingEvent(ctx context.Context, sess *Session, threadID string, threadType ThreadType) error {
serviceKey := "chat"
apiPath := "/api/message/typing"
if threadType == ThreadTypeGroup {
serviceKey = "group"
apiPath = "/api/group/typing"
}
baseURL := getServiceURL(sess, serviceKey)
if baseURL == "" {
return fmt.Errorf("zalo_personal: no service URL for %s", serviceKey)
}
payload := map[string]any{"imei": sess.IMEI}
if threadType == ThreadTypeGroup {
payload["grid"] = threadID
} else {
payload["toid"] = threadID
payload["destType"] = 3 // DestTypeUser
}
encData, err := encryptPayload(sess, payload)
if err != nil {
return fmt.Errorf("zalo_personal: encrypt typing payload: %w", err)
}
typingURL := makeURL(sess, baseURL+apiPath, nil, true)
form := buildFormBody(map[string]string{"params": encData})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, typingURL, form)
if err != nil {
return err
}
setDefaultHeaders(req, sess)
resp, err := sess.Client.Do(req)
if err != nil {
return fmt.Errorf("zalo_personal: send typing: %w", err)
}
defer resp.Body.Close()
var envelope Response[any]
if err := readJSON(resp, &envelope); err != nil {
return fmt.Errorf("zalo_personal: parse typing response: %w", err)
}
if envelope.ErrorCode != 0 {
return fmt.Errorf("zalo_personal: typing error code %d", envelope.ErrorCode)
}
return nil
}
// getServiceURL extracts a service base URL from LoginInfo.
func getServiceURL(sess *Session, service string) string {
if sess.LoginInfo == nil {
return ""
}
var urls []string
switch service {
case "chat":
urls = sess.LoginInfo.ZpwServiceMapV3.Chat
case "group":
urls = sess.LoginInfo.ZpwServiceMapV3.Group
case "file":
urls = sess.LoginInfo.ZpwServiceMapV3.File
case "profile":
urls = sess.LoginInfo.ZpwServiceMapV3.Profile
case "group_poll":
urls = sess.LoginInfo.ZpwServiceMapV3.GroupPoll
}
if len(urls) == 0 {
return ""
}
return urls[0]
}
// encryptPayload encrypts a JSON payload with the session's secret key via AES-CBC.
func encryptPayload(sess *Session, payload map[string]any) (string, error) {
blob, err := json.Marshal(payload)
if err != nil {
return "", err
}
key, err := base64.StdEncoding.DecodeString(sess.SecretKey)
if err != nil {
return "", fmt.Errorf("decode secret key: %w", err)
}
return EncodeAESCBC(key, string(blob), false)
}
@@ -0,0 +1,117 @@
package protocol
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"github.com/gorilla/websocket"
)
// WSClient wraps gorilla/websocket with a thread-safe write method.
type WSClient struct {
conn *websocket.Conn
mu sync.Mutex
}
// DialWS connects to a WebSocket endpoint using gorilla/websocket.
// Manually injects cookies from the jar using https:// scheme to bypass
// Go's cookiejar domain-matching limitations with wss:// URLs and host-only cookies.
func DialWS(ctx context.Context, wsURL string, headers http.Header, jar http.CookieJar) (*WSClient, error) {
dialer := websocket.Dialer{
EnableCompression: true,
}
// Don't pass jar to gorilla — we inject cookies manually below.
// This avoids issues with host-only cookies not matching WS subdomains.
// Inject cookies from chat.zalo.me base domain + the WS host itself.
if jar != nil {
injectCookies(headers, jar, wsURL)
}
conn, _, err := dialer.DialContext(ctx, wsURL, headers)
if err != nil {
return nil, fmt.Errorf("zalo_personal: ws dial: %w", err)
}
conn.SetReadLimit(1 << 20) // 1MB
return &WSClient{conn: conn}, nil
}
// injectCookies merges cookies from the base domain (chat.zalo.me) and the
// WS host into the request headers. Deduplicates by name so the most specific
// (WS host) cookie wins.
func injectCookies(headers http.Header, jar http.CookieJar, wsURL string) {
baseURL := &url.URL{Scheme: "https", Host: "chat.zalo.me", Path: "/"}
// Collect from base domain first, then WS host (WS host overrides).
seen := make(map[string]string, 4)
for _, c := range jar.Cookies(baseURL) {
seen[c.Name] = c.Name + "=" + c.Value
}
// Also try the exact WS host with https:// scheme
if u, err := url.Parse(strings.Replace(wsURL, "wss://", "https://", 1)); err == nil {
for _, c := range jar.Cookies(u) {
seen[c.Name] = c.Name + "=" + c.Value
}
}
if len(seen) == 0 {
return
}
parts := make([]string, 0, len(seen))
for _, v := range seen {
parts = append(parts, v)
}
cookieHeader := strings.Join(parts, "; ")
// Merge with any existing Cookie header
if existing := headers.Get("Cookie"); existing != "" {
cookieHeader = existing + "; " + cookieHeader
}
headers.Set("Cookie", cookieHeader)
slog.Info("zalo ws cookies injected", "count", len(seen), "url", wsURL)
}
// ReadMessage reads the next WebSocket message. Blocks until a message arrives
// or the connection is closed. NOTE: gorilla/websocket ReadMessage is blocking
// and does not observe ctx. Cancellation works indirectly: Stop() closes the
// connection which unblocks ReadMessage with an error. The listener applies
// read deadlines via conn.SetReadDeadline for silent disconnect detection.
func (c *WSClient) ReadMessage(ctx context.Context) ([]byte, error) {
_, data, err := c.conn.ReadMessage()
return data, err
}
// WriteMessage sends a binary WebSocket message. Thread-safe.
func (c *WSClient) WriteMessage(ctx context.Context, data []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.conn.WriteMessage(websocket.BinaryMessage, data)
}
// Close sends a close frame and shuts down the connection.
func (c *WSClient) Close(code int, reason string) {
c.mu.Lock()
defer c.mu.Unlock()
_ = c.conn.WriteMessage(
websocket.CloseMessage,
websocket.FormatCloseMessage(code, reason),
)
c.conn.Close()
}
// parseWSCloseInfo extracts close code and reason from a gorilla/websocket error.
func parseWSCloseInfo(err error) CloseInfo {
var ce *websocket.CloseError
if errors.As(err, &ce) {
return CloseInfo{Code: ce.Code, Reason: ce.Text}
}
return CloseInfo{Code: 1006, Reason: err.Error()}
}
@@ -0,0 +1,127 @@
package zalomethods
import (
"context"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"golang.org/x/sync/errgroup"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/store"
goclawprotocol "github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// ContactsMethods handles fetching Zalo friends/groups for the picker UI.
type ContactsMethods struct {
instanceStore store.ChannelInstanceStore
activeFetches sync.Map // instanceID string -> struct{}
}
func NewContactsMethods(s store.ChannelInstanceStore) *ContactsMethods {
return &ContactsMethods{instanceStore: s}
}
func (m *ContactsMethods) Register(router *gateway.MethodRouter) {
router.Register(goclawprotocol.MethodZaloPersonalContacts, m.handleContacts)
}
func (m *ContactsMethods) handleContacts(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) {
var params struct {
InstanceID string `json:"instance_id"`
}
if req.Params != nil {
_ = json.Unmarshal(req.Params, &params)
}
instID, err := uuid.Parse(params.InstanceID)
if err != nil {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "invalid instance_id"))
return
}
inst, err := m.instanceStore.Get(ctx, instID)
if err != nil || inst.ChannelType != "zalo_personal" {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrNotFound, "zalo_personal instance not found"))
return
}
if len(inst.Credentials) == 0 {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "instance has no credentials — complete QR login first"))
return
}
// Prevent concurrent fetches for same instance
if _, loaded := m.activeFetches.LoadOrStore(params.InstanceID, struct{}{}); loaded {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "contacts fetch already in progress"))
return
}
defer m.activeFetches.Delete(params.InstanceID)
// Parse credentials (same struct as factory.go)
var creds struct {
IMEI string `json:"imei"`
Cookie *protocol.CookieUnion `json:"cookie"`
UserAgent string `json:"userAgent"`
Language *string `json:"language,omitempty"`
}
if err := json.Unmarshal(inst.Credentials, &creds); err != nil {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInternal, "failed to parse credentials"))
return
}
protoCred := &protocol.Credentials{
IMEI: creds.IMEI,
Cookie: creds.Cookie,
UserAgent: creds.UserAgent,
Language: creds.Language,
}
// Create temporary session and login
fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
sess := protocol.NewSession()
if err := protocol.LoginWithCredentials(fetchCtx, sess, *protoCred); err != nil {
slog.Warn("Zalo Personal contacts: login failed", "instance", params.InstanceID, "error", err)
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInternal, "Zalo login failed — credentials may be expired, try QR login again"))
return
}
// Fetch friends and groups in parallel
var friends []protocol.FriendInfo
var groups []protocol.GroupListInfo
g, gctx := errgroup.WithContext(fetchCtx)
g.Go(func() error {
f, err := protocol.FetchFriends(gctx, sess)
if err != nil {
return err
}
friends = f
return nil
})
g.Go(func() error {
gr, err := protocol.FetchGroups(gctx, sess)
if err != nil {
return err
}
groups = gr
return nil
})
if err := g.Wait(); err != nil {
slog.Warn("Zalo Personal contacts: fetch failed", "instance", params.InstanceID, "error", err)
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInternal, "failed to fetch contacts"))
return
}
client.SendResponse(goclawprotocol.NewOKResponse(req.ID, map[string]any{
"friends": friends,
"groups": groups,
}))
}
@@ -0,0 +1,138 @@
package zalomethods
import (
"context"
"encoding/base64"
"encoding/json"
"log/slog"
"sync"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/channels/zalo/personal/protocol"
"github.com/nextlevelbuilder/goclaw/internal/gateway"
"github.com/nextlevelbuilder/goclaw/internal/store"
goclawprotocol "github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// QRMethods handles QR login for zalo_personal channel instances.
type QRMethods struct {
instanceStore store.ChannelInstanceStore
msgBus *bus.MessageBus
activeSessions sync.Map // instanceID (string) -> struct{}
}
func NewQRMethods(s store.ChannelInstanceStore, msgBus *bus.MessageBus) *QRMethods {
return &QRMethods{instanceStore: s, msgBus: msgBus}
}
func (m *QRMethods) Register(router *gateway.MethodRouter) {
router.Register(goclawprotocol.MethodZaloPersonalQRStart, m.handleQRStart)
}
func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) {
var params struct {
InstanceID string `json:"instance_id"`
}
if req.Params != nil {
_ = json.Unmarshal(req.Params, &params)
}
instID, err := uuid.Parse(params.InstanceID)
if err != nil {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "invalid instance_id"))
return
}
inst, err := m.instanceStore.Get(ctx, instID)
if err != nil || inst.ChannelType != "zalo_personal" {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrNotFound, "zalo_personal instance not found"))
return
}
if _, loaded := m.activeSessions.LoadOrStore(params.InstanceID, struct{}{}); loaded {
client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "QR session already active for this instance"))
return
}
// ACK immediately — QR arrives via event.
client.SendResponse(goclawprotocol.NewOKResponse(req.ID, map[string]any{"status": "started"}))
go m.runQRFlow(ctx, client, params.InstanceID, instID)
}
func (m *QRMethods) runQRFlow(ctx context.Context, client *gateway.Client, instanceIDStr string, instanceID uuid.UUID) {
defer m.activeSessions.Delete(instanceIDStr)
sess := protocol.NewSession()
// LoginQR has internal 100s timeout per QR code. Use 2m as outer bound.
// Derived from parent ctx so QR flow cancels when the WS client disconnects.
qrCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
cred, err := protocol.LoginQR(qrCtx, sess, func(qrPNG []byte) {
client.SendEvent(goclawprotocol.EventFrame{
Type: goclawprotocol.FrameTypeEvent,
Event: goclawprotocol.EventZaloPersonalQRCode,
Payload: map[string]any{
"instance_id": instanceIDStr,
"png_b64": base64.StdEncoding.EncodeToString(qrPNG),
},
})
})
if err != nil {
slog.Warn("Zalo Personal QR login failed", "instance", instanceIDStr, "error", err)
client.SendEvent(*goclawprotocol.NewEvent(goclawprotocol.EventZaloPersonalQRDone, map[string]any{
"instance_id": instanceIDStr,
"success": false,
"error": err.Error(),
}))
return
}
credsJSON, err := json.Marshal(map[string]any{
"imei": cred.IMEI,
"cookie": cred.Cookie,
"userAgent": cred.UserAgent,
"language": cred.Language,
})
if err != nil {
slog.Error("Zalo Personal QR: marshal credentials failed", "error", err)
client.SendEvent(*goclawprotocol.NewEvent(goclawprotocol.EventZaloPersonalQRDone, map[string]any{
"instance_id": instanceIDStr,
"success": false,
"error": "internal error: credential serialization failed",
}))
return
}
if err := m.instanceStore.Update(context.Background(), instanceID, map[string]any{
"credentials": string(credsJSON),
}); err != nil {
slog.Error("Zalo Personal QR: save credentials failed", "instance", instanceIDStr, "error", err)
client.SendEvent(*goclawprotocol.NewEvent(goclawprotocol.EventZaloPersonalQRDone, map[string]any{
"instance_id": instanceIDStr,
"success": false,
"error": "failed to save credentials",
}))
return
}
// Trigger instanceLoader reload via cache invalidation.
if m.msgBus != nil {
m.msgBus.Broadcast(bus.Event{
Name: goclawprotocol.EventCacheInvalidate,
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindChannelInstances},
})
}
client.SendEvent(*goclawprotocol.NewEvent(goclawprotocol.EventZaloPersonalQRDone, map[string]any{
"instance_id": instanceIDStr,
"success": true,
}))
slog.Info("Zalo Personal QR login completed, credentials saved", "instance", instanceIDStr)
}
+12 -2
View File
@@ -6,8 +6,9 @@ type ChannelsConfig struct {
Discord DiscordConfig `json:"discord"`
Slack SlackConfig `json:"slack"`
WhatsApp WhatsAppConfig `json:"whatsapp"`
Zalo ZaloConfig `json:"zalo"`
Feishu FeishuConfig `json:"feishu"`
Zalo ZaloConfig `json:"zalo"`
ZaloPersonal ZaloPersonalConfig `json:"zalo_personal"`
Feishu FeishuConfig `json:"feishu"`
}
type TelegramConfig struct {
@@ -103,6 +104,15 @@ type ZaloConfig struct {
MediaMaxMB int `json:"media_max_mb,omitempty"` // default 5
}
type ZaloPersonalConfig struct {
Enabled bool `json:"enabled"`
AllowFrom FlexibleStringSlice `json:"allow_from"`
DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default), "allowlist", "open", "disabled"
GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled"
RequireMention *bool `json:"require_mention,omitempty"` // require @bot mention in groups (default true)
CredentialsPath string `json:"credentials_path,omitempty"` // path to saved cookies JSON (standalone)
}
type FeishuConfig struct {
Enabled bool `json:"enabled"`
AppID string `json:"app_id"`
@@ -216,10 +216,11 @@ func maskInstance(inst store.ChannelInstanceData) map[string]interface{} {
"agent_id": inst.AgentID,
"config": inst.Config,
"enabled": inst.Enabled,
"is_default": store.IsDefaultChannelInstance(inst.Name),
"created_by": inst.CreatedBy,
"created_at": inst.CreatedAt,
"updated_at": inst.UpdatedAt,
"is_default": store.IsDefaultChannelInstance(inst.Name),
"has_credentials": len(inst.Credentials) > 0,
"created_by": inst.CreatedBy,
"created_at": inst.CreatedAt,
"updated_at": inst.UpdatedAt,
}
// Mask credentials: show keys with "***" values
+5 -4
View File
@@ -248,10 +248,11 @@ func maskInstanceHTTP(inst store.ChannelInstanceData) map[string]interface{} {
"agent_id": inst.AgentID,
"config": inst.Config,
"enabled": inst.Enabled,
"is_default": store.IsDefaultChannelInstance(inst.Name),
"created_by": inst.CreatedBy,
"created_at": inst.CreatedAt,
"updated_at": inst.UpdatedAt,
"is_default": store.IsDefaultChannelInstance(inst.Name),
"has_credentials": len(inst.Credentials) > 0,
"created_by": inst.CreatedBy,
"created_at": inst.CreatedAt,
"updated_at": inst.UpdatedAt,
}
if len(inst.Credentials) > 0 {
+2 -2
View File
@@ -95,7 +95,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{})
}
// SSRF protection
if err := checkSSRF(rawURL); err != nil {
if err := CheckSSRF(rawURL); err != nil {
return ErrorResult(fmt.Sprintf("SSRF protection: %v", err))
}
@@ -150,7 +150,7 @@ func (t *WebFetchTool) doFetch(ctx context.Context, rawURL, extractMode string,
return fmt.Errorf("stopped after %d redirects", defaultFetchMaxRedirect)
}
// Check SSRF on redirect target
if err := checkSSRF(req.URL.String()); err != nil {
if err := CheckSSRF(req.URL.String()); err != nil {
return fmt.Errorf("redirect SSRF protection: %w", err)
}
return nil
+2 -2
View File
@@ -159,9 +159,9 @@ func isPrivateIP(ipStr string) bool {
return false
}
// checkSSRF validates a URL against SSRF attacks.
// CheckSSRF validates a URL against SSRF attacks.
// Returns an error if the URL targets a private/blocked host.
func checkSSRF(rawURL string) error {
func CheckSSRF(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
+4
View File
@@ -35,6 +35,10 @@ const (
// Cache invalidation events (internal, not forwarded to WS clients).
EventCacheInvalidate = "cache.invalidate"
// Zalo Personal QR login events (client-scoped, not broadcast).
EventZaloPersonalQRCode = "zalo.personal.qr.code"
EventZaloPersonalQRDone = "zalo.personal.qr.done"
)
// Agent event subtypes (in payload.type)
+4
View File
@@ -133,4 +133,8 @@ const (
MethodBrowserScreenshot = "browser.screenshot"
MethodHeartbeat = "heartbeat"
// Zalo Personal (managed mode)
MethodZaloPersonalQRStart = "zalo.personal.qr.start"
MethodZaloPersonalContacts = "zalo.personal.contacts"
)
+1
View File
@@ -3,5 +3,6 @@ export const CHANNEL_TYPES = [
{ value: "discord", label: "Discord" },
{ value: "feishu", label: "Feishu / Lark" },
{ value: "zalo_oa", label: "Zalo OA" },
{ value: "zalo_personal", label: "Zalo Personal" },
{ value: "whatsapp", label: "WhatsApp" },
] as const;
@@ -20,17 +20,21 @@ import {
import type { ChannelInstanceData, ChannelInstanceInput } from "./hooks/use-channel-instances";
import type { AgentData } from "@/types/agent";
import { slugify, isValidSlug } from "@/lib/slug";
import { credentialsSchema, configSchema } from "./channel-schemas";
import { credentialsSchema, configSchema, wizardConfig } from "./channel-schemas";
import { ChannelFields } from "./channel-fields";
import { wizardAuthSteps, wizardConfigSteps, wizardEditConfigs } from "./channel-wizard-registry";
import { TelegramGroupOverrides } from "./telegram-group-overrides";
import { CHANNEL_TYPES } from "@/constants/channels";
type WizardStep = "form" | "auth" | "config";
interface ChannelInstanceFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
instance?: ChannelInstanceData | null;
agents: AgentData[];
onSubmit: (data: ChannelInstanceInput) => Promise<unknown>;
onUpdate?: (id: string, data: Partial<ChannelInstanceInput>) => Promise<unknown>;
}
export function ChannelInstanceFormDialog({
@@ -39,6 +43,7 @@ export function ChannelInstanceFormDialog({
instance,
agents,
onSubmit,
onUpdate,
}: ChannelInstanceFormDialogProps) {
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
@@ -50,19 +55,60 @@ export function ChannelInstanceFormDialog({
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Wizard state (activated for channels with wizardConfig on create only)
const [step, setStep] = useState<WizardStep>("form");
const [createdInstanceId, setCreatedInstanceId] = useState<string | null>(null);
const [authCompleted, setAuthCompleted] = useState(false);
const wizard = wizardConfig[channelType];
const hasWizard = !instance && !!wizard;
const channelLabel = CHANNEL_TYPES.find((ct) => ct.value === channelType)?.label ?? channelType;
// Step navigation
const totalSteps = hasWizard ? 1 + wizard!.steps.length : 1;
const currentStepNum = step === "form" ? 1 : (wizard?.steps.indexOf(step as "auth" | "config") ?? 0) + 2;
const getNextWizardStep = useCallback((current: WizardStep): "auth" | "config" | null => {
if (!wizard) return null;
if (current === "form") return wizard.steps[0] ?? null;
const idx = wizard.steps.indexOf(current as "auth" | "config");
return idx >= 0 ? wizard.steps[idx + 1] ?? null : null;
}, [wizard]);
useEffect(() => {
if (open) {
setName(instance?.name ?? "");
setDisplayName(instance?.display_name ?? "");
setChannelType(instance?.channel_type ?? "telegram");
setAgentId(instance?.agent_id ?? (agents[0]?.id ?? ""));
setCredsValues({}); // never pre-fill credentials
setConfigValues(instance?.config ? { ...instance.config } : {});
setCredsValues({});
// Merge schema defaults into config so select fields persist their defaults.
const ct = instance?.channel_type ?? "telegram";
const schema = configSchema[ct] ?? [];
const defaults: Record<string, unknown> = {};
for (const f of schema) {
if (f.defaultValue !== undefined) defaults[f.key] = f.defaultValue;
}
setConfigValues({ ...defaults, ...(instance?.config ?? {}) });
setEnabled(instance?.enabled ?? true);
setError("");
setStep("form");
setCreatedInstanceId(null);
setAuthCompleted(false);
}
}, [open, instance, agents]);
// Auto-advance from auth to next step on completion
useEffect(() => {
if (step !== "auth" || !authCompleted) return;
const next = getNextWizardStep("auth");
const id = setTimeout(() => {
if (next) setStep(next);
else onOpenChange(false);
}, 1200);
return () => clearTimeout(id);
}, [step, authCompleted, getNextWizardStep, onOpenChange]);
const handleCredsChange = useCallback((key: string, value: unknown) => {
setCredsValues((prev) => ({ ...prev, [key]: value }));
}, []);
@@ -72,20 +118,13 @@ export function ChannelInstanceFormDialog({
}, []);
const handleSubmit = async () => {
if (!name.trim()) {
setError("Name is required");
return;
}
if (!name.trim()) { setError("Name is required"); return; }
if (!isValidSlug(name.trim())) {
setError("Name must be a valid slug (lowercase letters, numbers, hyphens only)");
return;
}
if (!agentId) {
setError("Agent is required");
return;
}
if (!agentId) { setError("Agent is required"); return; }
// Validate required credentials on create
if (!instance) {
const schema = credentialsSchema[channelType] ?? [];
const missing = schema.filter((f) => f.required && !credsValues[f.key]);
@@ -95,12 +134,9 @@ export function ChannelInstanceFormDialog({
}
}
// Build clean config (strip undefined/empty values)
const cleanConfig = Object.fromEntries(
Object.entries(configValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
// Build credentials JSON from field values (only non-empty values)
const cleanCreds = Object.fromEntries(
Object.entries(credsValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
@@ -116,12 +152,22 @@ export function ChannelInstanceFormDialog({
config: Object.keys(cleanConfig).length > 0 ? cleanConfig : undefined,
enabled,
};
// Only send credentials if user entered something
if (Object.keys(cleanCreds).length > 0) {
data.credentials = cleanCreds;
if (Object.keys(cleanCreds).length > 0) data.credentials = cleanCreds;
const result = await onSubmit(data);
if (hasWizard && wizard) {
const res = result as Record<string, unknown> | undefined;
const firstStep = wizard.steps[0];
if (typeof res?.id === "string" && firstStep) {
setCreatedInstanceId(res.id);
setStep(firstStep);
} else {
onOpenChange(false);
}
} else {
onOpenChange(false);
}
await onSubmit(data);
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to save");
} finally {
@@ -129,141 +175,192 @@ export function ChannelInstanceFormDialog({
}
};
const handleConfigDone = async () => {
if (!createdInstanceId || !onUpdate) { onOpenChange(false); return; }
const cleanConfig = Object.fromEntries(
Object.entries(configValues).filter(([, v]) => v !== undefined && v !== "" && v !== null),
);
setLoading(true);
setError("");
try {
await onUpdate(createdInstanceId, { config: cleanConfig });
onOpenChange(false);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to save configuration");
} finally {
setLoading(false);
}
};
const handleSkipAuth = () => {
const next = getNextWizardStep("auth");
if (next) setStep(next);
else onOpenChange(false);
};
const canClose = step !== "auth";
const credsFields = credentialsSchema[channelType] ?? [];
const excludeSet = new Set(wizard?.excludeConfigFields ?? []);
const cfgFields = configSchema[channelType] ?? [];
const formCfgFields = excludeSet.size > 0 ? cfgFields.filter((f) => !excludeSet.has(f.key)) : cfgFields;
// Lookup registered step components for current channel type
const AuthStep = wizardAuthSteps[channelType];
const ConfigStep = wizardConfigSteps[channelType];
const EditConfig = wizardEditConfigs[channelType];
const dialogTitle = instance
? "Edit Channel Instance"
: step === "form"
? "Create Channel Instance"
: step === "auth"
? `Authenticate — ${channelLabel}`
: `Configure — ${channelLabel}`;
return (
<Dialog open={open} onOpenChange={(v) => !loading && onOpenChange(v)}>
<Dialog open={open} onOpenChange={(v) => { if (!loading && canClose) onOpenChange(v); }}>
<DialogContent className="max-h-[85vh] max-w-lg flex flex-col">
<DialogHeader>
<DialogTitle>
{instance ? "Edit Channel Instance" : "Create Channel Instance"}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
{hasWizard && (
<p className="text-xs text-muted-foreground">Step {currentStepNum} of {totalSteps}</p>
)}
</DialogHeader>
<div className="grid gap-4 py-2 overflow-y-auto min-h-0">
{/* Basic fields */}
<div className="grid gap-1.5">
<Label htmlFor="ci-name">Name *</Label>
<Input
id="ci-name"
value={name}
onChange={(e) => setName(slugify(e.target.value))}
placeholder="my-telegram-bot"
disabled={!!instance}
/>
<p className="text-xs text-muted-foreground">
Unique slug used as channel identifier
</p>
</div>
{/* === FORM STEP === */}
{step === "form" && (
<>
<div className="grid gap-4 py-2 overflow-y-auto min-h-0">
<div className="grid gap-1.5">
<Label htmlFor="ci-name">Name *</Label>
<Input id="ci-name" value={name} onChange={(e) => setName(slugify(e.target.value))} placeholder="my-telegram-bot" disabled={!!instance} />
<p className="text-xs text-muted-foreground">Unique slug used as channel identifier</p>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ci-display">Display Name</Label>
<Input
id="ci-display"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Sales Bot"
/>
</div>
<div className="grid gap-1.5">
<Label htmlFor="ci-display">Display Name</Label>
<Input id="ci-display" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Sales Bot" />
</div>
<div className="grid gap-1.5">
<Label>Channel Type *</Label>
<Select value={channelType} onValueChange={setChannelType} disabled={!!instance}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHANNEL_TYPES.map((ct) => (
<SelectItem key={ct.value} value={ct.value}>
{ct.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>Channel Type *</Label>
<Select value={channelType} onValueChange={setChannelType} disabled={!!instance}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{CHANNEL_TYPES.map((ct) => (
<SelectItem key={ct.value} value={ct.value}>{ct.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>Agent *</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger>
<SelectValue placeholder="Select agent" />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-1.5">
<Label>Agent *</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger><SelectValue placeholder="Select agent" /></SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.display_name || a.agent_key}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Credentials section */}
{credsFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">
Credentials
{instance && (
<span className="text-xs font-normal text-muted-foreground ml-1">
(leave blank to keep current)
</span>
)}
</legend>
<ChannelFields
fields={credsFields}
values={credsValues}
onChange={handleCredsChange}
idPrefix="ci-cred"
isEdit={!!instance}
{credsFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">
Credentials
{instance && <span className="text-xs font-normal text-muted-foreground ml-1">(leave blank to keep current)</span>}
</legend>
<ChannelFields fields={credsFields} values={credsValues} onChange={handleCredsChange} idPrefix="ci-cred" isEdit={!!instance} />
<p className="text-xs text-muted-foreground">Encrypted server-side. Never returned in API responses.</p>
</fieldset>
)}
{/* Auth status indicator (edit mode, channels with auth wizard step) */}
{instance && wizard?.steps.includes("auth") && (
<div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950 p-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${instance.has_credentials ? "bg-green-500" : "bg-amber-500"}`} />
<span className="text-sm">{instance.has_credentials ? "Authenticated" : "Not authenticated"}</span>
{!instance.has_credentials && (
<span className="text-xs text-muted-foreground ml-1">— Use the QR login button from the channels table</span>
)}
</div>
</div>
)}
{/* Wizard info banner (create mode) */}
{hasWizard && wizard?.formBanner && (
<div className="rounded-md border border-blue-200 bg-blue-50 dark:border-blue-900 dark:bg-blue-950 p-3">
<p className="text-sm text-muted-foreground">{wizard.formBanner}</p>
</div>
)}
{formCfgFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">Configuration</legend>
<ChannelFields fields={formCfgFields} values={configValues} onChange={handleConfigChange} idPrefix="ci-cfg" />
{instance && EditConfig && <EditConfig instance={instance} configValues={configValues} onConfigChange={handleConfigChange} />}
</fieldset>
)}
{/* Telegram group/topic overrides */}
{channelType === "telegram" && (
<TelegramGroupOverrides
groups={(configValues.groups as Record<string, Record<string, unknown>>) ?? {}}
onChange={(groups) => {
setConfigValues((prev) => ({
...prev,
groups: Object.keys(groups).length > 0 ? groups : undefined,
}));
}}
/>
)}
<div className="flex items-center gap-2">
<Switch id="ci-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="ci-enabled">Enabled</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? "Saving..." : instance ? "Update" : wizard?.createLabel ?? "Create"}
</Button>
</DialogFooter>
</>
)}
{/* === AUTH STEP (rendered by registered component) === */}
{step === "auth" && createdInstanceId && AuthStep && (
<AuthStep
instanceId={createdInstanceId}
onComplete={() => setAuthCompleted(true)}
onSkip={handleSkipAuth}
/>
)}
{/* === CONFIG STEP (rendered by registered component) === */}
{step === "config" && createdInstanceId && ConfigStep && (
<>
<div className="py-2 overflow-y-auto min-h-0">
<ConfigStep
instanceId={createdInstanceId}
authCompleted={authCompleted}
configValues={configValues}
onConfigChange={handleConfigChange}
/>
<p className="text-xs text-muted-foreground">
Encrypted server-side. Never returned in API responses.
</p>
</fieldset>
)}
{/* Config section */}
{cfgFields.length > 0 && (
<fieldset className="rounded-md border p-3 space-y-3">
<legend className="px-1 text-sm font-medium">Configuration</legend>
<ChannelFields
fields={cfgFields}
values={configValues}
onChange={handleConfigChange}
idPrefix="ci-cfg"
/>
</fieldset>
)}
{/* Telegram group/topic overrides */}
{channelType === "telegram" && (
<TelegramGroupOverrides
groups={(configValues.groups as Record<string, Record<string, unknown>>) ?? {}}
onChange={(groups) => {
setConfigValues((prev) => ({
...prev,
groups: Object.keys(groups).length > 0 ? groups : undefined,
}));
}}
/>
)}
<div className="flex items-center gap-2">
<Switch id="ci-enabled" checked={enabled} onCheckedChange={setEnabled} />
<Label htmlFor="ci-enabled">Enabled</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={loading}>
{loading ? "Saving..." : instance ? "Update" : "Create"}
</Button>
</DialogFooter>
{error && <p className="text-sm text-destructive mt-2">{error}</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>Skip</Button>
<Button onClick={handleConfigDone} disabled={loading}>{loading ? "Saving..." : "Done"}</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
@@ -48,6 +48,7 @@ export const credentialsSchema: Record<string, FieldDef[]> = {
{ key: "token", label: "OA Access Token", type: "password", required: true },
{ key: "webhook_secret", label: "Webhook Secret", type: "password" },
],
zalo_personal: [],
whatsapp: [
{ key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "http://bridge:3000" },
],
@@ -97,9 +98,39 @@ export const configSchema: Record<string, FieldDef[]> = {
{ key: "media_max_mb", label: "Max Media Size (MB)", type: "number", defaultValue: 5 },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Zalo user IDs" },
],
zalo_personal: [
{ key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "allowlist" },
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "allowlist" },
{ key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "Zalo user IDs or group IDs" },
],
whatsapp: [
{ key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "open" },
{ key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "open" },
{ key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" },
],
};
// --- Post-create wizard configuration ---
// Channels with multi-step create flows (e.g. auth then config).
// Channels not listed here use the default single-step create.
export interface WizardConfig {
/** Post-create step sequence */
steps: ("auth" | "config")[];
/** Custom label for the create button */
createLabel?: string;
/** Info banner shown on the form step during create */
formBanner?: string;
/** Config field keys excluded from form step (handled in wizard config step) */
excludeConfigFields?: string[];
}
export const wizardConfig: Partial<Record<string, WizardConfig>> = {
zalo_personal: {
steps: ["auth", "config"],
createLabel: "Create & Authenticate",
formBanner: "After creating, you'll authenticate via QR code and configure allowed users.",
excludeConfigFields: ["allow_from"],
},
};
@@ -0,0 +1,72 @@
// Channel wizard step component registry.
// To add wizard steps for a new channel type:
// 1. Create a directory: channels/{channel-name}/
// 2. Implement components matching the interfaces below
// 3. Register them in the maps at the bottom of this file
// 4. Add WizardConfig entry in channel-schemas.ts
import type { ComponentType } from "react";
import type { ChannelInstanceData } from "./hooks/use-channel-instances";
// --- Standard wizard step interfaces ---
/** Auth step rendered inside the wizard dialog after instance creation */
export interface WizardAuthStepProps {
instanceId: string;
/** Called when authentication completes successfully */
onComplete: () => void;
/** Called when user clicks "Skip" to bypass auth */
onSkip: () => void;
}
/** Config step rendered inside the wizard dialog after auth (or skip) */
export interface WizardConfigStepProps {
instanceId: string;
/** Whether the preceding auth step completed successfully */
authCompleted: boolean;
configValues: Record<string, unknown>;
onConfigChange: (key: string, value: unknown) => void;
}
/** Inline config widget rendered inside the form during edit mode */
export interface WizardEditConfigProps {
instance: ChannelInstanceData;
configValues: Record<string, unknown>;
onConfigChange: (key: string, value: unknown) => void;
}
/** Standalone re-auth dialog rendered from the channels table */
export interface StandaloneAuthDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
instanceId: string;
instanceName: string;
onSuccess: () => void;
}
// --- Channel step imports (add new channels here) ---
import { ZaloAuthStep, ZaloConfigStep, ZaloEditConfig } from "./zalo/zalo-wizard-steps";
import { ZaloPersonalQRDialog } from "./zalo/zalo-personal-qr-dialog";
// --- Component registries ---
export const wizardAuthSteps: Record<string, ComponentType<WizardAuthStepProps>> = {
zalo_personal: ZaloAuthStep,
};
export const wizardConfigSteps: Record<string, ComponentType<WizardConfigStepProps>> = {
zalo_personal: ZaloConfigStep,
};
export const wizardEditConfigs: Record<string, ComponentType<WizardEditConfigProps>> = {
zalo_personal: ZaloEditConfig,
};
/** Standalone auth dialogs for re-authentication from the channels table */
export const standaloneAuthDialogs: Record<string, ComponentType<StandaloneAuthDialogProps>> = {
zalo_personal: ZaloPersonalQRDialog,
};
/** Set of channel types that support re-authentication from the table */
export const channelsWithAuth = new Set(Object.keys(standaloneAuthDialogs));
+60 -6
View File
@@ -1,6 +1,6 @@
import { useState, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { Radio, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Radio, Plus, RefreshCw, Pencil, Trash2, QrCode } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/shared/page-header";
@@ -12,6 +12,7 @@ import { ConfirmDialog } from "@/components/shared/confirm-dialog";
import { useChannels } from "./hooks/use-channels";
import { useChannelInstances, type ChannelInstanceData, type ChannelInstanceInput } from "./hooks/use-channel-instances";
import { ChannelInstanceFormDialog } from "./channel-instance-form-dialog";
import { channelsWithAuth, standaloneAuthDialogs } from "./channel-wizard-registry";
import { ChannelsStatusView, channelTypeLabels } from "./channels-status-view";
import { ChannelDetailPage } from "./channel-detail/channel-detail-page";
import { useAgents } from "@/pages/agents/hooks/use-agents";
@@ -32,6 +33,8 @@ export function ChannelsPage() {
const [formOpen, setFormOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<ChannelInstanceData | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [editInstance, setEditInstance] = useState<ChannelInstanceData | null>(null);
const [qrTarget, setQrTarget] = useState<ChannelInstanceData | null>(null);
const pendingSearchRef = useRef("");
const flushSearch = useDebouncedCallback(() => {
@@ -47,7 +50,7 @@ export function ChannelsPage() {
const {
instances, total, loading: instancesLoading, supported,
refresh: refreshInstances, createInstance, deleteInstance,
refresh: refreshInstances, createInstance, updateInstance, deleteInstance,
} = useChannelInstances({
search: debouncedSearch || undefined,
limit: pageSize,
@@ -76,7 +79,16 @@ export function ChannelsPage() {
}
const handleCreate = async (data: ChannelInstanceInput) => {
await createInstance(data);
return await createInstance(data);
};
const handleEdit = async (data: ChannelInstanceInput) => {
if (!editInstance) return;
await updateInstance(editInstance.id, data);
};
const handleUpdate = async (id: string, data: Partial<ChannelInstanceInput>) => {
await updateInstance(id, data);
};
const handleDelete = async () => {
@@ -196,6 +208,23 @@ export function ChannelsPage() {
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{channelsWithAuth.has(inst.channel_type) && (
<Button
variant="ghost"
size="sm"
title={status?.running ? "Re-authenticate" : "Authenticate to start channel"}
onClick={(e) => { e.stopPropagation(); setQrTarget(inst); }}
>
<QrCode className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); setEditInstance(inst); setFormOpen(true); }}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{!inst.is_default && (
<Button
variant="ghost"
@@ -227,10 +256,17 @@ export function ChannelsPage() {
<ChannelInstanceFormDialog
open={formOpen}
onOpenChange={setFormOpen}
instance={null}
onOpenChange={(open) => {
setFormOpen(open);
if (!open) {
setEditInstance(null);
setTimeout(() => refresh(), 1500);
}
}}
instance={editInstance}
agents={agents}
onSubmit={handleCreate}
onSubmit={editInstance ? handleEdit : handleCreate}
onUpdate={handleUpdate}
/>
<ConfirmDialog
@@ -243,6 +279,24 @@ export function ChannelsPage() {
onConfirm={handleDelete}
loading={deleteLoading}
/>
{qrTarget && (() => {
const AuthDialog = standaloneAuthDialogs[qrTarget.channel_type];
return AuthDialog ? (
<AuthDialog
open={!!qrTarget}
onOpenChange={(v) => !v && setQrTarget(null)}
instanceId={qrTarget.id}
instanceName={qrTarget.display_name || qrTarget.name}
onSuccess={() => {
setQrTarget(null);
// Backend reload is async (~2-3s: stop → sleep → restart).
// Refresh after reload has time to complete.
setTimeout(() => refresh(), 3000);
}}
/>
) : null;
})()}
</div>
);
}
@@ -12,6 +12,7 @@ const channelTypeLabels: Record<string, string> = {
discord: "Discord",
feishu: "Feishu / Lark",
zalo_oa: "Zalo OA",
zalo_personal: "Zalo Personal",
whatsapp: "WhatsApp",
};
@@ -0,0 +1,62 @@
import { useState, useCallback } from "react";
import { useWsCall } from "@/hooks/use-ws-call";
import { useWsEvent } from "@/hooks/use-ws-event";
export type QrStatus = "idle" | "waiting" | "done" | "error";
export function useZaloQrLogin(instanceId: string | null) {
const [qrPng, setQrPng] = useState<string | null>(null);
const [status, setStatus] = useState<QrStatus>("idle");
const [errorMsg, setErrorMsg] = useState("");
const { call: startQR, loading } = useWsCall("zalo.personal.qr.start");
const start = useCallback(async () => {
if (!instanceId) return;
setStatus("waiting");
setQrPng(null);
setErrorMsg("");
try {
await startQR({ instance_id: instanceId });
} catch (err) {
setStatus("error");
setErrorMsg(err instanceof Error ? err.message : "Failed to start QR session");
}
}, [startQR, instanceId]);
const reset = useCallback(() => {
setStatus("idle");
setQrPng(null);
setErrorMsg("");
}, []);
useWsEvent(
"zalo.personal.qr.code",
useCallback(
(payload: unknown) => {
const p = payload as { instance_id: string; png_b64: string };
if (p.instance_id !== instanceId) return;
setQrPng(p.png_b64);
},
[instanceId],
),
);
useWsEvent(
"zalo.personal.qr.done",
useCallback(
(payload: unknown) => {
const p = payload as { instance_id: string; success: boolean; error?: string };
if (p.instance_id !== instanceId) return;
if (p.success) {
setStatus("done");
} else {
setStatus("error");
setErrorMsg(p.error ?? "QR login failed");
}
},
[instanceId],
),
);
return { qrPng, status, errorMsg, loading, start, reset, retry: start };
}
@@ -0,0 +1,185 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { useWsCall } from "@/hooks/use-ws-call";
interface Friend {
userId: string;
displayName: string;
zaloName?: string;
avatar?: string;
}
interface Group {
groupId: string;
name: string;
avatar?: string;
totalMember: number;
}
interface ContactsResult {
friends: Friend[];
groups: Group[];
}
interface ZaloContactsPickerProps {
instanceId: string;
hasCredentials: boolean;
value: string[];
onChange: (ids: string[]) => void;
}
export function ZaloContactsPicker({ instanceId, hasCredentials, value, onChange }: ZaloContactsPickerProps) {
const [contacts, setContacts] = useState<ContactsResult | null>(null);
const [search, setSearch] = useState("");
const [manualId, setManualId] = useState("");
const { loading, error, call: fetchContacts } = useWsCall<ContactsResult>("zalo.personal.contacts");
const autoLoaded = useRef(false);
// Auto-load contacts when credentials are available (resolve names for badges)
useEffect(() => {
if (hasCredentials && !autoLoaded.current) {
autoLoaded.current = true;
fetchContacts({ instance_id: instanceId }).then(setContacts).catch(() => {});
}
}, [hasCredentials, instanceId, fetchContacts]);
const handleLoad = async () => {
try {
const result = await fetchContacts({ instance_id: instanceId });
setContacts(result);
} catch {
// error state handled by useWsCall
}
};
const toggle = (id: string) => {
if (value.includes(id)) {
onChange(value.filter((v) => v !== id));
} else {
onChange([...value, id]);
}
};
const addManual = () => {
const trimmed = manualId.trim();
if (trimmed && !value.includes(trimmed)) {
onChange([...value, trimmed]);
setManualId("");
}
};
const resolveName = (id: string): string => {
const friend = contacts?.friends.find((f) => f.userId === id);
if (friend) return friend.displayName;
const group = contacts?.groups.find((g) => g.groupId === id);
if (group) return group.name;
return id;
};
if (!hasCredentials) {
return (
<div className="grid gap-1.5">
<Label>Allowed Users</Label>
<p className="text-sm text-muted-foreground">Complete QR login to load contacts</p>
</div>
);
}
const lowerSearch = search.toLowerCase();
const filteredFriends = contacts?.friends.filter(
(f) => f.displayName.toLowerCase().includes(lowerSearch) || f.userId.includes(search),
) ?? [];
const filteredGroups = contacts?.groups.filter(
(g) => g.name.toLowerCase().includes(lowerSearch) || g.groupId.includes(search),
) ?? [];
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Label>Allowed Users</Label>
{!contacts && (
<Button type="button" variant="outline" size="sm" onClick={handleLoad} disabled={loading}>
{loading ? "Loading..." : "Load Contacts"}
</Button>
)}
</div>
{error && <p className="text-sm text-destructive">{error.message}</p>}
{/* Selected tags */}
{value.length > 0 && (
<div className="flex flex-wrap gap-1">
{value.map((id) => (
<Badge key={id} variant="secondary" className="gap-1">
{resolveName(id)}
<button type="button" onClick={() => toggle(id)} className="ml-1 text-xs hover:text-destructive">
×
</button>
</Badge>
))}
</div>
)}
{/* Contact list (after loading) */}
{contacts && (
<>
<Input
placeholder="Search contacts..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8"
/>
<div className="max-h-48 overflow-y-auto rounded border p-2 space-y-1">
{filteredFriends.length > 0 && (
<>
<p className="text-xs font-medium text-muted-foreground">Friends</p>
{filteredFriends.map((f) => (
<label key={f.userId} className="flex items-center gap-2 py-0.5 text-sm cursor-pointer hover:bg-muted/50 rounded px-1">
<input type="checkbox" checked={value.includes(f.userId)} onChange={() => toggle(f.userId)} />
<span>{f.displayName}</span>
<span className="text-xs text-muted-foreground ml-auto">{f.userId}</span>
</label>
))}
</>
)}
{filteredGroups.length > 0 && (
<>
<p className="text-xs font-medium text-muted-foreground mt-2">Groups</p>
{filteredGroups.map((g) => (
<label key={g.groupId} className="flex items-center gap-2 py-0.5 text-sm cursor-pointer hover:bg-muted/50 rounded px-1">
<input type="checkbox" checked={value.includes(g.groupId)} onChange={() => toggle(g.groupId)} />
<span>{g.name}</span>
<span className="text-xs text-muted-foreground ml-auto">{g.totalMember} members</span>
</label>
))}
</>
)}
{filteredFriends.length === 0 && filteredGroups.length === 0 && (
<p className="text-sm text-muted-foreground py-2 text-center">
{search ? `No contacts match "${search}"` : "No contacts found"}
</p>
)}
</div>
</>
)}
{/* Manual ID entry */}
<div className="flex gap-2">
<Input
placeholder="Add ID manually"
value={manualId}
onChange={(e) => setManualId(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addManual(); } }}
className="h-8"
/>
<Button type="button" variant="outline" size="sm" onClick={addManual} disabled={!manualId.trim()}>
Add
</Button>
</div>
<p className="text-xs text-muted-foreground">Zalo user IDs or group IDs</p>
</div>
);
}
@@ -0,0 +1,84 @@
import { useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useZaloQrLogin } from "./use-zalo-qr-login";
interface ZaloPersonalQRDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
instanceId: string;
instanceName: string;
onSuccess: () => void;
}
export function ZaloPersonalQRDialog({
open,
onOpenChange,
instanceId,
instanceName,
onSuccess,
}: ZaloPersonalQRDialogProps) {
const { qrPng, status, errorMsg, loading, start, reset } = useZaloQrLogin(instanceId);
// Auto-start when dialog opens
useEffect(() => {
if (open && status === "idle") start();
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset state when dialog closes
useEffect(() => {
if (!open) reset();
}, [open, reset]);
// Auto-close on success
useEffect(() => {
if (status !== "done") return;
onSuccess();
const id = setTimeout(() => onOpenChange(false), 1500);
return () => clearTimeout(id);
}, [status, onSuccess, onOpenChange]);
return (
<Dialog open={open} onOpenChange={(v) => { if (!loading) onOpenChange(v); }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Login with QR — {instanceName}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
{status === "done" && (
<p className="text-sm text-green-600 font-medium">Login successful! Channel starting...</p>
)}
{status === "error" && (
<p className="text-sm text-destructive">{errorMsg}</p>
)}
{qrPng && status === "waiting" && (
<img
src={`data:image/png;base64,${qrPng}`}
alt="Zalo QR Code"
className="w-48 h-48 border rounded"
/>
)}
{status === "waiting" && !qrPng && (
<p className="text-sm text-muted-foreground">Generating QR code...</p>
)}
{status === "waiting" && qrPng && (
<p className="text-xs text-muted-foreground">Scan with your Zalo app (expires in ~100s)</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Close</Button>
{status === "error" && (
<Button onClick={start} disabled={loading}>Retry</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,65 @@
// Zalo Personal wizard step components for the channel create wizard.
// Registered in channel-wizard-registry.tsx.
import { useEffect } from "react";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { useZaloQrLogin } from "./use-zalo-qr-login";
import { ZaloContactsPicker } from "./zalo-contacts-picker";
import type { WizardAuthStepProps, WizardConfigStepProps, WizardEditConfigProps } from "../channel-wizard-registry";
/** QR code authentication step for Zalo Personal */
export function ZaloAuthStep({ instanceId, onComplete, onSkip }: WizardAuthStepProps) {
const { qrPng, status, errorMsg, loading, start, retry, reset } = useZaloQrLogin(instanceId);
// Auto-start QR on mount
useEffect(() => {
start();
return () => reset();
}, [start, reset]);
// Signal completion to parent
useEffect(() => {
if (status === "done") onComplete();
}, [status, onComplete]);
return (
<>
<div className="flex flex-col items-center gap-4 py-4 min-h-0">
{status === "done" && <p className="text-sm text-green-600 font-medium">Login successful! Loading contacts...</p>}
{status === "error" && <p className="text-sm text-destructive">{errorMsg}</p>}
{qrPng && status === "waiting" && <img src={`data:image/png;base64,${qrPng}`} alt="Zalo QR Code" className="w-48 h-48 border rounded" />}
{status === "waiting" && !qrPng && <p className="text-sm text-muted-foreground">Generating QR code...</p>}
{status === "waiting" && qrPng && <p className="text-xs text-muted-foreground">Scan with your Zalo app (expires in ~100s)</p>}
</div>
<DialogFooter>
<Button variant="outline" onClick={onSkip} disabled={loading}>Skip</Button>
{status === "error" && <Button onClick={retry} disabled={loading}>Retry</Button>}
</DialogFooter>
</>
);
}
/** Contacts picker step for configuring allowed users */
export function ZaloConfigStep({ instanceId, authCompleted, configValues, onConfigChange }: WizardConfigStepProps) {
return (
<ZaloContactsPicker
instanceId={instanceId}
hasCredentials={authCompleted}
value={(configValues.allow_from as string[]) ?? []}
onChange={(ids) => onConfigChange("allow_from", ids.length > 0 ? ids : undefined)}
/>
);
}
/** Inline contacts picker widget for edit mode */
export function ZaloEditConfig({ instance, configValues, onConfigChange }: WizardEditConfigProps) {
return (
<ZaloContactsPicker
instanceId={instance.id}
hasCredentials={instance.has_credentials}
value={(configValues.allow_from as string[]) ?? []}
onChange={(ids) => onConfigChange("allow_from", ids.length > 0 ? ids : undefined)}
/>
);
}
+1
View File
@@ -7,6 +7,7 @@ export interface ChannelInstanceData {
config: Record<string, unknown> | null;
enabled: boolean;
is_default: boolean;
has_credentials: boolean;
created_by: string;
created_at: string;
updated_at: string;