返回 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 if !validAccountID(accountID) {
50 return ""
51 }
52 return filepath.Join(weixinAccountDir(root), accountID+".json")
53 }
54
55 func loadSavedAccount(accountID string) (savedAccount, error) {
56 path := savedAccountPath(accountID)
57 if path == "" {
58 return savedAccount{}, fmt.Errorf("reasonix user config dir is unavailable")
59 }
60 data, err := fileencoding.ReadFileUTF8(path)
61 if err != nil {
62 return savedAccount{}, err
63 }
64 var account savedAccount
65 if err := json.Unmarshal(data, &account); err != nil {
66 return savedAccount{}, err
67 }
68 return account, nil
69 }
70
71 func loadAnySavedAccount() (savedAccount, error) {
72 root := config.MemoryUserDir()
73 if root == "" {
74 return savedAccount{}, fmt.Errorf("reasonix user config dir is unavailable")
75 }
76 entries, err := os.ReadDir(weixinAccountDir(root))
77 if err != nil {
78 return savedAccount{}, err
79 }
80 for _, entry := range entries {
81 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") || strings.Contains(entry.Name(), "context-tokens") {
82 continue
83 }
84 accountID := strings.TrimSuffix(entry.Name(), ".json")
85 account, err := loadSavedAccount(accountID)
86 if err == nil && account.Token != "" {
87 return account, nil
88 }
89 }
90 return savedAccount{}, fmt.Errorf("no saved weixin account")
91 }
92
93 func HasSavedAccount(accountID string) bool {
94 if accountID != "" {
95 account, err := loadSavedAccount(accountID)
96 return err == nil && account.Token != ""
97 }
98 account, err := loadSavedAccount("default")
99 if err == nil && account.Token != "" {
100 return true
101 }
102 account, err = loadAnySavedAccount()
103 return err == nil && account.Token != ""
104 }
105
106 // validAccountID keeps an id from the upstream login response to one file name.
107 // validAccountID accepts one file name. Either separator is refused on every
108 // OS: the config directory can be read from a system where the other one is.
109 func validAccountID(accountID string) bool {
110 return !strings.ContainsAny(accountID, `/\`) && filepath.IsLocal(accountID) && filepath.Base(accountID) == accountID
111 }
112
113 func saveAccount(accountID string, account savedAccount) error {
114 if !validAccountID(accountID) {
115 return fmt.Errorf("weixin account id %q is not a valid file name", accountID)
116 }
117 path := savedAccountPath(accountID)
118 if path == "" {
119 return fmt.Errorf("reasonix user config dir is unavailable")
120 }
121 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
122 return err
123 }
124 data, err := json.MarshalIndent(account, "", " ")
125 if err != nil {
126 return err
127 }
128 // Atomic write: a truncated credentials file silently breaks login.
129 return fileutil.AtomicWriteFile(path, data, 0o600)
130 }
131
132 func Login(ctx context.Context, out io.Writer, timeout time.Duration) (*LoginResult, error) {
133 if timeout <= 0 {
134 timeout = 8 * time.Minute
135 }
136 session, err := StartLogin(ctx)
137 if err != nil {
138 return nil, err
139 }
140 if out != nil {
141 fmt.Fprintln(out, "请使用微信扫描以下二维码链接:")
142 if session.QRCodeURL != "" {
143 fmt.Fprintln(out, session.QRCodeURL)
144 } else {
145 fmt.Fprintln(out, session.QRCode)
146 }
147 }
148
149 deadline := time.Now().Add(timeout)
150 for time.Now().Before(deadline) {
151 select {
152 case <-ctx.Done():
153 return nil, ctx.Err()
154 case <-time.After(time.Second):
155 }
156 result, status, err := PollLogin(ctx, session)
157 if err != nil {
158 if out != nil {
159 fmt.Fprintf(out, "二维码状态查询失败: %v\n", err)
160 }
161 continue
162 }
163 if result != nil {
164 return result, nil
165 }
166 if out != nil {
167 switch status {
168 case "wait", "", "<nil>":
169 fmt.Fprint(out, ".")
170 case "scaned":
171 fmt.Fprintln(out, "\n已扫码,请在微信里确认...")
172 default:
173 fmt.Fprintf(out, "\n二维码状态: %s\n", status)
174 }
175 }
176 }
177 return nil, fmt.Errorf("weixin login timed out")
178 }
179
180 func StartLogin(ctx context.Context) (*LoginSession, error) {
181 qrResp, err := ilinkGET(ctx, defaultWeixinAPI, getBotQRPath+"?bot_type=3")
182 if err != nil {
183 return nil, fmt.Errorf("fetch qr code: %w", err)
184 }
185 qrcode := fmt.Sprint(qrResp["qrcode"])
186 qrcodeURL := fmt.Sprint(qrResp["qrcode_img_content"])
187 if qrcode == "" || qrcode == "<nil>" {
188 return nil, fmt.Errorf("weixin qr response missing qrcode")
189 }
190 if qrcodeURL == "<nil>" {
191 qrcodeURL = ""
192 }
193 return &LoginSession{
194 SessionKey: qrcode,
195 QRCode: qrcode,
196 QRCodeURL: qrcodeURL,
197 BaseURL: defaultWeixinAPI,
198 StartedAt: time.Now(),
199 }, nil
200 }
201
202 func PollLogin(ctx context.Context, session *LoginSession) (*LoginResult, string, error) {
203 if session == nil || session.QRCode == "" {
204 return nil, "", fmt.Errorf("weixin login session is missing")
205 }
206 baseURL := session.BaseURL
207 if baseURL == "" {
208 baseURL = defaultWeixinAPI
209 }
210 statusResp, err := ilinkGET(ctx, baseURL, getQRStatusPath+"?qrcode="+session.QRCode)
211 if err != nil {
212 return nil, "", err
213 }
214 status := fmt.Sprint(statusResp["status"])
215 switch status {
216 case "wait", "", "<nil>":
217 return nil, status, nil
218 case "scaned":
219 return nil, status, nil
220 case "scaned_but_redirect":
221 if host := fmt.Sprint(statusResp["redirect_host"]); host != "" && host != "<nil>" {
222 session.BaseURL = "https://" + host
223 }
224 return nil, status, nil
225 case "confirmed":
226 accountID := fmt.Sprint(statusResp["ilink_bot_id"])
227 token := fmt.Sprint(statusResp["bot_token"])
228 userID := fmt.Sprint(statusResp["ilink_user_id"])
229 respBaseURL := fmt.Sprint(statusResp["baseurl"])
230 if respBaseURL == "" || respBaseURL == "<nil>" {
231 respBaseURL = defaultWeixinAPI
232 }
233 if accountID == "" || accountID == "<nil>" || token == "" || token == "<nil>" {
234 return nil, status, fmt.Errorf("weixin qr confirmed but credential payload is incomplete")
235 }
236 account := savedAccount{
237 Token: token,
238 BaseURL: respBaseURL,
239 UserID: userID,
240 SavedAt: time.Now().UTC().Format(time.RFC3339),
241 }
242 if err := saveAccount(accountID, account); err != nil {
243 return nil, status, err
244 }
245 if err := saveAccount("default", account); err != nil {
246 return nil, status, err
247 }
248 return &LoginResult{AccountID: accountID, Token: token, BaseURL: respBaseURL, UserID: userID}, status, nil
249 case "expired":
250 return nil, status, fmt.Errorf("weixin qr code expired; rerun login")
251 default:
252 return nil, status, nil
253 }
254 }
255
255 lines GO