diff --git a/Makefile b/Makefile index 82d4925b..e94688a3 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/cmd/gateway.go b/cmd/gateway.go index 17d9ef84..c9f37dd8 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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) diff --git a/cmd/gateway_consumer.go b/cmd/gateway_consumer.go index 05faf553..803624f9 100644 --- a/cmd/gateway_consumer.go +++ b/cmd/gateway_consumer.go @@ -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 } diff --git a/go.mod b/go.mod index 0486b8c3..ef9a3873 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index d7e0dce5..40002042 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -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 { diff --git a/internal/channels/zalo/personal/auth.go b/internal/channels/zalo/personal/auth.go new file mode 100644 index 00000000..5eb76084 --- /dev/null +++ b/internal/channels/zalo/personal/auth.go @@ -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) +} diff --git a/internal/channels/zalo/personal/channel.go b/internal/channels/zalo/personal/channel.go new file mode 100644 index 00000000..6bc41308 --- /dev/null +++ b/internal/channels/zalo/personal/channel.go @@ -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<= 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 +} diff --git a/internal/channels/zalo/personal/factory.go b/internal/channels/zalo/personal/factory.go new file mode 100644 index 00000000..3cc73ef5 --- /dev/null +++ b/internal/channels/zalo/personal/factory.go @@ -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 +} diff --git a/internal/channels/zalo/personal/policy.go b/internal/channels/zalo/personal/policy.go new file mode 100644 index 00000000..636b61ea --- /dev/null +++ b/internal/channels/zalo/personal/policy.go @@ -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 +} diff --git a/internal/channels/zalo/personal/protocol/auth.go b/internal/channels/zalo/personal/protocol/auth.go new file mode 100644 index 00000000..ba186474 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/auth.go @@ -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) +} diff --git a/internal/channels/zalo/personal/protocol/client.go b/internal/channels/zalo/personal/protocol/client.go new file mode 100644 index 00000000..8dfe8276 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/client.go @@ -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 +} diff --git a/internal/channels/zalo/personal/protocol/client_test.go b/internal/channels/zalo/personal/protocol/client_test.go new file mode 100644 index 00000000..4580a785 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/client_test.go @@ -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") + } +} diff --git a/internal/channels/zalo/personal/protocol/config.go b/internal/channels/zalo/personal/protocol/config.go new file mode 100644 index 00000000..1dc9e032 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/config.go @@ -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 +} diff --git a/internal/channels/zalo/personal/protocol/config_test.go b/internal/channels/zalo/personal/protocol/config_test.go new file mode 100644 index 00000000..72035165 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/config_test.go @@ -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") + } +} diff --git a/internal/channels/zalo/personal/protocol/contacts.go b/internal/channels/zalo/personal/protocol/contacts.go new file mode 100644 index 00000000..06b08c91 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/contacts.go @@ -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":""} + 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 +} diff --git a/internal/channels/zalo/personal/protocol/crypto.go b/internal/channels/zalo/personal/protocol/crypto.go new file mode 100644 index 00000000..ecc04115 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/crypto.go @@ -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 +} diff --git a/internal/channels/zalo/personal/protocol/crypto_test.go b/internal/channels/zalo/personal/protocol/crypto_test.go new file mode 100644 index 00000000..9b35fbc5 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/crypto_test.go @@ -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") + } +} diff --git a/internal/channels/zalo/personal/protocol/listener.go b/internal/channels/zalo/personal/protocol/listener.go new file mode 100644 index 00000000..f86d630c --- /dev/null +++ b/internal/channels/zalo/personal/protocol/listener.go @@ -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) + } + } +} + diff --git a/internal/channels/zalo/personal/protocol/listener_handlers.go b/internal/channels/zalo/personal/protocol/listener_handlers.go new file mode 100644 index 00000000..786f0028 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/listener_handlers.go @@ -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: + } + } +} diff --git a/internal/channels/zalo/personal/protocol/message.go b/internal/channels/zalo/personal/protocol/message.go new file mode 100644 index 00000000..2bb8b43d --- /dev/null +++ b/internal/channels/zalo/personal/protocol/message.go @@ -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 } diff --git a/internal/channels/zalo/personal/protocol/message_test.go b/internal/channels/zalo/personal/protocol/message_test.go new file mode 100644 index 00000000..d0232e9e --- /dev/null +++ b/internal/channels/zalo/personal/protocol/message_test.go @@ -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) + } +} diff --git a/internal/channels/zalo/personal/protocol/models.go b/internal/channels/zalo/personal/protocol/models.go new file mode 100644 index 00000000..52874305 --- /dev/null +++ b/internal/channels/zalo/personal/protocol/models.go @@ -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"` +} diff --git a/internal/channels/zalo/personal/protocol/models_test.go b/internal/channels/zalo/personal/protocol/models_test.go new file mode 100644 index 00000000..242bb43e --- /dev/null +++ b/internal/channels/zalo/personal/protocol/models_test.go @@ -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") + } +} diff --git a/internal/channels/zalo/personal/protocol/send.go b/internal/channels/zalo/personal/protocol/send.go new file mode 100644 index 00000000..0a507f2f --- /dev/null +++ b/internal/channels/zalo/personal/protocol/send.go @@ -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":""} + 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) +} diff --git a/internal/channels/zalo/personal/protocol/ws_client.go b/internal/channels/zalo/personal/protocol/ws_client.go new file mode 100644 index 00000000..aee8d0ca --- /dev/null +++ b/internal/channels/zalo/personal/protocol/ws_client.go @@ -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()} +} diff --git a/internal/channels/zalo/personal/zalomethods/contacts.go b/internal/channels/zalo/personal/zalomethods/contacts.go new file mode 100644 index 00000000..c0690c80 --- /dev/null +++ b/internal/channels/zalo/personal/zalomethods/contacts.go @@ -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, + })) +} diff --git a/internal/channels/zalo/personal/zalomethods/qr.go b/internal/channels/zalo/personal/zalomethods/qr.go new file mode 100644 index 00000000..37284bbe --- /dev/null +++ b/internal/channels/zalo/personal/zalomethods/qr.go @@ -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) +} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 579db3b8..79b508a9 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -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"` diff --git a/internal/gateway/methods/channel_instances.go b/internal/gateway/methods/channel_instances.go index 4203f9e9..68aef8e7 100644 --- a/internal/gateway/methods/channel_instances.go +++ b/internal/gateway/methods/channel_instances.go @@ -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 diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index a419bf2c..96e78220 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -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 { diff --git a/internal/tools/web_fetch.go b/internal/tools/web_fetch.go index 571efb95..2e5eb2c7 100644 --- a/internal/tools/web_fetch.go +++ b/internal/tools/web_fetch.go @@ -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 diff --git a/internal/tools/web_shared.go b/internal/tools/web_shared.go index b1ddda91..122afc07 100644 --- a/internal/tools/web_shared.go +++ b/internal/tools/web_shared.go @@ -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) diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index 97251c23..76d58d0d 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -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) diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 4a10a039..63ad86f5 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -133,4 +133,8 @@ const ( MethodBrowserScreenshot = "browser.screenshot" MethodHeartbeat = "heartbeat" + + // Zalo Personal (managed mode) + MethodZaloPersonalQRStart = "zalo.personal.qr.start" + MethodZaloPersonalContacts = "zalo.personal.contacts" ) diff --git a/ui/web/src/constants/channels.ts b/ui/web/src/constants/channels.ts index 510cfb7e..50a8bff8 100644 --- a/ui/web/src/constants/channels.ts +++ b/ui/web/src/constants/channels.ts @@ -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; diff --git a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx index 79f4ec38..6e8a4557 100644 --- a/ui/web/src/pages/channels/channel-instance-form-dialog.tsx +++ b/ui/web/src/pages/channels/channel-instance-form-dialog.tsx @@ -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; + onUpdate?: (id: string, data: Partial) => Promise; } 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("form"); + const [createdInstanceId, setCreatedInstanceId] = useState(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 = {}; + 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 | 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 ( - !loading && onOpenChange(v)}> + { if (!loading && canClose) onOpenChange(v); }}> - - {instance ? "Edit Channel Instance" : "Create Channel Instance"} - + {dialogTitle} + {hasWizard && ( +

Step {currentStepNum} of {totalSteps}

+ )}
-
- {/* Basic fields */} -
- - setName(slugify(e.target.value))} - placeholder="my-telegram-bot" - disabled={!!instance} - /> -

- Unique slug used as channel identifier -

-
+ {/* === FORM STEP === */} + {step === "form" && ( + <> +
+
+ + setName(slugify(e.target.value))} placeholder="my-telegram-bot" disabled={!!instance} /> +

Unique slug used as channel identifier

+
-
- - setDisplayName(e.target.value)} - placeholder="Sales Bot" - /> -
+
+ + setDisplayName(e.target.value)} placeholder="Sales Bot" /> +
-
- - -
+
+ + +
-
- - -
+
+ + +
- {/* Credentials section */} - {credsFields.length > 0 && ( -
- - Credentials - {instance && ( - - (leave blank to keep current) - - )} - - 0 && ( +
+ + Credentials + {instance && (leave blank to keep current)} + + +

Encrypted server-side. Never returned in API responses.

+
+ )} + + {/* Auth status indicator (edit mode, channels with auth wizard step) */} + {instance && wizard?.steps.includes("auth") && ( +
+
+ + {instance.has_credentials ? "Authenticated" : "Not authenticated"} + {!instance.has_credentials && ( + — Use the QR login button from the channels table + )} +
+
+ )} + + {/* Wizard info banner (create mode) */} + {hasWizard && wizard?.formBanner && ( +
+

{wizard.formBanner}

+
+ )} + + {formCfgFields.length > 0 && ( +
+ Configuration + + {instance && EditConfig && } +
+ )} + + {/* Telegram group/topic overrides */} + {channelType === "telegram" && ( + >) ?? {}} + onChange={(groups) => { + setConfigValues((prev) => ({ + ...prev, + groups: Object.keys(groups).length > 0 ? groups : undefined, + })); + }} + /> + )} + +
+ + +
+ {error &&

{error}

} +
+ + + + + + + )} + + {/* === AUTH STEP (rendered by registered component) === */} + {step === "auth" && createdInstanceId && AuthStep && ( + setAuthCompleted(true)} + onSkip={handleSkipAuth} + /> + )} + + {/* === CONFIG STEP (rendered by registered component) === */} + {step === "config" && createdInstanceId && ConfigStep && ( + <> +
+ -

- Encrypted server-side. Never returned in API responses. -

- - )} - - {/* Config section */} - {cfgFields.length > 0 && ( -
- Configuration - -
- )} - - {/* Telegram group/topic overrides */} - {channelType === "telegram" && ( - >) ?? {}} - onChange={(groups) => { - setConfigValues((prev) => ({ - ...prev, - groups: Object.keys(groups).length > 0 ? groups : undefined, - })); - }} - /> - )} - -
- - -
- {error &&

{error}

} -
- - - - - + {error &&

{error}

} +
+ + + + + + )}
); diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index 235a7693..562e05e2 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -48,6 +48,7 @@ export const credentialsSchema: Record = { { 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 = { { 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> = { + zalo_personal: { + steps: ["auth", "config"], + createLabel: "Create & Authenticate", + formBanner: "After creating, you'll authenticate via QR code and configure allowed users.", + excludeConfigFields: ["allow_from"], + }, +}; diff --git a/ui/web/src/pages/channels/channel-wizard-registry.tsx b/ui/web/src/pages/channels/channel-wizard-registry.tsx new file mode 100644 index 00000000..0842a687 --- /dev/null +++ b/ui/web/src/pages/channels/channel-wizard-registry.tsx @@ -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; + onConfigChange: (key: string, value: unknown) => void; +} + +/** Inline config widget rendered inside the form during edit mode */ +export interface WizardEditConfigProps { + instance: ChannelInstanceData; + configValues: Record; + 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> = { + zalo_personal: ZaloAuthStep, +}; + +export const wizardConfigSteps: Record> = { + zalo_personal: ZaloConfigStep, +}; + +export const wizardEditConfigs: Record> = { + zalo_personal: ZaloEditConfig, +}; + +/** Standalone auth dialogs for re-authentication from the channels table */ +export const standaloneAuthDialogs: Record> = { + zalo_personal: ZaloPersonalQRDialog, +}; + +/** Set of channel types that support re-authentication from the table */ +export const channelsWithAuth = new Set(Object.keys(standaloneAuthDialogs)); diff --git a/ui/web/src/pages/channels/channels-page.tsx b/ui/web/src/pages/channels/channels-page.tsx index 3eb26541..7f07ee48 100644 --- a/ui/web/src/pages/channels/channels-page.tsx +++ b/ui/web/src/pages/channels/channels-page.tsx @@ -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(null); const [deleteLoading, setDeleteLoading] = useState(false); + const [editInstance, setEditInstance] = useState(null); + const [qrTarget, setQrTarget] = useState(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) => { + await updateInstance(id, data); }; const handleDelete = async () => { @@ -196,6 +208,23 @@ export function ChannelsPage() {
+ {channelsWithAuth.has(inst.channel_type) && ( + + )} + {!inst.is_default && (
); } diff --git a/ui/web/src/pages/channels/channels-status-view.tsx b/ui/web/src/pages/channels/channels-status-view.tsx index 0f13381d..edeea48c 100644 --- a/ui/web/src/pages/channels/channels-status-view.tsx +++ b/ui/web/src/pages/channels/channels-status-view.tsx @@ -12,6 +12,7 @@ const channelTypeLabels: Record = { discord: "Discord", feishu: "Feishu / Lark", zalo_oa: "Zalo OA", + zalo_personal: "Zalo Personal", whatsapp: "WhatsApp", }; diff --git a/ui/web/src/pages/channels/zalo/use-zalo-qr-login.ts b/ui/web/src/pages/channels/zalo/use-zalo-qr-login.ts new file mode 100644 index 00000000..8e7c736a --- /dev/null +++ b/ui/web/src/pages/channels/zalo/use-zalo-qr-login.ts @@ -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(null); + const [status, setStatus] = useState("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 }; +} diff --git a/ui/web/src/pages/channels/zalo/zalo-contacts-picker.tsx b/ui/web/src/pages/channels/zalo/zalo-contacts-picker.tsx new file mode 100644 index 00000000..151e954e --- /dev/null +++ b/ui/web/src/pages/channels/zalo/zalo-contacts-picker.tsx @@ -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(null); + const [search, setSearch] = useState(""); + const [manualId, setManualId] = useState(""); + const { loading, error, call: fetchContacts } = useWsCall("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 ( +
+ +

Complete QR login to load contacts

+
+ ); + } + + 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 ( +
+
+ + {!contacts && ( + + )} +
+ + {error &&

{error.message}

} + + {/* Selected tags */} + {value.length > 0 && ( +
+ {value.map((id) => ( + + {resolveName(id)} + + + ))} +
+ )} + + {/* Contact list (after loading) */} + {contacts && ( + <> + setSearch(e.target.value)} + className="h-8" + /> +
+ {filteredFriends.length > 0 && ( + <> +

Friends

+ {filteredFriends.map((f) => ( + + ))} + + )} + {filteredGroups.length > 0 && ( + <> +

Groups

+ {filteredGroups.map((g) => ( + + ))} + + )} + {filteredFriends.length === 0 && filteredGroups.length === 0 && ( +

+ {search ? `No contacts match "${search}"` : "No contacts found"} +

+ )} +
+ + )} + + {/* Manual ID entry */} +
+ setManualId(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addManual(); } }} + className="h-8" + /> + +
+

Zalo user IDs or group IDs

+
+ ); +} diff --git a/ui/web/src/pages/channels/zalo/zalo-personal-qr-dialog.tsx b/ui/web/src/pages/channels/zalo/zalo-personal-qr-dialog.tsx new file mode 100644 index 00000000..b522b3f7 --- /dev/null +++ b/ui/web/src/pages/channels/zalo/zalo-personal-qr-dialog.tsx @@ -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 ( + { if (!loading) onOpenChange(v); }}> + + + Login with QR — {instanceName} + + +
+ {status === "done" && ( +

Login successful! Channel starting...

+ )} + {status === "error" && ( +

{errorMsg}

+ )} + {qrPng && status === "waiting" && ( + Zalo QR Code + )} + {status === "waiting" && !qrPng && ( +

Generating QR code...

+ )} + {status === "waiting" && qrPng && ( +

Scan with your Zalo app (expires in ~100s)

+ )} +
+ +
+ + {status === "error" && ( + + )} +
+
+
+ ); +} diff --git a/ui/web/src/pages/channels/zalo/zalo-wizard-steps.tsx b/ui/web/src/pages/channels/zalo/zalo-wizard-steps.tsx new file mode 100644 index 00000000..90114b8f --- /dev/null +++ b/ui/web/src/pages/channels/zalo/zalo-wizard-steps.tsx @@ -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 ( + <> +
+ {status === "done" &&

Login successful! Loading contacts...

} + {status === "error" &&

{errorMsg}

} + {qrPng && status === "waiting" && Zalo QR Code} + {status === "waiting" && !qrPng &&

Generating QR code...

} + {status === "waiting" && qrPng &&

Scan with your Zalo app (expires in ~100s)

} +
+ + + {status === "error" && } + + + ); +} + +/** Contacts picker step for configuring allowed users */ +export function ZaloConfigStep({ instanceId, authCompleted, configValues, onConfigChange }: WizardConfigStepProps) { + return ( + onConfigChange("allow_from", ids.length > 0 ? ids : undefined)} + /> + ); +} + +/** Inline contacts picker widget for edit mode */ +export function ZaloEditConfig({ instance, configValues, onConfigChange }: WizardEditConfigProps) { + return ( + onConfigChange("allow_from", ids.length > 0 ? ids : undefined)} + /> + ); +} diff --git a/ui/web/src/types/channel.ts b/ui/web/src/types/channel.ts index fccb7b20..5e346ec7 100644 --- a/ui/web/src/types/channel.ts +++ b/ui/web/src/types/channel.ts @@ -7,6 +7,7 @@ export interface ChannelInstanceData { config: Record | null; enabled: boolean; is_default: boolean; + has_credentials: boolean; created_by: string; created_at: string; updated_at: string;