返回 DeepSeek-Reasonix
weixin_login.go
根目录 / internal / bot / weixin / weixin_login.go
1 package weixin
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "strings"
11 "time"
12
13 "reasonix/internal/config"
14 "reasonix/internal/fileutil"
15 fileencoding "reasonix/internal/fileutil/encoding"
16 )
17
18 type savedAccount struct {
19 Token string `json:"token"`
20 BaseURL string `json:"base_url"`
21 UserID string `json:"user_id"`
22 SavedAt string `json:"saved_at"`
23 }
24
25 type LoginResult struct {
26 AccountID string
27 Token string
28 BaseURL string
29 UserID string
30 }
31
32 type LoginSession struct {
33 SessionKey string
34 QRCode string
35 QRCodeURL string
36 BaseURL string
37 StartedAt time.Time
38 }
39
40 func weixinAccountDir(root string) string {
41 return filepath.Join(root, "weixin", "accounts")
42 }
43
44 func savedAccountPath(accountID string) string {
45 root := config.MemoryUserDir()
46 if root == "" || accountID == "" {
47 return ""
48 }
49 return filepath.Join(weixinAccountDir(root), accountID+".json")
50 }
51
52 func loadSavedAccount(accountID string) (savedAccount, error) {
53 path := savedAccountPath(accountID)
54 if path == "" {
55 return savedAccount{}, fmt.Errorf("reasonix user config dir is unavailable")
56 }
57 data, err := fileencoding.ReadFileUTF8(path)
58 if err != nil {
59 return savedAccount{}, err
60 }
61 var account savedAccount
62 if err := json.Unmarshal(data, &account); err != nil {
63 return savedAccount{}, err
64 }
65 return account, nil
66 }
67
68 func loadAnySavedAccount() (savedAccount, error) {
69 root := config.MemoryUserDir()
70 if root == "" {
71 return savedAccount{}, fmt.Errorf("reasonix user config dir is unavailable")
72 }
73 entries, err := os.ReadDir(weixinAccountDir(root))
74 if err != nil {
75 return savedAccount{}, err
76 }
77 for _, entry := range entries {
78 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") || strings.Contains(entry.Name(), "context-tokens") {
79 continue
80 }
81 accountID := strings.TrimSuffix(entry.Name(), ".json")
82 account, err := loadSavedAccount(accountID)
83 if err == nil && account.Token != "" {
84 return account, nil
85 }
86 }
87 return savedAccount{}, fmt.Errorf("no saved weixin account")
88 }
89
90 func HasSavedAccount(accountID string) bool {
91 if accountID != "" {
92 account, err := loadSavedAccount(accountID)
93 return err == nil && account.Token != ""
94 }
95 account, err := loadSavedAccount("default")
96 if err == nil && account.Token != "" {
97 return true
98 }
99 account, err = loadAnySavedAccount()
100 return err == nil && account.Token != ""
101 }
102
103 func saveAccount(accountID string, account savedAccount) error {
104 path := savedAccountPath(accountID)
105 if path == "" {
106 return fmt.Errorf("reasonix user config dir is unavailable")
107 }
108 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
109 return err
110 }
111 data, err := json.MarshalIndent(account, "", " ")
112 if err != nil {
113 return err
114 }
115 // Atomic write: a truncated credentials file silently breaks login.
116 return fileutil.AtomicWriteFile(path, data, 0o600)
117 }
118
119 func Login(ctx context.Context, out io.Writer, timeout time.Duration) (*LoginResult, error) {
120 if timeout <= 0 {
121 timeout = 8 * time.Minute
122 }
123 session, err := StartLogin(ctx)
124 if err != nil {
125 return nil, err
126 }
127 if out != nil {
128 fmt.Fprintln(out, "请使用微信扫描以下二维码链接:")
129 if session.QRCodeURL != "" {
130 fmt.Fprintln(out, session.QRCodeURL)
131 } else {
132 fmt.Fprintln(out, session.QRCode)
133 }
134 }
135
136 deadline := time.Now().Add(timeout)
137 for time.Now().Before(deadline) {
138 select {
139 case <-ctx.Done():
140 return nil, ctx.Err()
141 case <-time.After(time.Second):
142 }
143 result, status, err := PollLogin(ctx, session)
144 if err != nil {
145 if out != nil {
146 fmt.Fprintf(out, "二维码状态查询失败: %v\n", err)
147 }
148 continue
149 }
150 if result != nil {
151 return result, nil
152 }
153 if out != nil {
154 switch status {
155 case "wait", "", "<nil>":
156 fmt.Fprint(out, ".")
157 case "scaned":
158 fmt.Fprintln(out, "\n已扫码,请在微信里确认...")
159 default:
160 fmt.Fprintf(out, "\n二维码状态: %s\n", status)
161 }
162 }
163 }
164 return nil, fmt.Errorf("weixin login timed out")
165 }
166
167 func StartLogin(ctx context.Context) (*LoginSession, error) {
168 qrResp, err := ilinkGET(ctx, defaultWeixinAPI, getBotQRPath+"?bot_type=3")
169 if err != nil {
170 return nil, fmt.Errorf("fetch qr code: %w", err)
171 }
172 qrcode := fmt.Sprint(qrResp["qrcode"])
173 qrcodeURL := fmt.Sprint(qrResp["qrcode_img_content"])
174 if qrcode == "" || qrcode == "<nil>" {
175 return nil, fmt.Errorf("weixin qr response missing qrcode")
176 }
177 if qrcodeURL == "<nil>" {
178 qrcodeURL = ""
179 }
180 return &LoginSession{
181 SessionKey: qrcode,
182 QRCode: qrcode,
183 QRCodeURL: qrcodeURL,
184 BaseURL: defaultWeixinAPI,
185 StartedAt: time.Now(),
186 }, nil
187 }
188
189 func PollLogin(ctx context.Context, session *LoginSession) (*LoginResult, string, error) {
190 if session == nil || session.QRCode == "" {
191 return nil, "", fmt.Errorf("weixin login session is missing")
192 }
193 baseURL := session.BaseURL
194 if baseURL == "" {
195 baseURL = defaultWeixinAPI
196 }
197 statusResp, err := ilinkGET(ctx, baseURL, getQRStatusPath+"?qrcode="+session.QRCode)
198 if err != nil {
199 return nil, "", err
200 }
201 status := fmt.Sprint(statusResp["status"])
202 switch status {
203 case "wait", "", "<nil>":
204 return nil, status, nil
205 case "scaned":
206 return nil, status, nil
207 case "scaned_but_redirect":
208 if host := fmt.Sprint(statusResp["redirect_host"]); host != "" && host != "<nil>" {
209 session.BaseURL = "https://" + host
210 }
211 return nil, status, nil
212 case "confirmed":
213 accountID := fmt.Sprint(statusResp["ilink_bot_id"])
214 token := fmt.Sprint(statusResp["bot_token"])
215 userID := fmt.Sprint(statusResp["ilink_user_id"])
216 respBaseURL := fmt.Sprint(statusResp["baseurl"])
217 if respBaseURL == "" || respBaseURL == "<nil>" {
218 respBaseURL = defaultWeixinAPI
219 }
220 if accountID == "" || accountID == "<nil>" || token == "" || token == "<nil>" {
221 return nil, status, fmt.Errorf("weixin qr confirmed but credential payload is incomplete")
222 }
223 account := savedAccount{
224 Token: token,
225 BaseURL: respBaseURL,
226 UserID: userID,
227 SavedAt: time.Now().UTC().Format(time.RFC3339),
228 }
229 if err := saveAccount(accountID, account); err != nil {
230 return nil, status, err
231 }
232 if err := saveAccount("default", account); err != nil {
233 return nil, status, err
234 }
235 return &LoginResult{AccountID: accountID, Token: token, BaseURL: respBaseURL, UserID: userID}, status, nil
236 case "expired":
237 return nil, status, fmt.Errorf("weixin qr code expired; rerun login")
238 default:
239 return nil, status, nil
240 }
241 }
242
242 lines GO