mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 03:27:00 +08:00
- 将 WechatExplorer 产品身份升级为 TraceMemo - 更新 appId、Runtime Identity、Reader Skill 和 API 环境变量 - 增加旧用户数据、Knowledge、Token 与 AI Provider 安全迁移 - 保留 Windows WeFlow 和旧版配置兼容 - 完善首次启动迁移测试及 v2.2.0 发布文档 - 移除 macOS Intel x64 构建与发布支持
237 lines
5.6 KiB
Go
237 lines
5.6 KiB
Go
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
|
|
}
|
|
}
|
|
}
|
|
|
|
func accountsDir(rootName string) (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(home, rootName, "wechat-connector", "accounts"), nil
|
|
}
|
|
|
|
// AccountsDir returns the TraceMemo directory where new credentials are stored.
|
|
func AccountsDir() (string, error) {
|
|
return accountsDir(".tracememo")
|
|
}
|
|
|
|
// LegacyAccountsDir is read-only compatibility for v2.1.9 and earlier.
|
|
func LegacyAccountsDir() (string, error) {
|
|
return accountsDir(".wechatexplorer")
|
|
}
|
|
|
|
func accountDirectoryForID(accountID string) (string, error) {
|
|
current, err := AccountsDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := os.Stat(filepath.Join(current, accountID+".json")); err == nil {
|
|
return current, nil
|
|
}
|
|
legacy, err := LegacyAccountsDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := os.Stat(filepath.Join(legacy, accountID+".json")); err == nil {
|
|
return legacy, nil
|
|
}
|
|
return current, 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
|
|
}
|
|
|
|
func loadCredentialsFromDir(dir string) ([]*Credentials, error) {
|
|
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
|
|
}
|
|
|
|
// LoadAllCredentials loads TraceMemo credentials first and falls back to the
|
|
// untouched WechatExplorer directory for one-version upgrade compatibility.
|
|
func LoadAllCredentials() ([]*Credentials, error) {
|
|
current, err := AccountsDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
credentials, err := loadCredentialsFromDir(current)
|
|
if err != nil || len(credentials) > 0 {
|
|
return credentials, err
|
|
}
|
|
legacy, err := LegacyAccountsDir()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return loadCredentialsFromDir(legacy)
|
|
}
|
|
|
|
// CredentialsPath returns the path for display purposes.
|
|
func CredentialsPath() (string, error) {
|
|
return AccountsDir()
|
|
}
|