package ilink import ( "context" "encoding/json" "fmt" "os" "path/filepath" "strings" "time" ) const ( qrCodeURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_bot_qrcode?bot_type=3" qrStatusURL = "https://ilinkai.weixin.qq.com/ilink/bot/get_qrcode_status?qrcode=" statusWait = "wait" statusScanned = "scaned" statusConfirmed = "confirmed" statusExpired = "expired" ) // FetchQRCode retrieves a new QR code for login. func FetchQRCode(ctx context.Context) (*QRCodeResponse, error) { c := NewUnauthenticatedClient() var resp QRCodeResponse if err := c.doGet(ctx, qrCodeURL, &resp); err != nil { return nil, fmt.Errorf("fetch QR code: %w", err) } return &resp, nil } // PollQRStatus polls for QR code scan status until confirmed or expired. // It calls onStatus for each status change so the caller can display progress. func PollQRStatus(ctx context.Context, qrcode string, onStatus func(status string)) (*Credentials, error) { c := NewUnauthenticatedClient() url := qrStatusURL + qrcode for { select { case <-ctx.Done(): return nil, ctx.Err() default: } pollCtx, cancel := context.WithTimeout(ctx, 40*time.Second) var resp QRStatusResponse err := c.doGet(pollCtx, url, &resp) cancel() if err != nil { // Timeout is normal for long-poll, retry if ctx.Err() != nil { return nil, ctx.Err() } continue } if onStatus != nil { onStatus(resp.Status) } switch resp.Status { case statusConfirmed: creds := &Credentials{ BotToken: resp.BotToken, ILinkBotID: resp.ILinkBotID, BaseURL: resp.BaseURL, ILinkUserID: resp.ILinkUserID, } return creds, nil case statusExpired: return nil, fmt.Errorf("QR code expired") case statusWait, statusScanned: // Continue polling default: // Unknown status, continue } } } // AccountsDir returns the directory where account credentials are stored. func AccountsDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".wechatexplorer", "wechat-connector", "accounts"), nil } // NormalizeAccountID converts raw bot ID to filesystem-safe format. func NormalizeAccountID(raw string) string { s := raw for _, ch := range []string{"@", ".", ":"} { s = filepath.Clean(s) s = replaceAll(s, ch, "-") } return s } func replaceAll(s, old, new string) string { for { i := indexOf(s, old) if i < 0 { return s } s = s[:i] + new + s[i+len(old):] } } func indexOf(s, sub string) int { for i := range s { if i+len(sub) <= len(s) && s[i:i+len(sub)] == sub { return i } } return -1 } // SaveCredentials saves the latest credentials and removes older accounts. // The new credential is written first so a failed login never destroys the // previously working credential. func SaveCredentials(creds *Credentials) error { dir, err := AccountsDir() if err != nil { return err } if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("create accounts dir: %w", err) } id := NormalizeAccountID(creds.ILinkBotID) path := filepath.Join(dir, id+".json") data, err := json.MarshalIndent(creds, "", " ") if err != nil { return fmt.Errorf("marshal credentials: %w", err) } if err := os.WriteFile(path, data, 0o600); err != nil { return fmt.Errorf("write credentials: %w", err) } entries, err := os.ReadDir(dir) if err != nil { return fmt.Errorf("prune old credentials: %w", err) } keepPrefix := id + "." for _, entry := range entries { if entry.IsDir() || strings.HasPrefix(entry.Name(), keepPrefix) { continue } if filepath.Ext(entry.Name()) != ".json" { continue } if err := os.Remove(filepath.Join(dir, entry.Name())); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove old credential %s: %w", entry.Name(), err) } } return nil } // LoadAllCredentials loads all saved account credentials. func LoadAllCredentials() ([]*Credentials, error) { dir, err := AccountsDir() if err != nil { return nil, err } entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("read accounts dir: %w", err) } var result []*Credentials for _, e := range entries { if e.IsDir() || filepath.Ext(e.Name()) != ".json" { continue } data, err := os.ReadFile(filepath.Join(dir, e.Name())) if err != nil { continue } var creds Credentials if json.Unmarshal(data, &creds) == nil && creds.BotToken != "" { result = append(result, &creds) } } return result, nil } // CredentialsPath returns the path for display purposes. func CredentialsPath() (string, error) { return AccountsDir() }