magic-agent 2.6.0 → 2.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CLIENT-CONTRACT.md +188 -16
  2. package/client-ui/pc/app.css +42 -0
  3. package/client-ui/pc/js/90-glue.js +3 -0
  4. package/client-ui/shared/conv/03-chat-message.js +25 -19
  5. package/client-ui/shared/conv/09-stream-apply.js +9 -0
  6. package/client-ui/shared/conv/29-msg-variants.js +136 -0
  7. package/client-ui/shared/conv/conv.css +253 -158
  8. package/client-ui/shared/conversation.js +39 -2
  9. package/client-ui-mobile/apps.css +16 -0
  10. package/client-ui-mobile/apps.js +406 -0
  11. package/client-ui-mobile/index.html +2695 -379
  12. package/client-ui-mobile/reqs.css +11 -4
  13. package/client-ui-mobile/reqs.js +49 -5
  14. package/client-ui-mobile/scripts.css +46 -0
  15. package/client-ui-mobile/scripts.js +66 -6
  16. package/cmd/magic-client/browsectl.go +1 -1
  17. package/cmd/magic-client/clientctl.go +37 -2
  18. package/cmd/magic-client/main.go +159 -12
  19. package/internal/agent/dsh_caps_cache_test.go +184 -0
  20. package/internal/agent/dsh_resume.go +144 -5
  21. package/internal/agent/env.go +173 -1
  22. package/internal/agent/env_test.go +114 -0
  23. package/internal/agent/limitnotice.go +207 -1
  24. package/internal/agent/limitnotice_test.go +140 -0
  25. package/internal/agent/stream.go +30 -0
  26. package/internal/agent/streamjson.go +11 -1
  27. package/internal/cli/ask.go +8 -0
  28. package/internal/cli/engines_test.go +103 -0
  29. package/internal/cli/events.go +11 -0
  30. package/internal/cli/events_test.go +50 -0
  31. package/internal/cli/judge.go +104 -0
  32. package/internal/cli/root.go +35 -6
  33. package/internal/cli/session.go +69 -3
  34. package/internal/cli/session_test.go +82 -3
  35. package/internal/cli/sessionlog.go +6 -0
  36. package/internal/cli/sessionlog_test.go +22 -2
  37. package/internal/client/apk.go +8 -0
  38. package/internal/client/apk_test.go +59 -0
  39. package/internal/client/apps.go +1078 -0
  40. package/internal/client/apps_test.go +516 -0
  41. package/internal/client/coreexe.go +196 -0
  42. package/internal/client/coreexe_test.go +161 -0
  43. package/internal/client/coreupgrade.go +866 -0
  44. package/internal/client/coreupgrade_test.go +561 -0
  45. package/internal/client/daemon.go +209 -18
  46. package/internal/client/feishu.go +1259 -0
  47. package/internal/client/feishu_test.go +649 -0
  48. package/internal/client/health_identity_test.go +84 -0
  49. package/internal/client/hostenv.go +68 -0
  50. package/internal/client/hostenv_test.go +37 -0
  51. package/internal/client/jobs.go +233 -12
  52. package/internal/client/jobs_auto.go +571 -0
  53. package/internal/client/jobs_auto_test.go +431 -0
  54. package/internal/client/plugins.go +5 -1
  55. package/internal/client/prefs.go +67 -0
  56. package/internal/client/procstart_linux.go +59 -0
  57. package/internal/client/publicurl.go +82 -0
  58. package/internal/client/requirements.go +9 -1
  59. package/internal/client/requirements_test.go +103 -0
  60. package/internal/client/restart.go +167 -13
  61. package/internal/client/restart_handoff_test.go +230 -0
  62. package/internal/client/restart_test.go +140 -1
  63. package/internal/client/scripts_decode_implausible.go +38 -0
  64. package/internal/client/scripts_decode_other.go +4 -0
  65. package/internal/client/scripts_decode_test.go +16 -11
  66. package/internal/client/scripts_decode_windows.go +0 -24
  67. package/internal/client/scripts_test.go +14 -1
  68. package/internal/client/service.go +403 -87
  69. package/internal/client/service_test.go +166 -1
  70. package/internal/client/sessions_cache.go +148 -0
  71. package/internal/client/sessions_cache_test.go +163 -0
  72. package/internal/client/sessions_fold.go +165 -0
  73. package/internal/client/sessions_fold_test.go +204 -0
  74. package/internal/client/singleinstance.go +470 -0
  75. package/internal/client/singleinstance_test.go +607 -0
  76. package/internal/client/testmain_test.go +88 -0
  77. package/internal/client/workspace_default_test.go +158 -0
  78. package/internal/client/workspacelockenv_test.go +54 -0
  79. package/internal/config/config.go +48 -0
  80. package/internal/judge/judge.go +315 -0
  81. package/internal/session/multiclient_test.go +368 -0
  82. package/internal/session/session.go +83 -11
  83. package/internal/session/session_test.go +7 -0
  84. package/internal/session/status.go +21 -0
  85. package/internal/session/status_test.go +28 -0
  86. package/internal/session/title.go +16 -0
  87. package/internal/session/title_test.go +26 -0
  88. package/internal/session/workspace_lock.go +265 -28
  89. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  90. package/npm/apk/version.json +8 -1
  91. package/npm/dist/darwin-arm64/magic-agent +0 -0
  92. package/npm/dist/darwin-arm64/magic-client +0 -0
  93. package/npm/dist/darwin-x64/magic-agent +0 -0
  94. package/npm/dist/darwin-x64/magic-client +0 -0
  95. package/npm/dist/linux-arm64/magic-agent +0 -0
  96. package/npm/dist/linux-arm64/magic-client +0 -0
  97. package/npm/dist/linux-x64/magic-agent +0 -0
  98. package/npm/dist/linux-x64/magic-client +0 -0
  99. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  100. package/npm/dist/win32-x64/magic-client.exe +0 -0
  101. package/npm/install.js +11 -0
  102. package/package.json +8 -3
