mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 12:14:17 +00:00
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:
1 parent
de284733f0
commit
0f5dd08f76
45 files changed
+5762
-167
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms)
|
||||
}
|
||||
|
||||
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, ¶ms)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -133,4 +133,8 @@ const (
|
||||
MethodBrowserScreenshot = "browser.screenshot"
|
||||
|
||||
MethodHeartbeat = "heartbeat"
|
||||
|
||||
// Zalo Personal (managed mode)
|
||||
MethodZaloPersonalQRStart = "zalo.personal.qr.start"
|
||||
MethodZaloPersonalContacts = "zalo.personal.contacts"
|
||||
)
|
||||
@@ -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));
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user