@@ -0,0 +1,649 @@
1
+ package client
2
+
3
+ /* 飞书推送的单测:**完全不碰外网**,用 httptest 冒充 open.feishu.cn 的两个接口。
4
+ *
5
+ * 钉住的几件事(每条都对应一个真出过 / 很会出的坑):
6
+ * ① 状态接口**永不回显 app_secret**(它落在一个手机能访问的接口上)。
7
+ * ② 绑定码:6 位、一次性、过期作废、错码拒绝。
8
+ * ③ 推送幂等:同一 ask_id 连调两次只发一条(手机多端轮询会重入)。
9
+ * ④ 发送失败要重试;最终失败**不吞**,且幂等键释放(下一轮还能推)。
10
+ * ⑤ 租户令牌缓存:两次推送只换一次令牌。
11
+ */
12
+
13
+ import (
14
+ "context"
15
+ "crypto/hmac"
16
+ "crypto/sha256"
17
+ "encoding/base64"
18
+ "encoding/json"
19
+ "io"
20
+ "net/http"
21
+ "net/http/httptest"
22
+ "os"
23
+ "path/filepath"
24
+ "strings"
25
+ "sync"
26
+ "testing"
27
+ "time"
28
+ )
29
+
30
+ // fakeFeishu 冒充飞书开放平台。
31
+ type fakeFeishu struct {
32
+ mu sync.Mutex
33
+ tokenHits int
34
+ sent []map[string]any
35
+ webhookSent []map[string]any
36
+ failNext int // 接下来 N 次发消息回失败(模拟网络抖动 / 频控)
37
+ badToken bool // 令牌接口回凭据错误(测 config 校验)
38
+ }
39
+
40
+ func newFakeFeishu(t *testing.T) (*fakeFeishu, *httptest.Server) {
41
+ f := &fakeFeishu{}
42
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
43
+ f.mu.Lock()
44
+ defer f.mu.Unlock()
45
+ switch r.URL.Path {
46
+ case "/open-apis/auth/v3/tenant_access_token/internal":
47
+ f.tokenHits++
48
+ if f.badToken {
49
+ _ = json.NewEncoder(w).Encode(map[string]any{"code": 10003, "msg": "app_secret invalid"})
50
+ return
51
+ }
52
+ _ = json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "ok", "tenant_access_token": "t-test-token", "expire": 7200})
53
+ case "/desk/health":
54
+ // 验链接用:只有真指向本服务的入口才 200 + ok。测试里把假服务器当入口。
55
+ if r.Header.Get("X-Magic-Token") != "" {
56
+ _, _ = io.WriteString(w, `{"ok":true}`)
57
+ } else {
58
+ w.WriteHeader(http.StatusUnauthorized)
59
+ _, _ = io.WriteString(w, `{"error":"缺少令牌"}`)
60
+ }
61
+ case "/open-apis/bot/v2/hook/fake-token", "/open-apis/bot/v2/hook/x":
62
+ body, _ := io.ReadAll(r.Body)
63
+ var m map[string]any
64
+ _ = json.Unmarshal(body, &m)
65
+ f.webhookSent = append(f.webhookSent, m)
66
+ _, _ = io.WriteString(w, `{"code":0,"msg":"success","data":{}}`)
67
+ case "/open-apis/im/v1/messages":
68
+ if got := r.Header.Get("Authorization"); got != "Bearer t-test-token" {
69
+ w.WriteHeader(http.StatusUnauthorized)
70
+ _, _ = io.WriteString(w, `{"code":99991663,"msg":"invalid token"}`)
71
+ return
72
+ }
73
+ if f.failNext > 0 {
74
+ f.failNext--
75
+ w.WriteHeader(http.StatusTooManyRequests)
76
+ _, _ = io.WriteString(w, `{"code":99991400,"msg":"rate limited"}`)
77
+ return
78
+ }
79
+ body, _ := io.ReadAll(r.Body)
80
+ var m map[string]any
81
+ _ = json.Unmarshal(body, &m)
82
+ m["__receive_id_type"] = r.URL.Query().Get("receive_id_type")
83
+ f.sent = append(f.sent, m)
84
+ _, _ = io.WriteString(w, `{"code":0,"msg":"success","data":{"message_id":"om_1"}}`)
85
+ default:
86
+ w.WriteHeader(http.StatusNotFound)
87
+ }
88
+ }))
89
+ t.Cleanup(srv.Close)
90
+ return f, srv
91
+ }
92
+
93
+ // newFeishuNotifier 一个把状态文件落在 t.TempDir、对接到假飞书的推送器。
94
+ func newFeishuNotifier(t *testing.T, base string) *feishuNotifier {
95
+ t.Helper()
96
+ return &feishuNotifier{
97
+ path: filepath.Join(t.TempDir(), "feishu-notify.json"),
98
+ sent: map[string]time.Time{},
99
+ client: &http.Client{Timeout: 5 * time.Second},
100
+ baseURL: base,
101
+ }
102
+ }
103
+
104
+ // newFeishuService 一个只带飞书推送器的 Service(不跑 New(),也就不需要 core 二进制)。
105
+ func newFeishuService(t *testing.T, base string) (*Service, *feishuNotifier) {
106
+ t.Helper()
107
+ n := newFeishuNotifier(t, base)
108
+ return &Service{feishuN: n, feishuOnce: sync.Once{}}, n
109
+ }
110
+
111
+ func doJSON(t *testing.T, s *Service, method, path string, body any) (int, map[string]any) {
112
+ t.Helper()
113
+ var rd io.Reader
114
+ if body != nil {
115
+ b, _ := json.Marshal(body)
116
+ rd = strings.NewReader(string(b))
117
+ }
118
+ req := httptest.NewRequest(method, path, rd)
119
+ rec := httptest.NewRecorder()
120
+ s.handleFeishu(rec, req)
121
+ var out map[string]any
122
+ _ = json.Unmarshal(rec.Body.Bytes(), &out)
123
+ return rec.Code, out
124
+ }
125
+
126
+ func seedState(t *testing.T, n *feishuNotifier, st feishuState) {
127
+ t.Helper()
128
+ if err := n.save(st); err != nil {
129
+ t.Fatal(err)
130
+ }
131
+ }
132
+
133
+ func TestFeishuStatusNeverLeaksSecret(t *testing.T) {
134
+ _, srv := newFakeFeishu(t)
135
+ s, n := newFeishuService(t, srv.URL)
136
+ seedState(t, n, feishuState{
137
+ AppID: "cli_abc", AppSecret: "super-secret-value",
138
+ OpenID: "ou_1234567890abcdef", Enabled: true, BoundAt: time.Now(),
139
+ })
140
+ code, out := doJSON(t, s, "GET", "/desk/feishu", nil)
141
+ if code != http.StatusOK {
142
+ t.Fatalf("status = %d", code)
143
+ }
144
+ raw, _ := json.Marshal(out)
145
+ if strings.Contains(string(raw), "super-secret-value") {
146
+ t.Fatalf("状态接口回显了 app_secret:%s", raw)
147
+ }
148
+ if out["configured"] != true || out["bound"] != true || out["enabled"] != true {
149
+ t.Fatalf("状态不对:%v", out)
150
+ }
151
+ if tail := out["open_id_tail"]; tail != "abcdef" {
152
+ t.Fatalf("open_id_tail = %v,只该给末 6 位", tail)
153
+ }
154
+ }
155
+
156
+ func TestFeishuBindCodeLifecycle(t *testing.T) {
157
+ _, srv := newFakeFeishu(t)
158
+ s, n := newFeishuService(t, srv.URL)
159
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec"})
160
+
161
+ // 没申请绑定码就绑:拒绝,且说清楚下一步。
162
+ if code, out := doJSON(t, s, "POST", "/desk/feishu/bind", map[string]string{"code": "123456", "open_id": "ou_x"}); code != http.StatusBadRequest {
163
+ t.Fatalf("无码绑定应 400,得到 %d %v", code, out)
164
+ }
165
+ code, out := doJSON(t, s, "POST", "/desk/feishu/code", nil)
166
+ if code != http.StatusOK {
167
+ t.Fatalf("申请绑定码失败:%d %v", code, out)
168
+ }
169
+ bc, _ := out["code"].(string)
170
+ if len(bc) != 6 || strings.Trim(bc, "0123456789") != "" {
171
+ t.Fatalf("绑定码应为 6 位数字,得到 %q", bc)
172
+ }
173
+ // 绑一次成功(并自动推一条「绑定成功」)。
174
+ code, out = doJSON(t, s, "POST", "/desk/feishu/bind", map[string]string{"code": bc, "open_id": "ou_7d8a6e6df7621556ce0d21922b676706"})
175
+ if code != http.StatusOK || out["enabled"] != true {
176
+ t.Fatalf("绑定失败:%d %v", code, out)
177
+ }
178
+ // 同一码再用一次:作废。
179
+ if code, _ := doJSON(t, s, "POST", "/desk/feishu/bind", map[string]string{"code": bc, "open_id": "ou_aaaaaaaabbbbbbbbccccccccdddddddd"}); code != http.StatusBadRequest {
180
+ t.Fatalf("绑定码应当一次性,重复使用得到了 %d", code)
181
+ }
182
+ st, err := n.load()
183
+ if err != nil || st.ReceiveID != "ou_7d8a6e6df7621556ce0d21922b676706" || st.ReceiveType != "open_id" || !st.Enabled || st.Code != "" {
184
+ t.Fatalf("落盘状态不对:%+v err=%v", st, err)
185
+ }
186
+ // 权限:状态文件 0600(里面有 secret)。
187
+ fi, err := os.Stat(n.path)
188
+ if err != nil {
189
+ t.Fatal(err)
190
+ }
191
+ if fi.Mode().Perm()&0o077 != 0 {
192
+ t.Fatalf("状态文件权限过宽:%v", fi.Mode().Perm())
193
+ }
194
+ }
195
+
196
+ func TestFeishuBindCodeExpired(t *testing.T) {
197
+ _, srv := newFakeFeishu(t)
198
+ s, n := newFeishuService(t, srv.URL)
199
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec", Code: "654321", CodeAt: time.Now().Add(-feishuCodeTTL - time.Minute)})
200
+ code, out := doJSON(t, s, "POST", "/desk/feishu/bind", map[string]string{"code": "654321", "open_id": "ou_7d8a6e6df7621556ce0d21922b676706"})
201
+ if code != http.StatusBadRequest || !strings.Contains(string(mustJSON(out)), "过期") {
202
+ t.Fatalf("过期绑定码应 400 并说明过期:%d %v", code, out)
203
+ }
204
+ }
205
+
206
+ func TestFeishuPushIdempotentAndTokenCached(t *testing.T) {
207
+ f, srv := newFakeFeishu(t)
208
+ s, n := newFeishuService(t, srv.URL)
209
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec", OpenID: "ou_me", Enabled: true})
210
+
211
+ code, out := doJSON(t, s, "POST", "/desk/feishu/push", map[string]any{"event": "result", "ask_id": "ask-1", "title": "重构登录"})
212
+ if code != http.StatusOK || out["ok"] != true {
213
+ t.Fatalf("推送失败:%d %v", code, out)
214
+ }
215
+ code, out = doJSON(t, s, "POST", "/desk/feishu/push", map[string]any{"event": "result", "ask_id": "ask-1", "title": "重构登录"})
216
+ if code != http.StatusOK || out["dupe"] != true {
217
+ t.Fatalf("同一 ask_id 应判定为重复:%d %v", code, out)
218
+ }
219
+ // 另一个事件(待确认)是另一条通知,不该被 result 的去重吃掉。
220
+ if _, out = doJSON(t, s, "POST", "/desk/feishu/push", map[string]any{"event": "confirm", "ask_id": "ask-1", "title": "重构登录"}); out["ok"] != true || out["dupe"] != nil {
221
+ t.Fatalf("confirm 应独立推送:%v", out)
222
+ }
223
+ f.mu.Lock()
224
+ defer f.mu.Unlock()
225
+ if len(f.sent) != 2 {
226
+ t.Fatalf("应发出 2 条(result + confirm),实际 %d", len(f.sent))
227
+ }
228
+ if f.tokenHits != 1 {
229
+ t.Fatalf("租户令牌应只换一次(缓存),实际 %d 次", f.tokenHits)
230
+ }
231
+ if f.sent[0]["receive_id"] != "ou_me" || f.sent[0]["msg_type"] != "interactive" {
232
+ t.Fatalf("消息体不对:%v", f.sent[0])
233
+ }
234
+ if c, _ := f.sent[0]["content"].(string); !strings.Contains(c, "重构登录") || !strings.Contains(c, "任务完成") {
235
+ t.Fatalf("卡片里缺标题:%s", c)
236
+ }
237
+ }
238
+
239
+ func TestFeishuPushRetriesThenReleasesKey(t *testing.T) {
240
+ f, srv := newFakeFeishu(t)
241
+ s, n := newFeishuService(t, srv.URL)
242
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec", OpenID: "ou_me", Enabled: true})
243
+
244
+ // 前 3 次都失败 → 这一次推送彻底失败(3 次尝试全用掉)。
245
+ f.failNext = feishuSendAttempts
246
+ code, out := doJSON(t, s, "POST", "/desk/feishu/push", map[string]any{"event": "result", "ask_id": "ask-2", "title": "x"})
247
+ if code != http.StatusBadGateway {
248
+ t.Fatalf("最终失败应回 502,得到 %d %v", code, out)
249
+ }
250
+ // 键必须释放:网络恢复后同一 ask_id 还能再推(否则「失败一次就永远不推」)。
251
+ if code, out := doJSON(t, s, "POST", "/desk/feishu/push", map[string]any{"event": "result", "ask_id": "ask-2", "title": "x"}); code != http.StatusOK || out["ok"] != true {
252
+ t.Fatalf("失败后重推应成功:%d %v", code, out)
253
+ }
254
+ // 最近记录里要能看到那次失败(排障用)。
255
+ st, _ := n.load()
256
+ if len(st.Recent) == 0 || st.Recent[len(st.Recent)-1].OK != true {
257
+ t.Fatalf("推送记录不对:%+v", st.Recent)
258
+ }
259
+ found := false
260
+ for _, r := range st.Recent {
261
+ if !r.OK && r.Attempt == feishuSendAttempts {
262
+ found = true
263
+ }
264
+ }
265
+ if !found {
266
+ t.Fatalf("失败记录没落盘:%+v", st.Recent)
267
+ }
268
+ }
269
+
270
+ func TestFeishuPushDisabledIsNotAnError(t *testing.T) {
271
+ _, srv := newFakeFeishu(t)
272
+ s, n := newFeishuService(t, srv.URL)
273
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec", OpenID: "ou_me", Enabled: false})
274
+ code, out := doJSON(t, s, "POST", "/desk/feishu/push", map[string]any{"event": "result", "ask_id": "a"})
275
+ if code != http.StatusOK || out["disabled"] != true {
276
+ t.Fatalf("关闭推送时应 200 + disabled(不是报错):%d %v", code, out)
277
+ }
278
+ }
279
+
280
+ func TestFeishuConfigValidatesBeforeSaving(t *testing.T) {
281
+ f, srv := newFakeFeishu(t)
282
+ s, n := newFeishuService(t, srv.URL)
283
+
284
+ // 错 secret:先验再存,一个字都不写。
285
+ f.badToken = true
286
+ if code, out := doJSON(t, s, "POST", "/desk/feishu/config", map[string]string{"app_id": "cli_x", "app_secret": "bad"}); code != http.StatusBadRequest {
287
+ t.Fatalf("错凭据应 400:%d %v", code, out)
288
+ }
289
+ if st, _ := n.load(); st.AppID != "" || st.AppSecret != "" {
290
+ t.Fatalf("校验失败不应落盘:%+v", st)
291
+ }
292
+ f.badToken = false
293
+ if code, out := doJSON(t, s, "POST", "/desk/feishu/config", map[string]string{"app_id": "cli_x", "app_secret": "good"}); code != http.StatusOK || out["configured"] != true {
294
+ t.Fatalf("正确凭据应保存:%d %v", code, out)
295
+ }
296
+ // 形如 cli_ 的校验。
297
+ if code, _ := doJSON(t, s, "POST", "/desk/feishu/config", map[string]string{"app_id": "abc", "app_secret": "s"}); code != http.StatusBadRequest {
298
+ t.Fatalf("非 cli_ 前缀应 400,得到 %d", code)
299
+ }
300
+ }
301
+
302
+ func TestFeishuUnknownAction(t *testing.T) {
303
+ _, srv := newFakeFeishu(t)
304
+ s, n := newFeishuService(t, srv.URL)
305
+ seedState(t, n, feishuState{})
306
+ if code, _ := doJSON(t, s, "POST", "/desk/feishu/nope", nil); code != http.StatusNotFound {
307
+ t.Fatalf("未知动作应 404,得到 %d", code)
308
+ }
309
+ // 动作类接口只认 POST:GET /desk/feishu/code 不该顺手发码。
310
+ if code, _ := doJSON(t, s, "GET", "/desk/feishu/code", nil); code != http.StatusMethodNotAllowed {
311
+ t.Fatalf("GET 动作接口应 405,得到 %d", code)
312
+ }
313
+ }
314
+
315
+ func TestFeishuUnknownEventRejected(t *testing.T) {
316
+ _, srv := newFakeFeishu(t)
317
+ s, n := newFeishuService(t, srv.URL)
318
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec", OpenID: "ou_me", Enabled: true})
319
+ code, out := doJSON(t, s, "POST", "/desk/feishu/push", map[string]any{"event": "whatever", "ask_id": "a"})
320
+ if code != http.StatusBadGateway || !strings.Contains(string(mustJSON(out)), "未知的推送事件") {
321
+ t.Fatalf("未知事件应报错:%d %v", code, out)
322
+ }
323
+ }
324
+
325
+ /*
326
+ 2026-10-26 用户实测报障:「填了 ou_me 也绑上了,然后一条都推不出去」。
327
+
328
+ 两个问题各自一条用例钉住:① 占位符不能当 open_id 收下;② 用邮箱绑定时必须
329
+ 按 receive_id_type=email 发(飞书的 open_id 是应用维度的,跨应用抄一个必然失败)。
330
+ */
331
+ func TestFeishuBindRejectsPlaceholderOpenID(t *testing.T) {
332
+ _, srv := newFakeFeishu(t)
333
+ s, n := newFeishuService(t, srv.URL)
334
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec"})
335
+ _, out := doJSON(t, s, "POST", "/desk/feishu/code", nil)
336
+ code := out["code"].(string)
337
+
338
+ status, out := doJSON(t, s, "POST", "/desk/feishu/bind", map[string]string{"code": code, "open_id": "ou_me"})
339
+ if status != http.StatusBadRequest {
340
+ t.Fatalf("占位符 open_id 应 400,得到 %d %v", status, out)
341
+ }
342
+ msg, _ := out["error"].(string)
343
+ if !strings.Contains(msg, "不完整") || !strings.Contains(msg, "邮箱") {
344
+ t.Fatalf("错误提示应说清「不完整」并建议填邮箱:%q", msg)
345
+ }
346
+ st, err := n.load()
347
+ if err != nil {
348
+ t.Fatal(err)
349
+ }
350
+ if st.ReceiveID != "" || st.Enabled {
351
+ t.Fatalf("被拒的绑定不许落盘:%+v", st)
352
+ }
353
+ }
354
+
355
+ func TestFeishuBindByEmailSendsToEmail(t *testing.T) {
356
+ f, srv := newFakeFeishu(t)
357
+ s, n := newFeishuService(t, srv.URL)
358
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec"})
359
+ _, out := doJSON(t, s, "POST", "/desk/feishu/code", nil)
360
+ code := out["code"].(string)
361
+
362
+ status, out := doJSON(t, s, "POST", "/desk/feishu/bind",
363
+ map[string]string{"code": code, "to": "someone@company.com"})
364
+ if status != http.StatusOK {
365
+ t.Fatalf("邮箱绑定应成功:%d %v", status, out)
366
+ }
367
+ if out["to_type"] != "email" {
368
+ t.Fatalf("to_type 应为 email:%v", out)
369
+ }
370
+ f.mu.Lock()
371
+ if len(f.sent) == 0 {
372
+ f.mu.Unlock()
373
+ t.Fatal("绑定成功后应立刻推一条确认卡片")
374
+ }
375
+ got := f.sent[0]
376
+ f.mu.Unlock()
377
+ if got["receive_id"] != "someone@company.com" || got["__receive_id_type"] != "email" {
378
+ t.Fatalf("应按邮箱发:%v", got)
379
+ }
380
+
381
+ st, err := n.load()
382
+ if err != nil {
383
+ t.Fatal(err)
384
+ }
385
+ if st.ReceiveID != "someone@company.com" || st.ReceiveType != "email" || !st.Enabled || st.Code != "" {
386
+ t.Fatalf("状态不对:%+v", st)
387
+ }
388
+
389
+ // 状态接口给的是**打过码**的收件人
390
+ _, stOut := doJSON(t, s, "GET", "/desk/feishu", nil)
391
+ if stOut["bound_to"] != "s***@company.com" || stOut["bound_type"] != "email" {
392
+ t.Fatalf("状态里的收件人应打码:%v", stOut)
393
+ }
394
+ }
395
+
396
+ func TestFeishuLegacyOpenIDStateStillPushes(t *testing.T) {
397
+ f, srv := newFakeFeishu(t)
398
+ s, n := newFeishuService(t, srv.URL)
399
+ // 老状态文件:只有 open_id,没有 receive_id/type
400
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec", OpenID: "ou_1234567890abcdef", Enabled: true})
401
+ if code, out := doJSON(t, s, "POST", "/desk/feishu/push",
402
+ map[string]string{"event": "result", "ask_id": "a1", "title": "老状态"}); code != http.StatusOK {
403
+ t.Fatalf("老状态文件必须照旧能推:%d %v", code, out)
404
+ }
405
+ f.mu.Lock()
406
+ defer f.mu.Unlock()
407
+ if len(f.sent) != 1 || f.sent[0]["__receive_id_type"] != "open_id" || f.sent[0]["receive_id"] != "ou_1234567890abcdef" {
408
+ t.Fatalf("应回落成 open_id 发送:%v", f.sent)
409
+ }
410
+ }
411
+
412
+ /* ── 群自定义机器人(2026-10-26 用户:「为什么需要邮箱啊」)── */
413
+
414
+ // 签名算法钉住:以 `timestamp + "\n" + secret` 为密钥对**空串**做 HMAC-SHA256 再 base64。
415
+ // 这里用固定输入算一遍,防止以后有人改成「对 body 签名」(那是另一套协议,飞书不认)。
416
+ func TestFeishuWebhookSignMatchesFeishuSpec(t *testing.T) {
417
+ // 独立算一遍,不看被测函数的中间量。
418
+ mac := hmac.New(sha256.New, []byte("1599360473\nmy-secret"))
419
+ mac.Write(nil)
420
+ want := base64.StdEncoding.EncodeToString(mac.Sum(nil))
421
+ if got := feishuWebhookSign("1599360473", "my-secret"); got != want {
422
+ t.Fatalf("签名不符:got %s want %s", got, want)
423
+ }
424
+ // 换个 secret 必须变(防止「忘了用 secret」这种恒等错误)
425
+ if feishuWebhookSign("1599360473", "other") == want {
426
+ t.Fatal("secret 没参与签名")
427
+ }
428
+ }
429
+
430
+ func TestFeishuWebhookRejectsNonBotURL(t *testing.T) {
431
+ _, srv := newFakeFeishu(t)
432
+ s, n := newFeishuService(t, srv.URL)
433
+ seedState(t, n, feishuState{})
434
+ for _, bad := range []string{
435
+ "https://example.com/hook/abc", // 不是飞书
436
+ "https://open.feishu.cn/open-apis/im/v1", // 飞书,但不是自定义机器人
437
+ "", // 空
438
+ strings.TrimRight(srv.URL, "/") + "/wrong", // 测试替身也不许乱指
439
+ } {
440
+ if code, _ := doJSON(t, s, "POST", "/desk/feishu/webhook", map[string]string{"webhook": bad}); code != http.StatusBadRequest {
441
+ t.Fatalf("%q 应被拒,得到 %d", bad, code)
442
+ }
443
+ }
444
+ st, _ := n.load()
445
+ if st.Webhook != "" {
446
+ t.Fatalf("被拒的地址不许落盘:%+v", st)
447
+ }
448
+ }
449
+
450
+ func TestFeishuWebhookBindSendsCardWithLinkAndStatus(t *testing.T) {
451
+ f, srv := newFakeFeishu(t)
452
+ s, n := newFeishuService(t, srv.URL)
453
+ s.opts.Token = "tok-abc"
454
+ s.opts.Lan = false
455
+ seedState(t, n, feishuState{})
456
+
457
+ // 真地址形状(假服务器只按 path 匹配,所以这里换成它自己的地址 + 同一段 path)
458
+ hook := srv.URL + "/open-apis/bot/v2/hook/fake-token"
459
+ code, out := doJSON(t, s, "POST", "/desk/feishu/webhook", map[string]string{
460
+ "webhook": hook, "webhook_secret": "my-secret", "url": srv.URL,
461
+ })
462
+ if code != http.StatusOK {
463
+ t.Fatalf("绑定应成功:%d %v", code, out)
464
+ }
465
+ if out["mode"] != "webhook" || out["bound"] != true || out["configured"] != true {
466
+ t.Fatalf("状态不对:%v", out)
467
+ }
468
+
469
+ f.mu.Lock()
470
+ sent := append([]map[string]any(nil), f.webhookSent...)
471
+ f.mu.Unlock()
472
+ if len(sent) != 1 {
473
+ t.Fatalf("绑定应立刻发一条测试卡片,实际 %d", len(sent))
474
+ }
475
+ got := sent[0]
476
+ if got["timestamp"] == nil || got["sign"] == nil {
477
+ t.Fatalf("配了签名密钥就必须带 timestamp + sign:%v", got)
478
+ }
479
+ card, _ := got["card"].(map[string]any)
480
+ if card == nil {
481
+ t.Fatalf("webhook 的卡片应放在 card 字段里:%v", got)
482
+ }
483
+ raw, _ := json.Marshal(card)
484
+ if !strings.Contains(string(raw), "tok-abc") {
485
+ t.Fatalf("卡片里应带上「打开」链接(含令牌):%s", raw)
486
+ }
487
+
488
+ // 之后 push 走同一条通道,且同一 ask_id 只推一次
489
+ if code, out := doJSON(t, s, "POST", "/desk/feishu/push",
490
+ map[string]string{"event": "result", "ask_id": "a1", "title": "跑完了", "url": srv.URL}); code != http.StatusOK {
491
+ t.Fatalf("webhook 推送应成功:%d %v", code, out)
492
+ }
493
+ _, out = doJSON(t, s, "POST", "/desk/feishu/push", map[string]string{"event": "result", "ask_id": "a1"})
494
+ if out["dupe"] != true {
495
+ t.Fatalf("第二次应判重:%v", out)
496
+ }
497
+ f.mu.Lock()
498
+ defer f.mu.Unlock()
499
+ if len(f.webhookSent) != 2 {
500
+ t.Fatalf("绑定 1 条 + 推送 1 条 = 2,实际 %d", len(f.webhookSent))
501
+ }
502
+ }
503
+
504
+ func TestFeishuWebhookTestMessageCarriesAddress(t *testing.T) {
505
+ f, srv := newFakeFeishu(t)
506
+ s, n := newFeishuService(t, srv.URL)
507
+ s.opts.Token = "tok-xyz"
508
+ seedState(t, n, feishuState{Webhook: srv.URL + "/open-apis/bot/v2/hook/x", Enabled: true})
509
+ if code, out := doJSON(t, s, "POST", "/desk/feishu/test", map[string]string{"url": srv.URL}); code != http.StatusOK {
510
+ t.Fatalf("测试消息应成功:%d %v", code, out)
511
+ }
512
+ f.mu.Lock()
513
+ defer f.mu.Unlock()
514
+ raw, _ := json.Marshal(f.webhookSent[len(f.webhookSent)-1])
515
+ if !strings.Contains(string(raw), srv.URL) || !strings.Contains(string(raw), "tok-xyz") {
516
+ t.Fatalf("测试消息应带上当前入口地址:%s", raw)
517
+ }
518
+ }
519
+
520
+ /*
521
+ 2026-10-26 用户定调:「改为直接绑定会话 id 即可」—— oc_ 开头的群/单聊,
522
+
523
+ 推送时 receive_id_type 必须是 chat_id(不是 open_id,也不是 email)。
524
+ */
525
+ func TestFeishuBindByChatIDSendsToChat(t *testing.T) {
526
+ f, srv := newFakeFeishu(t)
527
+ s, n := newFeishuService(t, srv.URL)
528
+ seedState(t, n, feishuState{AppID: "cli_abc", AppSecret: "sec"})
529
+ _, out := doJSON(t, s, "POST", "/desk/feishu/code", nil)
530
+ code := out["code"].(string)
531
+
532
+ const chat = "oc_a90ab20986ffccf22b95939a1a8ce2a4"
533
+ status, out := doJSON(t, s, "POST", "/desk/feishu/bind", map[string]string{"code": code, "to": chat})
534
+ if status != http.StatusOK {
535
+ t.Fatalf("会话 id 绑定应成功:%d %v", status, out)
536
+ }
537
+ if out["to_type"] != "chat_id" {
538
+ t.Fatalf("to_type 应为 chat_id:%v", out)
539
+ }
540
+ f.mu.Lock()
541
+ got := f.sent[0]
542
+ f.mu.Unlock()
543
+ if got["receive_id"] != chat || got["__receive_id_type"] != "chat_id" {
544
+ t.Fatalf("应按 chat_id 发:%v", got)
545
+ }
546
+ // 状态里只留末 6 位,别把整串会话 id 甩给页面
547
+ _, stOut := doJSON(t, s, "GET", "/desk/feishu", nil)
548
+ if stOut["bound_to"] != "…8ce2a4" || stOut["bound_type"] != "chat_id" {
549
+ t.Fatalf("状态里的会话 id 应打码:%v", stOut)
550
+ }
551
+ }
552
+
553
+ /*
554
+ 2026-10-26 用户实测:「发送的消息中的访问连接地址不对 无法访问」。
555
+
556
+ 根因是缓存里的公网域名属于**上一轮隧道**(cloudflared 重启就换域名,老域名 TCP 都不通)。
557
+ 所以:只有回打 /desk/health 通了才写进卡片;全都不通就不放链接。
558
+ */
559
+ func TestFeishuLinkOnlyUsesReachableAddress(t *testing.T) {
560
+ f, srv := newFakeFeishu(t)
561
+ s, n := newFeishuService(t, srv.URL)
562
+ s.opts.Token = "tok-live"
563
+
564
+ // 缓存里躺着一个死域名(.invalid 立即解析失败),手机报的入口是通的。
565
+ s.pubMu.Lock()
566
+ s.pubURL = "https://stale-tunnel.invalid"
567
+ s.pubExp = time.Now().Add(time.Hour)
568
+ s.pubLoaded = true
569
+ s.pubMu.Unlock()
570
+ link := s.feishuLink(context.Background(), srv.URL)
571
+ if !strings.HasPrefix(link, srv.URL) || !strings.Contains(link, "tok-live") {
572
+ t.Fatalf("应跳过死域名、用手机报的入口:%q", link)
573
+ }
574
+
575
+ // 全都不通 → 空串(卡片不带按钮),而不是发一个打不开的门。
576
+ s.pubMu.Lock()
577
+ s.pubURL = ""
578
+ s.pubMu.Unlock()
579
+ if got := s.feishuLink(context.Background(), "http://10.255.255.1:9"); got != "" {
580
+ t.Fatalf("没有可用地址时应返回空:%q", got)
581
+ }
582
+
583
+ // 走一遍真实发送:带链接时卡片里有 action 按钮,无链接时没有。
584
+ f.mu.Lock()
585
+ f.webhookSent = nil
586
+ f.mu.Unlock()
587
+ seedState(t, n, feishuState{Webhook: srv.URL + "/open-apis/bot/v2/hook/x", Enabled: true})
588
+ if code, _ := doJSON(t, s, "POST", "/desk/feishu/test", map[string]string{"url": srv.URL}); code != http.StatusOK {
589
+ t.Fatalf("测试消息应成功")
590
+ }
591
+ f.mu.Lock()
592
+ raw, _ := json.Marshal(f.webhookSent[len(f.webhookSent)-1])
593
+ f.mu.Unlock()
594
+ if !strings.Contains(string(raw), "动作") && !strings.Contains(string(raw), "action") {
595
+ t.Fatalf("通了就应该带按钮:%s", raw)
596
+ }
597
+ }
598
+
599
+ /* ── 启动通知(2026-10-26 用户:「重新启动后如果绑定了飞书 触发一下发送访问地址」)── */
600
+
601
+ // 绑了飞书 + 有可用入口 → 启动后推一条带地址的卡片。
602
+ func TestFeishuStartupPushesAccessAddress(t *testing.T) {
603
+ f, srv := newFakeFeishu(t)
604
+ s, n := newFeishuService(t, srv.URL)
605
+ s.opts.Token = "tok-start"
606
+ seedState(t, n, feishuState{Webhook: srv.URL + "/open-apis/bot/v2/hook/x", Enabled: true})
607
+
608
+ // 只试一次:用例不该为了等隧道真睡一分半。
609
+ oldDelays := feishuStartupDelays
610
+ feishuStartupDelays = []time.Duration{0}
611
+ t.Cleanup(func() { feishuStartupDelays = oldDelays })
612
+
613
+ s.NotifyStartup(srv.URL + "/?token=tok-start")
614
+
615
+ f.mu.Lock()
616
+ defer f.mu.Unlock()
617
+ if len(f.webhookSent) != 1 {
618
+ t.Fatalf("启动后应推一条,实际 %d", len(f.webhookSent))
619
+ }
620
+ raw, _ := json.Marshal(f.webhookSent[0])
621
+ if !strings.Contains(string(raw), "tok-start") || !strings.Contains(string(raw), "地址") {
622
+ t.Fatalf("卡片里应带上当前访问地址(含令牌):%s", raw)
623
+ }
624
+ }
625
+
626
+ // 没绑定 / 关了推送 → 启动时一个请求都不发(启动不该产生出站流量)。
627
+ func TestFeishuStartupSilentWhenUnboundOrDisabled(t *testing.T) {
628
+ f, srv := newFakeFeishu(t)
629
+ s, n := newFeishuService(t, srv.URL)
630
+ s.opts.Token = "tok-start"
631
+
632
+ oldDelays := feishuStartupDelays
633
+ feishuStartupDelays = []time.Duration{0}
634
+ t.Cleanup(func() { feishuStartupDelays = oldDelays })
635
+
636
+ // 还没绑定
637
+ seedState(t, n, feishuState{})
638
+ s.NotifyStartup(srv.URL + "/?token=tok-start")
639
+
640
+ // 绑了但用户关掉了
641
+ seedState(t, n, feishuState{Webhook: srv.URL + "/open-apis/bot/v2/hook/x", Enabled: false})
642
+ s.NotifyStartup(srv.URL + "/?token=tok-start")
643
+
644
+ f.mu.Lock()
645
+ defer f.mu.Unlock()
646
+ if len(f.webhookSent) != 0 || f.tokenHits != 0 {
647
+ t.Fatalf("没绑定 / 关了推送时不应有出站请求:webhook=%d token=%d", len(f.webhookSent), f.tokenHits)
648
+ }
649
+ }