magic-agent 2.4.3 → 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 (207) hide show
  1. package/CLIENT-CONTRACT.md +467 -41
  2. package/README.md +15 -14
  3. package/client-ui/pc/app.css +74 -0
  4. package/client-ui/pc/js/04-task-list.js +5 -1
  5. package/client-ui/pc/js/07-chat-stream-render.js +3 -3
  6. package/client-ui/pc/js/90-glue.js +40 -2
  7. package/client-ui/pc/js/92-composer.js +19 -4
  8. package/client-ui/pc/js/93-rail.js +16 -5
  9. package/client-ui/shared/conv/02-media-kinds.js +2 -2
  10. package/client-ui/shared/conv/03-chat-message.js +247 -23
  11. package/client-ui/shared/conv/06-ask-answer.js +75 -1
  12. package/client-ui/shared/conv/09-stream-apply.js +72 -7
  13. package/client-ui/shared/conv/19-attachments.js +157 -6
  14. package/client-ui/shared/conv/27-conv-ledger.js +5 -2
  15. package/client-ui/shared/conv/29-msg-variants.js +217 -0
  16. package/client-ui/shared/conv/conv.css +329 -174
  17. package/client-ui/shared/conv/dom-map.md +1 -0
  18. package/client-ui/shared/conversation.css +13 -2
  19. package/client-ui/shared/conversation.js +373 -50
  20. package/client-ui/shared/transport-http.js +14 -0
  21. package/client-ui-mobile/apps.css +16 -0
  22. package/client-ui-mobile/apps.js +406 -0
  23. package/client-ui-mobile/index.html +5554 -639
  24. package/client-ui-mobile/reqs.css +110 -1
  25. package/client-ui-mobile/reqs.js +533 -74
  26. package/client-ui-mobile/reqshot.js +505 -0
  27. package/client-ui-mobile/scripts.css +67 -0
  28. package/client-ui-mobile/scripts.js +599 -0
  29. package/client-ui-mobile/vendor/cose-base.js +3214 -0
  30. package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
  31. package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
  32. package/client-ui-mobile/vendor/layout-base.js +5230 -0
  33. package/cmd/magic-client/browsectl.go +199 -0
  34. package/cmd/magic-client/clientctl.go +69 -10
  35. package/cmd/magic-client/main.go +210 -32
  36. package/internal/agent/ask.go +45 -0
  37. package/internal/agent/ask_test.go +39 -0
  38. package/internal/agent/attachments_test.go +6 -2
  39. package/internal/agent/capability.go +7 -1
  40. package/internal/agent/claude.go +20 -15
  41. package/internal/agent/codebuddy.go +125 -34
  42. package/internal/agent/codebuddy_account_test.go +54 -0
  43. package/internal/agent/codebuddy_stream_test.go +103 -0
  44. package/internal/agent/credits.go +713 -0
  45. package/internal/agent/credits_minimax_test.go +97 -0
  46. package/internal/agent/credits_test.go +211 -0
  47. package/internal/agent/custom_models.go +155 -0
  48. package/internal/agent/custom_models_test.go +125 -0
  49. package/internal/agent/dsh.go +123 -49
  50. package/internal/agent/dsh_caps_cache_test.go +184 -0
  51. package/internal/agent/dsh_resume.go +157 -7
  52. package/internal/agent/dsh_test.go +108 -6
  53. package/internal/agent/engine.go +37 -1
  54. package/internal/agent/engine_base.go +21 -17
  55. package/internal/agent/engine_base_test.go +2 -2
  56. package/internal/agent/engine_test.go +47 -86
  57. package/internal/agent/env.go +173 -1
  58. package/internal/agent/env_test.go +114 -0
  59. package/internal/agent/fallback.go +313 -0
  60. package/internal/agent/fallback_test.go +340 -0
  61. package/internal/agent/launch.go +43 -1
  62. package/internal/agent/limitnotice.go +207 -1
  63. package/internal/agent/limitnotice_test.go +140 -0
  64. package/internal/agent/llmhttp.go +46 -4
  65. package/internal/agent/llmhttp_test.go +157 -0
  66. package/internal/agent/models.go +53 -1
  67. package/internal/agent/models_test.go +20 -5
  68. package/internal/agent/pidalive_windows_test.go +40 -0
  69. package/internal/agent/runcmd.go +26 -0
  70. package/internal/agent/runcmd_windows.go +36 -5
  71. package/internal/agent/stream.go +166 -6
  72. package/internal/agent/stream_test.go +25 -38
  73. package/internal/agent/stream_timeline_test.go +2 -37
  74. package/internal/agent/streamjson.go +119 -7
  75. package/internal/agent/tools_suffix_test.go +6 -3
  76. package/internal/agent/trae.go +50 -243
  77. package/internal/agent/trae_exec.go +433 -0
  78. package/internal/agent/trae_exec_test.go +181 -0
  79. package/internal/agent/wraplaunch_test.go +113 -0
  80. package/internal/cli/ask.go +168 -37
  81. package/internal/cli/ask_test.go +1 -1
  82. package/internal/cli/checkin.go +77 -0
  83. package/internal/cli/clientcmd.go +3 -2
  84. package/internal/cli/control.go +46 -5
  85. package/internal/cli/control_test.go +65 -0
  86. package/internal/cli/engines_test.go +103 -0
  87. package/internal/cli/events.go +15 -0
  88. package/internal/cli/events_test.go +50 -0
  89. package/internal/cli/judge.go +104 -0
  90. package/internal/cli/quotas.go +125 -0
  91. package/internal/cli/requirements.go +51 -0
  92. package/internal/cli/root.go +157 -11
  93. package/internal/cli/session.go +75 -3
  94. package/internal/cli/session_test.go +82 -3
  95. package/internal/cli/sessionlog.go +6 -0
  96. package/internal/cli/sessionlog_test.go +22 -2
  97. package/internal/cli/suggest.go +7 -5
  98. package/internal/cli/team.go +37 -9
  99. package/internal/cli/team_test.go +39 -0
  100. package/internal/client/ab.go +372 -0
  101. package/internal/client/ab_test.go +287 -0
  102. package/internal/client/apk.go +8 -0
  103. package/internal/client/apk_test.go +59 -0
  104. package/internal/client/apps.go +1078 -0
  105. package/internal/client/apps_test.go +516 -0
  106. package/internal/client/browse.go +84 -36
  107. package/internal/client/browse_test.go +107 -9
  108. package/internal/client/browseinput.go +481 -0
  109. package/internal/client/browseinput_test.go +216 -0
  110. package/internal/client/corecmd.go +23 -0
  111. package/internal/client/corecmd_other.go +8 -0
  112. package/internal/client/corecmd_windows.go +24 -0
  113. package/internal/client/coreexe.go +196 -0
  114. package/internal/client/coreexe_test.go +161 -0
  115. package/internal/client/coreupgrade.go +866 -0
  116. package/internal/client/coreupgrade_test.go +561 -0
  117. package/internal/client/daemon.go +297 -33
  118. package/internal/client/daemon_test.go +33 -0
  119. package/internal/client/engines_checkin_test.go +57 -0
  120. package/internal/client/fallback_flags_test.go +64 -0
  121. package/internal/client/feishu.go +1259 -0
  122. package/internal/client/feishu_test.go +649 -0
  123. package/internal/client/health_identity_test.go +84 -0
  124. package/internal/client/hostenv.go +68 -0
  125. package/internal/client/hostenv_test.go +37 -0
  126. package/internal/client/jobs.go +518 -24
  127. package/internal/client/jobs_auto.go +571 -0
  128. package/internal/client/jobs_auto_test.go +431 -0
  129. package/internal/client/jobs_test.go +299 -2
  130. package/internal/client/knowledge.go +854 -0
  131. package/internal/client/knowledge_test.go +510 -0
  132. package/internal/client/lan.go +3 -3
  133. package/internal/client/plugins.go +5 -1
  134. package/internal/client/prefs.go +67 -0
  135. package/internal/client/procstart_linux.go +59 -0
  136. package/internal/client/publicurl.go +82 -0
  137. package/internal/client/quotas_merge_test.go +107 -0
  138. package/internal/client/requirements.go +207 -2
  139. package/internal/client/requirements_shots_test.go +200 -0
  140. package/internal/client/requirements_test.go +103 -0
  141. package/internal/client/restart.go +288 -0
  142. package/internal/client/restart_handoff_test.go +230 -0
  143. package/internal/client/restart_test.go +290 -0
  144. package/internal/client/scripts.go +1047 -0
  145. package/internal/client/scripts_decode_implausible.go +38 -0
  146. package/internal/client/scripts_decode_other.go +31 -0
  147. package/internal/client/scripts_decode_test.go +130 -0
  148. package/internal/client/scripts_decode_windows.go +182 -0
  149. package/internal/client/scripts_test.go +739 -0
  150. package/internal/client/service.go +717 -97
  151. package/internal/client/service_test.go +217 -1
  152. package/internal/client/sessions_cache.go +148 -0
  153. package/internal/client/sessions_cache_test.go +163 -0
  154. package/internal/client/sessions_fold.go +165 -0
  155. package/internal/client/sessions_fold_test.go +204 -0
  156. package/internal/client/singleinstance.go +470 -0
  157. package/internal/client/singleinstance_test.go +607 -0
  158. package/internal/client/suggest.go +105 -2
  159. package/internal/client/suggest_test.go +86 -0
  160. package/internal/client/team_flags.go +25 -11
  161. package/internal/client/team_flags_test.go +39 -0
  162. package/internal/client/testmain_test.go +88 -0
  163. package/internal/client/workspace_default_test.go +158 -0
  164. package/internal/client/workspacelockenv_test.go +54 -0
  165. package/internal/config/config.go +48 -0
  166. package/internal/judge/judge.go +315 -0
  167. package/internal/requirements/bootstrap.go +16 -0
  168. package/internal/requirements/consolidate.go +100 -9
  169. package/internal/requirements/doc.go +154 -3
  170. package/internal/requirements/graph.go +138 -0
  171. package/internal/requirements/memory.go +7 -0
  172. package/internal/requirements/modules.go +312 -0
  173. package/internal/requirements/modules_test.go +373 -0
  174. package/internal/requirements/relations.go +155 -0
  175. package/internal/requirements/relations_test.go +231 -0
  176. package/internal/requirements/requirements.go +10 -0
  177. package/internal/requirements/shots.go +418 -0
  178. package/internal/requirements/shots_test.go +387 -0
  179. package/internal/session/multiclient_test.go +368 -0
  180. package/internal/session/session.go +175 -11
  181. package/internal/session/session_test.go +7 -0
  182. package/internal/session/status.go +21 -0
  183. package/internal/session/status_test.go +28 -0
  184. package/internal/session/title.go +16 -0
  185. package/internal/session/title_test.go +26 -0
  186. package/internal/session/workspace_lock.go +265 -28
  187. package/internal/suggest/suggest.go +23 -1
  188. package/internal/team/event.go +3 -1
  189. package/internal/team/execute.go +21 -6
  190. package/internal/team/fallback_route_test.go +80 -0
  191. package/internal/team/planner.go +15 -3
  192. package/internal/team/route.go +89 -17
  193. package/internal/team/team.go +18 -4
  194. package/npm/apk/magic-agent-mobile-release.apk +0 -0
  195. package/npm/apk/version.json +6 -3
  196. package/npm/dist/darwin-arm64/magic-agent +0 -0
  197. package/npm/dist/darwin-arm64/magic-client +0 -0
  198. package/npm/dist/darwin-x64/magic-agent +0 -0
  199. package/npm/dist/darwin-x64/magic-client +0 -0
  200. package/npm/dist/linux-arm64/magic-agent +0 -0
  201. package/npm/dist/linux-arm64/magic-client +0 -0
  202. package/npm/dist/linux-x64/magic-agent +0 -0
  203. package/npm/dist/linux-x64/magic-client +0 -0
  204. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  205. package/npm/dist/win32-x64/magic-client.exe +0 -0
  206. package/npm/install.js +11 -0
  207. package/package.json +14 -3
@@ -0,0 +1,1259 @@
1
+ package client
2
+
3
+ /* 飞书推送(2026-10-26 用户:「另增加飞书绑定支持登录后推送通知」)。
4
+ *
5
+ * 要解决的问题:手机端的系统通知只在 App 在前台轮询时才会响(见 index.html 的
6
+ * pollOnce)—— 锁屏 / 切到后台 / 离开 Wi-Fi 就什么都收不到。飞书推送把**桌面端**
7
+ * 变成推送源:桌面端在任务出结果、需要人确认的时候,用飞书机器人往用户自己的飞书
8
+ * 推一条卡片。用户在飞书里就能知道「哪条任务跑完了、问的什么、该去点什么」。
9
+ *
10
+ * 分两步,跟「登录」这件事对齐:
11
+ * POST /desk/feishu/code → 发一个 6 位绑定码(10 分钟有效),手机端显示出来;
12
+ * POST /desk/feishu/bind → {code, open_id} 绑定(手机端从绑定的飞书桌面端/应用里
13
+ * 取 open_id,见 index.html openFeishuBind)。
14
+ * 绑定成功后**只推给这个 open_id**,不是推给一个群 —— 推送是「我的通知」,不是广播。
15
+ *
16
+ * 凭据:飞书自建应用的 APP_ID / APP_SECRET 属于部署方的敏感信息,**不收进移动端**。
17
+ * 桌面端按以下顺序找:
18
+ * ① 环境变量 MAGIC_FEISHU_APP_ID / MAGIC_FEISHU_APP_SECRET
19
+ * ② ~/.magic-agent/feishu.json ({"app_id":"…","app_secret":"…"},0600)
20
+ * ③ config.json 的 feishu.app_id / feishu.app_secret
21
+ * ④ 本机已登录的 lark-cli(`lark-cli config show --json` 拿 appId)**不做**——
22
+ * 那是用户自己的开发者身份,借来发「任务完成」有冒用风险。
23
+ * 都没有时 GET /desk/feishu/status 回 configured=false,移动端把 App ID/Secret 的录入框
24
+ * 亮出来(POST /desk/feishu/config,只写本机 0600 文件,不回显 secret)。
25
+ *
26
+ * 推送事件(**幂等**):手机端在 /desk/sessions 看到一轮结束 / 等待确认时调
27
+ * POST /desk/feishu/push {event, ask_id}。ask_id 是这一轮的稳定身份,重复调用只推一次
28
+ * (手机轮询会重入、多台手机各自轮询,不做去重就会刷屏)。服务端发送失败按退避重试
29
+ * (最多 3 次),最终失败只记在最近的推送记录里,**不吞掉**。
30
+ *
31
+ * 飞书接口:
32
+ * POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal
33
+ * POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id
34
+ * 两者都用官方直连,不经过 /b/ 中转(这是服务端自己的出站请求)。
35
+ */
36
+
37
+ import (
38
+ "bytes"
39
+ "context"
40
+ "crypto/hmac"
41
+ "crypto/rand"
42
+ "crypto/sha256"
43
+ "encoding/base64"
44
+ "encoding/json"
45
+ "errors"
46
+ "fmt"
47
+ "io"
48
+ "math/big"
49
+ "net/http"
50
+ "os"
51
+ "path/filepath"
52
+ "strconv"
53
+ "strings"
54
+ "sync"
55
+ "time"
56
+ )
57
+
58
+ const (
59
+ feishuCodeTTL = 10 * time.Minute
60
+ feishuTokenAhead = 2 * time.Minute // 租户令牌提前这么久换新
61
+ feishuSendAttempts = 3
62
+ feishuRetryBackoff = 800 * time.Millisecond
63
+ feishuRecentKeep = 20
64
+ feishuHTTPTimeout = 10 * time.Second
65
+ feishuMaxPushTextLen = 800
66
+ )
67
+
68
+ // 推送事件:result / confirm 与手机端 pollOnce 里的通知种类一一对应;
69
+ // startup 是服务启动自己的通知(见 NotifyStartup),没有对应的手机端轮询种类。
70
+ var feishuEvents = map[string]struct {
71
+ title string
72
+ tag string
73
+ }{
74
+ "result": {"任务完成", "green"},
75
+ "confirm": {"任务待确认", "orange"},
76
+ "startup": {"服务已重启", "blue"},
77
+ }
78
+
79
+ // feishuState 落盘形状(~/.magic-agent/feishu-notify.json)。
80
+ // 特意与 mobile-prefs.json 分开:那份是「设备偏好」,这份含凭据与个人 open_id,
81
+ // 权限、备份范围都不一样。
82
+ type feishuState struct {
83
+ AppID string `json:"app_id,omitempty"`
84
+ AppSecret string `json:"app_secret,omitempty"`
85
+ /* 推给谁。2026-10-26 用户报「填了 ou_me 也能绑上、然后一条都推不出去」之后改的:
86
+ open_id **是应用维度的**(同一个人在不同应用下 open_id 不同),而且飞书客户端
87
+ 根本不显示它 —— 让用户手抄 open_id 等于让他猜。所以绑定改成「邮箱也行」:
88
+ ReceiveType = email → 收件人是飞书账号邮箱(im/v1/messages 原生支持)
89
+ ReceiveType = open_id → 老路子,手里真有 open_id 的人照旧能用
90
+ ReceiveType = chat_id → 推到某个群(oc_ 开头)
91
+ ReceiveID 为空时回落到 OpenID(老状态文件兼容,见 target())。 */
92
+ /* 群自定义机器人 webhook(2026-10-26 用户:「为什么需要邮箱啊」)。
93
+ 这才是「把消息推到我的飞书」最省事的路:群里加一个「自定义机器人」拿一条地址,
94
+ 不用建应用、不用审批、不用知道 open_id / 邮箱 —— 收件人就是那个群。
95
+ 配了它就走它(优先级高于应用凭据)。 */
96
+ Webhook string `json:"webhook,omitempty"`
97
+ WebhookSecret string `json:"webhook_secret,omitempty"`
98
+ ReceiveID string `json:"receive_id,omitempty"`
99
+ ReceiveType string `json:"receive_type,omitempty"`
100
+ OpenID string `json:"open_id,omitempty"`
101
+ BoundAt time.Time `json:"bound_at,omitempty"`
102
+ Enabled bool `json:"enabled"`
103
+ Code string `json:"code,omitempty"`
104
+ CodeAt time.Time `json:"code_at,omitempty"`
105
+ Recent []feishuPushLog `json:"recent,omitempty"`
106
+ }
107
+
108
+ // target 推给谁 + 用哪种 id 解释它。老状态文件只有 open_id → 按 open_id 用。
109
+ func (st feishuState) target() (string, string) {
110
+ if st.ReceiveID != "" {
111
+ t := st.ReceiveType
112
+ if t == "" {
113
+ t = "open_id"
114
+ }
115
+ return st.ReceiveID, t
116
+ }
117
+ if st.OpenID != "" {
118
+ return st.OpenID, "open_id"
119
+ }
120
+ return "", ""
121
+ }
122
+
123
+ // feishuPushLog 最近一次推送的结果(排障用:用户报「没收到」时看这里)。
124
+ // Link 记下这条卡片实际发出去的地址 —— 「地址打不开」时第一件事就是看
125
+ // 当时推的是公网还是局域网、是不是上一轮隧道的域名。
126
+ type feishuPushLog struct {
127
+ At time.Time `json:"at"`
128
+ Event string `json:"event"`
129
+ AskID string `json:"ask_id"`
130
+ Title string `json:"title"`
131
+ Link string `json:"link,omitempty"`
132
+ OK bool `json:"ok"`
133
+ Attempt int `json:"attempt"`
134
+ Error string `json:"error,omitempty"`
135
+ }
136
+
137
+ // feishuNotifier 单例(进程内),由 Service.feishu() 惰性建。
138
+ type feishuNotifier struct {
139
+ mu sync.RWMutex
140
+ path string
141
+
142
+ token string
143
+ tokenExp time.Time
144
+ tokenBase string // 令牌对应的 app(换 app 就作废)
145
+
146
+ sent map[string]time.Time // event|ask_id → 首次受理时间(幂等去重)
147
+ client *http.Client
148
+
149
+ baseURL string // 测试可改;空 = 官方
150
+ }
151
+
152
+ func feishuStatePath() string {
153
+ home, err := os.UserHomeDir()
154
+ if err != nil || home == "" {
155
+ return ""
156
+ }
157
+ return filepath.Join(home, ".magic-agent", "feishu-notify.json")
158
+ }
159
+
160
+ // Service.feishu 惰性建推送器(与其它 hub 一个套路:请求路径上不建)。
161
+ func (s *Service) feishu() *feishuNotifier {
162
+ s.feishuOnce.Do(func() {
163
+ if s.feishuN == nil {
164
+ s.feishuN = &feishuNotifier{}
165
+ }
166
+ n := s.feishuN
167
+ if n.path == "" {
168
+ n.path = feishuStatePath()
169
+ }
170
+ if n.sent == nil {
171
+ n.sent = map[string]time.Time{}
172
+ }
173
+ if n.client == nil {
174
+ n.client = &http.Client{Timeout: feishuHTTPTimeout}
175
+ }
176
+ })
177
+ return s.feishuN
178
+ }
179
+
180
+ func (n *feishuNotifier) load() (feishuState, error) {
181
+ var st feishuState
182
+ if n.path == "" {
183
+ return st, errors.New("找不到用户目录")
184
+ }
185
+ b, err := os.ReadFile(n.path)
186
+ if err != nil {
187
+ if os.IsNotExist(err) {
188
+ return st, nil
189
+ }
190
+ return st, err
191
+ }
192
+ if len(bytes.TrimSpace(b)) == 0 {
193
+ return st, nil
194
+ }
195
+ if err := json.Unmarshal(b, &st); err != nil {
196
+ return feishuState{}, fmt.Errorf("飞书配置损坏(%s):%v", n.path, err)
197
+ }
198
+ return st, nil
199
+ }
200
+
201
+ func (n *feishuNotifier) save(st feishuState) error {
202
+ if n.path == "" {
203
+ return errors.New("找不到用户目录")
204
+ }
205
+ if err := os.MkdirAll(filepath.Dir(n.path), 0o755); err != nil {
206
+ return err
207
+ }
208
+ b, err := json.MarshalIndent(st, "", " ")
209
+ if err != nil {
210
+ return err
211
+ }
212
+ tmp := n.path + ".tmp"
213
+ // 0600:里面有 app_secret 与个人 open_id。
214
+ if err := os.WriteFile(tmp, b, 0o600); err != nil {
215
+ return err
216
+ }
217
+ return os.Rename(tmp, n.path)
218
+ }
219
+
220
+ /* 凭据判定。环境变量名与 internal/config 的 EnvFeishu* 保持一致;
221
+ 这里只读不写(写由移动端的 /desk/feishu/config 负责,落本机 0600 文件)。 */
222
+
223
+ func envCreds() (string, string) {
224
+ return strings.TrimSpace(os.Getenv("MAGIC_AGENT_FEISHU_APP_ID")),
225
+ strings.TrimSpace(os.Getenv("MAGIC_AGENT_FEISHU_APP_SECRET"))
226
+ }
227
+
228
+ // credsOf 判定凭据:本机文件 > 环境变量 > config.json(Options 传进来)。
229
+ // 返回 (appID, appSecret, 来源说明)。来源只用于界面展示,secret 永远不回显。
230
+ func (s *Service) credsOf(st feishuState) (string, string, string) {
231
+ if st.AppID != "" && st.AppSecret != "" {
232
+ return st.AppID, st.AppSecret, "本机配置"
233
+ }
234
+ if id, sec := envCreds(); id != "" && sec != "" {
235
+ return id, sec, "环境变量"
236
+ }
237
+ if s.opts.FeishuAppID != "" && s.opts.FeishuAppSecret != "" {
238
+ return s.opts.FeishuAppID, s.opts.FeishuAppSecret, "config.json"
239
+ }
240
+ return "", "", ""
241
+ }
242
+
243
+ /* ─────────────── 飞书 API ─────────────── */
244
+
245
+ func (n *feishuNotifier) apiBase() string {
246
+ if n.baseURL != "" {
247
+ return strings.TrimRight(n.baseURL, "/")
248
+ }
249
+ return "https://open.feishu.cn"
250
+ }
251
+
252
+ func (n *feishuNotifier) post(ctx context.Context, path string, body any, bearer string) ([]byte, error) {
253
+ b, err := json.Marshal(body)
254
+ if err != nil {
255
+ return nil, err
256
+ }
257
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.apiBase()+path, bytes.NewReader(b))
258
+ if err != nil {
259
+ return nil, err
260
+ }
261
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
262
+ if bearer != "" {
263
+ req.Header.Set("Authorization", "Bearer "+bearer)
264
+ }
265
+ resp, err := n.client.Do(req)
266
+ if err != nil {
267
+ return nil, err
268
+ }
269
+ defer resp.Body.Close()
270
+ out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
271
+ if err != nil {
272
+ return nil, err
273
+ }
274
+ if resp.StatusCode >= 300 {
275
+ return out, fmt.Errorf("飞书接口 HTTP %d:%s", resp.StatusCode, feishuErrText(out))
276
+ }
277
+ return out, nil
278
+ }
279
+
280
+ // feishuErrText 把飞书统一的 {code,msg} 翻成人话,拿不到就截断原文。
281
+ func feishuErrText(b []byte) string {
282
+ var v struct {
283
+ Code int `json:"code"`
284
+ Msg string `json:"msg"`
285
+ }
286
+ if json.Unmarshal(b, &v) == nil && (v.Msg != "" || v.Code != 0) {
287
+ return fmt.Sprintf("code=%d %s", v.Code, v.Msg)
288
+ }
289
+ s := strings.TrimSpace(string(b))
290
+ if len(s) > 200 {
291
+ s = s[:200] + "…"
292
+ }
293
+ if s == "" {
294
+ return "(空响应)"
295
+ }
296
+ return s
297
+ }
298
+
299
+ // tenantToken 拿(并缓存)租户令牌。app 变了立刻作废缓存 —— 换 app 还拿旧令牌
300
+ // 会得到「机器人没被加入会话」这类莫名其妙的错误。
301
+ func (n *feishuNotifier) tenantToken(ctx context.Context, appID, appSecret string) (string, error) {
302
+ // 读缓存走 RLock:推送是高频路径,不该每次都在写锁上排队。
303
+ n.mu.RLock()
304
+ if n.token != "" && n.tokenBase == appID && time.Now().Before(n.tokenExp) {
305
+ tk := n.token
306
+ n.mu.RUnlock()
307
+ return tk, nil
308
+ }
309
+ n.mu.RUnlock()
310
+
311
+ out, err := n.post(ctx, "/open-apis/auth/v3/tenant_access_token/internal",
312
+ map[string]string{"app_id": appID, "app_secret": appSecret}, "")
313
+ if err != nil {
314
+ return "", err
315
+ }
316
+ var v struct {
317
+ Code int `json:"code"`
318
+ Msg string `json:"msg"`
319
+ TenantAccessToken string `json:"tenant_access_token"`
320
+ Expire int `json:"expire"`
321
+ }
322
+ if err := json.Unmarshal(out, &v); err != nil {
323
+ return "", fmt.Errorf("飞书令牌响应无法解析:%v", err)
324
+ }
325
+ if v.Code != 0 || v.TenantAccessToken == "" {
326
+ return "", fmt.Errorf("飞书拒绝换取令牌:code=%d %s", v.Code, v.Msg)
327
+ }
328
+ ttl := time.Duration(v.Expire) * time.Second
329
+ if ttl <= 0 {
330
+ ttl = time.Hour
331
+ }
332
+ if ttl > feishuTokenAhead {
333
+ ttl -= feishuTokenAhead
334
+ }
335
+ n.mu.Lock()
336
+ n.token, n.tokenBase, n.tokenExp = v.TenantAccessToken, appID, time.Now().Add(ttl)
337
+ n.mu.Unlock()
338
+ return v.TenantAccessToken, nil
339
+ }
340
+
341
+ // sendCard 发一条 interactive 卡片给 to(toType 决定 to 怎么解释:open_id / email / chat_id)。
342
+ // 为什么用卡片而不是 text:卡片能带灰字脚注(时间)与标题色,
343
+ // 通知列表里一眼能分辨「完成(绿)/ 待确认(橙)/ 测试(蓝)」。
344
+ // feishuCard 组装卡片(应用 / 群机器人两条路共用同一张卡)。
345
+ //
346
+ // link 非空时加一个「打开」按钮(2026-10-26 用户:「发测试消息就是把当前公网地址发送过去」)——
347
+ // 在飞书里点一下就回到 App,不用自己回忆地址、也不用手抄令牌。
348
+ func feishuCard(title, color, body, link string) map[string]any {
349
+ var els []any
350
+ if body != "" {
351
+ els = append(els, map[string]any{"tag": "div", "text": map[string]any{"tag": "lark_md", "content": body}})
352
+ }
353
+ if link != "" {
354
+ els = append(els, map[string]any{
355
+ "tag": "action",
356
+ "actions": []any{map[string]any{
357
+ "tag": "button",
358
+ "type": "primary",
359
+ "text": map[string]any{"tag": "plain_text", "content": "打开 Magic Agent"},
360
+ "url": link,
361
+ }},
362
+ })
363
+ }
364
+ els = append(els, map[string]any{
365
+ "tag": "note",
366
+ "elements": []any{map[string]any{
367
+ "tag": "plain_text",
368
+ "content": "Magic Agent · " + time.Now().Format("01-2 15:04") + " · 来自桌面端",
369
+ }},
370
+ })
371
+ return map[string]any{
372
+ "config": map[string]any{"wide_screen_mode": true},
373
+ "header": map[string]any{
374
+ "template": color,
375
+ "title": map[string]any{"tag": "plain_text", "content": title},
376
+ },
377
+ "elements": els,
378
+ }
379
+ }
380
+
381
+ // sendCard 发一条 interactive 卡片给 to(toType 决定 to 怎么解释:open_id / email / chat_id)。
382
+ // 为什么用卡片而不是 text:卡片能带灰字脚注(时间)与标题色,
383
+ // 通知列表里一眼能分辨「完成(绿)/ 待确认(橙)/ 测试(蓝)」。
384
+ func (n *feishuNotifier) sendCard(ctx context.Context, appID, appSecret, to, toType, title, color, body, link string) error {
385
+ tk, err := n.tenantToken(ctx, appID, appSecret)
386
+ if err != nil {
387
+ return err
388
+ }
389
+ // im/v1/messages 的 content 字段是**卡片 JSON 的字符串**(v1 结构:config + header + elements)。
390
+ payload := map[string]any{
391
+ "receive_id": to,
392
+ "msg_type": "interactive",
393
+ "content": mustJSON(feishuCard(title, color, body, link)),
394
+ }
395
+ if toType == "" {
396
+ toType = "open_id"
397
+ }
398
+ out, err := n.post(ctx, "/open-apis/im/v1/messages?receive_id_type="+toType, payload, tk)
399
+ if err != nil {
400
+ return err
401
+ }
402
+ var v struct {
403
+ Code int `json:"code"`
404
+ Msg string `json:"msg"`
405
+ }
406
+ if err := json.Unmarshal(out, &v); err != nil {
407
+ return fmt.Errorf("飞书发送响应无法解析:%v", err)
408
+ }
409
+ if v.Code != 0 {
410
+ // 令牌失效那几种码:作废缓存,下一次重新换。
411
+ switch v.Code {
412
+ case 99991663, 99991664, 99991668:
413
+ n.mu.Lock()
414
+ n.token = ""
415
+ n.mu.Unlock()
416
+ }
417
+ return fmt.Errorf("飞书拒绝发送:code=%d %s", v.Code, v.Msg)
418
+ }
419
+ return nil
420
+ }
421
+
422
+ /* ─────────────── 群自定义机器人(webhook) ─────────────── */
423
+
424
+ // webhookOK 只认自定义机器人的地址:<官方域名>/open-apis/bot/v2/hook/<token>。
425
+ // 其它地址(比如某个应用的回调)发过去只会得到一段不明所以的 404。
426
+ //
427
+ // ⚠️ 必须是 https + 官方域名:这条 URL 里的 token 就是发消息的凭证,允许随便是谁
428
+ // (http、任意主机)都等于让人把「往哪发」指到别处去。单测里 n.baseURL 指向假服务器时
429
+ // 只认那一个前缀 —— 测试替身不能顺手把生产校验放宽。
430
+ func (n *feishuNotifier) webhookOK(u string) bool {
431
+ const path = "/open-apis/bot/v2/hook/"
432
+ if n.baseURL != "" {
433
+ return strings.HasPrefix(u, n.baseURL+path) && len(u) > len(n.baseURL+path)
434
+ }
435
+ return strings.HasPrefix(u, "https://open.feishu.cn"+path) ||
436
+ strings.HasPrefix(u, "https://open.larksuite.com"+path)
437
+ }
438
+
439
+ // feishuWebhookSign 飞书自定义机器人的「签名校验」:以 `timestamp + "\n" + secret` 为密钥,
440
+ // 对**空字符串**做 HMAC-SHA256,再 base64。不是对请求体签名 —— 这里很容易写错。
441
+ func feishuWebhookSign(timestamp, secret string) string {
442
+ mac := hmac.New(sha256.New, []byte(timestamp+"\n"+secret))
443
+ mac.Write(nil)
444
+ return base64.StdEncoding.EncodeToString(mac.Sum(nil))
445
+ }
446
+
447
+ // sendWebhook 往群机器人发一张卡片。收件人是那个群 —— 不需要 open_id / 邮箱。
448
+ func (n *feishuNotifier) sendWebhook(ctx context.Context, hook, secret, title, color, body, link string) error {
449
+ payload := map[string]any{
450
+ "msg_type": "interactive",
451
+ "card": feishuCard(title, color, body, link),
452
+ }
453
+ if secret != "" {
454
+ ts := strconv.FormatInt(time.Now().Unix(), 10)
455
+ payload["timestamp"] = ts
456
+ payload["sign"] = feishuWebhookSign(ts, secret)
457
+ }
458
+ b, err := json.Marshal(payload)
459
+ if err != nil {
460
+ return err
461
+ }
462
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, hook, bytes.NewReader(b))
463
+ if err != nil {
464
+ return err
465
+ }
466
+ req.Header.Set("Content-Type", "application/json; charset=utf-8")
467
+ resp, err := n.client.Do(req)
468
+ if err != nil {
469
+ return err
470
+ }
471
+ defer resp.Body.Close()
472
+ out, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
473
+ if err != nil {
474
+ return err
475
+ }
476
+ if resp.StatusCode >= 300 {
477
+ return fmt.Errorf("飞书群机器人 HTTP %d:%s", resp.StatusCode, feishuErrText(out))
478
+ }
479
+ /* 响应有两种拼写:新版 {code,msg},老版 {StatusCode,StatusMessage}。都要认,
480
+ 否则「发失败但返回 200」会被当成成功 —— 用户那边就是「一条都没收到」。 */
481
+ var v struct {
482
+ Code *int `json:"code"`
483
+ Msg string `json:"msg"`
484
+ StatusCode *int `json:"StatusCode"`
485
+ StatusMessage string `json:"StatusMessage"`
486
+ }
487
+ if err := json.Unmarshal(out, &v); err != nil {
488
+ return fmt.Errorf("飞书群机器人响应无法解析:%v", err)
489
+ }
490
+ code, msg := 0, v.Msg
491
+ if v.Code != nil {
492
+ code = *v.Code
493
+ } else if v.StatusCode != nil {
494
+ code, msg = *v.StatusCode, v.StatusMessage
495
+ }
496
+ if code != 0 {
497
+ if code == 19021 {
498
+ msg = "签名校验失败(签名密钥填错了)"
499
+ }
500
+ return fmt.Errorf("飞书群机器人拒绝接收:code=%d %s", code, msg)
501
+ }
502
+ return nil
503
+ }
504
+
505
+ func mustJSON(v any) string {
506
+ b, _ := json.Marshal(v)
507
+ return string(b)
508
+ }
509
+
510
+ /* ─────────────── 绑定码 ─────────────── */
511
+
512
+ // newBindCode 6 位数字(纯数字方便在手机上转述 / 粘贴)。crypto/rand,不用 math/rand。
513
+ func newBindCode() (string, error) {
514
+ v, err := rand.Int(rand.Reader, big.NewInt(1000000))
515
+ if err != nil {
516
+ return "", err
517
+ }
518
+ return fmt.Sprintf("%06d", v.Int64()), nil
519
+ }
520
+
521
+ /* ─────────────── 推送(幂等 + 退避重试) ─────────────── */
522
+
523
+ // pushResult 一次推送调用的结果(HTTP 层用)。
524
+ type pushResult struct {
525
+ OK bool `json:"ok"`
526
+ Dupe bool `json:"dupe,omitempty"` // 这一轮已经推过(幂等命中)
527
+ Disabled bool `json:"disabled,omitempty"` // 没开 / 没绑 / 没配,什么也没做
528
+ Reason string `json:"reason,omitempty"`
529
+ }
530
+
531
+ // push 推一条。key 由 event + ask_id 组成(没有 ask_id 时退回标题),保证同一轮只推一次。
532
+ //
533
+ // 为什么幂等键在**服务端**而不是手机端:手机在多个 tab / 多台手机里各自轮询,
534
+ // 每台都有自己的基线;靠客户端去重 = 换个手机就重推一遍。服务端记一份,
535
+ // 谁先到谁推,后来的直接断定为 dupe。
536
+ func (n *feishuNotifier) push(ctx context.Context, appID, appSecret string, st feishuState, event, askID, title, body, link string) pushResult {
537
+ ev, ok := feishuEvents[event]
538
+ if !ok {
539
+ return pushResult{Reason: "未知的推送事件:" + event}
540
+ }
541
+ /* 开关 / 绑定 / 凭据在**幂等判重之后**判:不推、也不占键。
542
+ 两条通道:配了群机器人 webhook 就走它(不需要知道收件人是谁),
543
+ 否则走「飞书应用 + 收件人」。 */
544
+ to, toType := st.target()
545
+ useHook := st.Webhook != ""
546
+ if !useHook && to == "" {
547
+ return pushResult{Disabled: true, Reason: "还没绑定飞书账号(群机器人地址或收件人都没有)"}
548
+ }
549
+ if !st.Enabled {
550
+ return pushResult{Disabled: true, Reason: "飞书推送已关闭"}
551
+ }
552
+ if !useHook && (appID == "" || appSecret == "") {
553
+ return pushResult{Disabled: true, Reason: "飞书应用凭据未配置"}
554
+ }
555
+ key := event + "|" + askID
556
+ if askID == "" {
557
+ key = event + "|" + title
558
+ }
559
+
560
+ n.mu.Lock()
561
+ if _, dup := n.sent[key]; dup {
562
+ n.mu.Unlock()
563
+ return pushResult{OK: true, Dupe: true}
564
+ }
565
+ // 先把键占上:两台手机几乎同时上报时,后到的那台也会看到 dupe。
566
+ // 发送失败时下面会**释放**键,否则「失败一次就永远不再推」。
567
+ n.sent[key] = time.Now()
568
+ n.mu.Unlock()
569
+
570
+ if len([]rune(body)) > feishuMaxPushTextLen {
571
+ r := []rune(body)
572
+ body = string(r[:feishuMaxPushTextLen]) + "…"
573
+ }
574
+ fullTitle := "【" + ev.title + "】" + title
575
+
576
+ var lastErr error
577
+ for attempt := 1; attempt <= feishuSendAttempts; attempt++ {
578
+ var err error
579
+ if useHook {
580
+ err = n.sendWebhook(ctx, st.Webhook, st.WebhookSecret, fullTitle, ev.tag, body, link)
581
+ } else {
582
+ err = n.sendCard(ctx, appID, appSecret, to, toType, fullTitle, ev.tag, body, link)
583
+ }
584
+ if err == nil {
585
+ n.logPush(feishuPushLog{At: time.Now(), Event: event, AskID: askID, Title: title, Link: link, OK: true, Attempt: attempt})
586
+ return pushResult{OK: true}
587
+ }
588
+ lastErr = err
589
+ if ctx.Err() != nil {
590
+ break
591
+ }
592
+ if attempt < feishuSendAttempts {
593
+ select {
594
+ case <-ctx.Done():
595
+ case <-time.After(feishuRetryBackoff * time.Duration(attempt)):
596
+ }
597
+ }
598
+ }
599
+ n.mu.Lock()
600
+ delete(n.sent, key)
601
+ n.mu.Unlock()
602
+ msg := ""
603
+ if lastErr != nil {
604
+ msg = lastErr.Error()
605
+ }
606
+ n.logPush(feishuPushLog{At: time.Now(), Event: event, AskID: askID, Title: title, Link: link, OK: false, Attempt: feishuSendAttempts, Error: msg})
607
+ return pushResult{Reason: msg}
608
+ }
609
+
610
+ // logPush 把最近一次结果追加到落盘状态(只留最后 feishuRecentKeep 条)。
611
+ // 写失败无所谓 —— 这是排障记录,不能影响推送本身。
612
+ func (n *feishuNotifier) logPush(l feishuPushLog) {
613
+ n.mu.Lock()
614
+ defer n.mu.Unlock()
615
+ st, err := n.load()
616
+ if err != nil {
617
+ return
618
+ }
619
+ st.Recent = append(st.Recent, l)
620
+ if len(st.Recent) > feishuRecentKeep {
621
+ st.Recent = st.Recent[len(st.Recent)-feishuRecentKeep:]
622
+ }
623
+ _ = n.save(st)
624
+ }
625
+
626
+ /* ─────────────── 会话标题(推送正文用) ─────────────── */
627
+
628
+ // feishuAskTitle 给一个 ask_id 找会话标题:先查作业队列,再查标题表。
629
+ // 都没有时问句首行 / 「会话 <前缀>」兜底 —— 宁可不推,也不要推一条「任务完成:」的空卡片。
630
+ func (s *Service) feishuAskTitle(askID, sessionID string) (title, workspace string) {
631
+ if s.jobs != nil && askID != "" {
632
+ if j := s.jobs.get(askID); j != nil {
633
+ sessionID = j.SessionID
634
+ workspace = j.Workspace
635
+ if j.Prompt != "" {
636
+ title = firstLine(j.Prompt)
637
+ }
638
+ }
639
+ }
640
+ if sessionID != "" {
641
+ if t := strings.TrimSpace(loadTitles()[sessionID]); t != "" {
642
+ title = t
643
+ } else if title == "" {
644
+ title = "会话 " + shortID(sessionID)
645
+ }
646
+ }
647
+ return title, workspace
648
+ }
649
+
650
+ func firstLine(s string) string {
651
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
652
+ s = s[:i]
653
+ }
654
+ r := []rune(strings.TrimSpace(s))
655
+ if len(r) > 40 {
656
+ r = append(r[:40], '…')
657
+ }
658
+ return string(r)
659
+ }
660
+
661
+ func shortID(id string) string {
662
+ if len(id) > 8 {
663
+ return id[:8]
664
+ }
665
+ return id
666
+ }
667
+
668
+ /* ─────────────── 启动通知 ─────────────── */
669
+
670
+ /* 2026-10-26 用户:「重新启动后如果绑定了飞书 触发一下发送访问地址」。
671
+
672
+ 重启是唯一「手机一定会失联」的时刻(换网 / 换端口 / 隧道换域名),而飞书是唯一
673
+ 不依赖那条连接的通道 —— 服务起来后主动把**验证过能用**的入口推过去,用户不用回忆、
674
+ 也不用在飞书上翻上次那张卡片。
675
+
676
+ 两个约束:
677
+ · 没绑定 / 关了推送 → 一个请求都不发(启动不该产生任何出站流量);
678
+ · 地址必须过 /desk/health(feishuLink 的口径):客户端往往比隧道 / 网卡起得早,
679
+ 所以按 feishuStartupDelays 退避重试;**公网优先**(最多等 90 秒),一直等不到
680
+ 才用局域网入口兜底;两者都不通就不发 —— 一条「重启了」但没有地址的卡片没有用。 */
681
+
682
+ // feishuStartupDelays 启动后**第几秒**去试一次公网地址(绝对时间,不是每次睡多久)。
683
+ // 第一次立即试(只重启 magic-client 时隧道没断,落盘里就有域名);之后按退避再试。
684
+ //
685
+ // 为什么最长等到 90 秒:重启掌天瓶时 cloudflared 跟着重起,quick tunnel 的新域名
686
+ // 从「进程给了 hostname」到「公网真能解析、能回 /desk/health」实测要 90 秒上下
687
+ // (2026-10-26 20:12 重启:cloudflared 14 秒起、域名 96 秒后才验通)。中途就退回局域网,
688
+ // 等于把「不在同一个 Wi-Fi」的人送到打不开的地址上 —— 那正是用户报过的毛病。
689
+ // 是 var 不是 const:测试里改短,不用真等一分半。
690
+ var feishuStartupDelays = []time.Duration{
691
+ 0, 5 * time.Second, 10 * time.Second, 15 * time.Second, 20 * time.Second,
692
+ 30 * time.Second, 45 * time.Second, 60 * time.Second, 75 * time.Second, 90 * time.Second,
693
+ }
694
+
695
+ // NotifyStartup 服务启动后给已绑定的飞书推一条「当前访问地址」。
696
+ //
697
+ // lanEntry 是启动时已知的局域网入口(带令牌,由 cmd/magic-client 传入):没有公网入口
698
+ // 时用它兜底 —— 服务端算不出自己的监听地址,只有那一层知道。
699
+ //
700
+ // 由调用方 `go` 起来:里面会为了等隧道退避重试几十秒,不能挡住服务启动。
701
+ func (s *Service) NotifyStartup(lanEntry string) {
702
+ n := s.feishu()
703
+ st, err := n.load()
704
+ if err != nil {
705
+ s.logf("飞书启动通知:读状态失败:%v", err)
706
+ return
707
+ }
708
+ to, _ := st.target()
709
+ if st.Webhook == "" && to == "" {
710
+ return // 没绑定
711
+ }
712
+ if !st.Enabled {
713
+ return // 绑了但用户关了推送
714
+ }
715
+ id, sec, _ := s.credsOf(st)
716
+ if st.Webhook == "" && (id == "" || sec == "") {
717
+ s.logf("飞书启动通知:已绑定但没有应用凭据,跳过")
718
+ return
719
+ }
720
+
721
+ var link string
722
+ start := time.Now()
723
+ for _, at := range feishuStartupDelays {
724
+ if wait := time.Until(start.Add(at)); wait > 0 {
725
+ time.Sleep(wait)
726
+ }
727
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
728
+ link = s.feishuLink(ctx, "") // 公网优先:人可能不在同一个 Wi-Fi 里
729
+ cancel()
730
+ if link != "" {
731
+ break
732
+ }
733
+ }
734
+ if link == "" && strings.TrimSpace(lanEntry) != "" {
735
+ /* 一直没等到公网入口(没开公网 / 隧道根本起不来)才退回局域网 ——
736
+ 同 Wi-Fi 能打开,总比一条什么都没有的「重启了」强。 */
737
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
738
+ link = s.feishuLink(ctx, lanEntry)
739
+ cancel()
740
+ }
741
+ if link == "" {
742
+ s.logf("飞书启动通知:没有验证通过的可用地址,本次不发")
743
+ return
744
+ }
745
+
746
+ body := "桌面端已重新启动,当前访问地址:\n\n地址:" + link +
747
+ "\n(这个链接含访问令牌,别转发给别人)"
748
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
749
+ defer cancel()
750
+ // key 带 pid + 时间:每次启动都是独立一条,不跟上一轮的去重键撞。
751
+ key := fmt.Sprintf("startup-%d-%d", os.Getpid(), time.Now().Unix())
752
+ res := n.push(ctx, id, sec, st, "startup", key, "当前访问地址", body, link)
753
+ if res.Disabled || !res.OK {
754
+ s.logf("飞书启动通知:%s", res.Reason)
755
+ }
756
+ }
757
+
758
+ /* ─────────────── HTTP 端点 ─────────────── */
759
+
760
+ // handleFeishu 前缀路由:/desk/feishu 与 /desk/feishu/{action}。
761
+ //
762
+ // GET /desk/feishu → 状态(不含 secret)
763
+ // POST /desk/feishu/code → {code, expires_in}
764
+ // POST /desk/feishu/bind → {code, open_id} 绑定并**自动开启**
765
+ // POST /desk/feishu/unbind → 解绑(open_id 清空,enabled 置 false)
766
+ // POST /desk/feishu/enable → {enabled}
767
+ // POST /desk/feishu/config → {app_id, app_secret} 先验令牌,再落本机 0600
768
+ // POST /desk/feishu/test → 立即发一条测试卡片
769
+ // POST /desk/feishu/push → {event, ask_id, session_id?, title?, body?} 幂等推送
770
+ func (s *Service) handleFeishu(w http.ResponseWriter, r *http.Request) {
771
+ action := strings.Trim(strings.TrimPrefix(r.URL.Path, "/desk/feishu"), "/")
772
+ n := s.feishu()
773
+ if action == "" {
774
+ if r.Method != http.MethodGet {
775
+ writeErr(w, http.StatusMethodNotAllowed, "只支持 GET")
776
+ return
777
+ }
778
+ s.feishuWriteStatus(w, n)
779
+ return
780
+ }
781
+ if r.Method != http.MethodPost {
782
+ writeErr(w, http.StatusMethodNotAllowed, "只支持 POST")
783
+ return
784
+ }
785
+ var req struct {
786
+ Code string `json:"code"`
787
+ To string `json:"to"` // 邮箱 / open_id / chat_id,见 feishuDetectTarget
788
+ OpenID string `json:"open_id"` // 老客户端
789
+ Enabled *bool `json:"enabled"`
790
+ Webhook string `json:"webhook"`
791
+ WebhookSecret string `json:"webhook_secret"`
792
+ AppID string `json:"app_id"`
793
+ AppSecret string `json:"app_secret"`
794
+ Event string `json:"event"`
795
+ AskID string `json:"ask_id"`
796
+ SessionID string `json:"session_id"`
797
+ URL string `json:"url"` // 手机当前用的入口(拼卡片里那个「打开」按钮)
798
+ Title string `json:"title"`
799
+ Body string `json:"body"`
800
+ }
801
+ if r.ContentLength != 0 {
802
+ if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
803
+ writeErr(w, http.StatusBadRequest, "请求体不是合法 JSON:"+err.Error())
804
+ return
805
+ }
806
+ }
807
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
808
+ defer cancel()
809
+
810
+ switch action {
811
+ case "code":
812
+ st, err := n.load()
813
+ if err != nil {
814
+ writeErr(w, http.StatusInternalServerError, err.Error())
815
+ return
816
+ }
817
+ code, err := newBindCode()
818
+ if err != nil {
819
+ writeErr(w, http.StatusInternalServerError, "生成绑定码失败:"+err.Error())
820
+ return
821
+ }
822
+ st.Code, st.CodeAt = code, time.Now()
823
+ if err := n.save(st); err != nil {
824
+ writeErr(w, http.StatusInternalServerError, "写入绑定码失败:"+err.Error())
825
+ return
826
+ }
827
+ writeJSON(w, http.StatusOK, map[string]any{"code": code, "expires_in": int(feishuCodeTTL.Seconds())})
828
+
829
+ case "bind":
830
+ code := strings.TrimSpace(req.Code)
831
+ raw := strings.TrimSpace(req.To)
832
+ if raw == "" {
833
+ raw = strings.TrimSpace(req.OpenID) // 老客户端只发 open_id
834
+ }
835
+ to, toType, err := feishuDetectTarget(raw)
836
+ if err != nil {
837
+ writeErr(w, http.StatusBadRequest, err.Error())
838
+ return
839
+ }
840
+ st, err := n.load()
841
+ if err != nil {
842
+ writeErr(w, http.StatusInternalServerError, err.Error())
843
+ return
844
+ }
845
+ if st.Code == "" {
846
+ writeErr(w, http.StatusBadRequest, "还没有申请绑定码,请先点「获取绑定码」")
847
+ return
848
+ }
849
+ if time.Since(st.CodeAt) > feishuCodeTTL || st.CodeAt.After(time.Now()) {
850
+ writeErr(w, http.StatusBadRequest, "绑定码已过期,请重新获取")
851
+ return
852
+ }
853
+ // 先判过期再比码:过期的码填对了也该让它去重新申请,而不是说「不正确」。
854
+ if code != st.Code {
855
+ writeErr(w, http.StatusBadRequest, "绑定码不正确(6 位数字,10 分钟内有效)")
856
+ return
857
+ }
858
+ st.ReceiveID, st.ReceiveType, st.BoundAt = to, toType, time.Now()
859
+ st.OpenID = "" // 新模型只用 ReceiveID/ReceiveType,别再让两份不一致
860
+ st.Enabled = true
861
+ st.Code, st.CodeAt = "", time.Time{} // 一次性:用过即废
862
+ if err := n.save(st); err != nil {
863
+ writeErr(w, http.StatusInternalServerError, "写入绑定失败:"+err.Error())
864
+ return
865
+ }
866
+ // 绑好立刻推一条:用户不用等下一次任务跑完才知道通没通。
867
+ id, sec, _ := s.credsOf(st)
868
+ var pushRes pushResult
869
+ if id != "" && sec != "" {
870
+ pushRes = n.push(ctx, id, sec, st, "result", "bind-"+to, "飞书绑定成功",
871
+ "之后任务完成 / 待确认会推到这里。在「我的 → 通知」里可以关掉。", s.feishuLink(ctx, req.URL))
872
+ }
873
+ out := map[string]any{"ok": true, "to": to, "to_type": toType, "enabled": true, "push": pushRes}
874
+ if id == "" || sec == "" {
875
+ out["notice"] = "已绑定,但飞书应用凭据未配置,还没法发消息 —— 在「我的 → 通知」里填 App ID / Secret"
876
+ }
877
+ writeJSON(w, http.StatusOK, out)
878
+
879
+ /* 群自定义机器人(2026-10-26):用户要的是「把消息推到我的飞书」,不是「建一个应用」。
880
+ 填一条 webhook 地址就完事 —— 不用应用凭据、不用审批、不需要 open_id / 邮箱。
881
+ 落盘前**当场发一条测试卡片**:地址错了 / 签名错了现在就说,别等下一次任务跑完。 */
882
+ case "webhook":
883
+ hook := strings.TrimSpace(req.Webhook)
884
+ sec := strings.TrimSpace(req.WebhookSecret)
885
+ if !n.webhookOK(hook) {
886
+ writeErr(w, http.StatusBadRequest,
887
+ "这不像群机器人地址:应形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxxxxxxx")
888
+ return
889
+ }
890
+ st, err := n.load()
891
+ if err != nil {
892
+ writeErr(w, http.StatusInternalServerError, err.Error())
893
+ return
894
+ }
895
+ link := s.feishuLink(ctx, req.URL)
896
+ body := "绑定成功。之后任务跑完 / 等你确认时会推到这个群。"
897
+ if link != "" {
898
+ body += "\n\n地址:`" + link + "`\n(这个链接含访问令牌,别转发给别人)"
899
+ }
900
+ if err := n.sendWebhook(ctx, hook, sec, "【绑定】Magic Agent 飞书推送", "green", body, link); err != nil {
901
+ writeErr(w, http.StatusBadRequest, "测试发送失败:"+err.Error())
902
+ return
903
+ }
904
+ st.Webhook, st.WebhookSecret = hook, sec
905
+ st.Enabled, st.BoundAt = true, time.Now()
906
+ if err := n.save(st); err != nil {
907
+ writeErr(w, http.StatusInternalServerError, "写入绑定失败:"+err.Error())
908
+ return
909
+ }
910
+ n.mu.Lock()
911
+ n.sent = map[string]time.Time{}
912
+ n.mu.Unlock()
913
+ n.logPush(feishuPushLog{At: time.Now(), Event: "bind", Title: "群机器人绑定", OK: true, Attempt: 1})
914
+ s.feishuWriteStatus(w, n)
915
+
916
+ case "unbind":
917
+ st, err := n.load()
918
+ if err != nil {
919
+ writeErr(w, http.StatusInternalServerError, err.Error())
920
+ return
921
+ }
922
+ st.ReceiveID, st.ReceiveType, st.OpenID = "", "", ""
923
+ st.Webhook, st.WebhookSecret = "", ""
924
+ st.BoundAt, st.Enabled = time.Time{}, false
925
+ if err := n.save(st); err != nil {
926
+ writeErr(w, http.StatusInternalServerError, "写入失败:"+err.Error())
927
+ return
928
+ }
929
+ n.mu.Lock()
930
+ n.sent = map[string]time.Time{}
931
+ n.mu.Unlock()
932
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
933
+
934
+ case "enable":
935
+ st, err := n.load()
936
+ if err != nil {
937
+ writeErr(w, http.StatusInternalServerError, err.Error())
938
+ return
939
+ }
940
+ if req.Enabled == nil {
941
+ writeErr(w, http.StatusBadRequest, "缺少 enabled")
942
+ return
943
+ }
944
+ if *req.Enabled && st.Webhook == "" && func() bool { t, _ := st.target(); return t == "" }() {
945
+ writeErr(w, http.StatusBadRequest, "还没绑定飞书账号")
946
+ return
947
+ }
948
+ st.Enabled = *req.Enabled
949
+ if err := n.save(st); err != nil {
950
+ writeErr(w, http.StatusInternalServerError, "写入失败:"+err.Error())
951
+ return
952
+ }
953
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": st.Enabled})
954
+
955
+ case "config":
956
+ id := strings.TrimSpace(req.AppID)
957
+ sec := strings.TrimSpace(req.AppSecret)
958
+ if id == "" || sec == "" {
959
+ writeErr(w, http.StatusBadRequest, "App ID 与 App Secret 都要填")
960
+ return
961
+ }
962
+ if !strings.HasPrefix(id, "cli_") {
963
+ writeErr(w, http.StatusBadRequest, "App ID 形如 cli_xxxxxxxxxxxx")
964
+ return
965
+ }
966
+ /* 先验一次令牌再落盘:错的 secret 存进去只会变成「绑定成功、推送全失败」,
967
+ 用户在界面里看不出是哪一步坏了。验不过就当场报出来,一个字都不写。 */
968
+ if _, err := n.tenantToken(ctx, id, sec); err != nil {
969
+ writeErr(w, http.StatusBadRequest, "凭据校验失败:"+err.Error())
970
+ return
971
+ }
972
+ st, err := n.load()
973
+ if err != nil {
974
+ writeErr(w, http.StatusInternalServerError, err.Error())
975
+ return
976
+ }
977
+ st.AppID, st.AppSecret = id, sec
978
+ if err := n.save(st); err != nil {
979
+ writeErr(w, http.StatusInternalServerError, "写入失败:"+err.Error())
980
+ return
981
+ }
982
+ s.feishuWriteStatus(w, n)
983
+
984
+ case "test":
985
+ st, err := n.load()
986
+ if err != nil {
987
+ writeErr(w, http.StatusInternalServerError, err.Error())
988
+ return
989
+ }
990
+ to, toType := st.target()
991
+ link := s.feishuLink(ctx, req.URL)
992
+ body := "收到这条说明通道是通的。任务跑完 / 需要确认时会自动推。"
993
+ if link != "" {
994
+ body += "\n\n地址:`" + link + "`\n(这个链接含访问令牌,别转发给别人)"
995
+ }
996
+ if st.Webhook != "" {
997
+ if err := n.sendWebhook(ctx, st.Webhook, st.WebhookSecret, "【测试】Magic Agent 飞书推送", "blue", body, link); err != nil {
998
+ writeErr(w, http.StatusBadGateway, err.Error())
999
+ return
1000
+ }
1001
+ n.logPush(feishuPushLog{At: time.Now(), Event: "test", Title: "测试(群机器人)", OK: true, Attempt: 1})
1002
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
1003
+ return
1004
+ }
1005
+ if to == "" {
1006
+ writeErr(w, http.StatusBadRequest, "还没绑定飞书账号")
1007
+ return
1008
+ }
1009
+ id, sec, _ := s.credsOf(st)
1010
+ if id == "" || sec == "" {
1011
+ writeErr(w, http.StatusBadRequest, "飞书应用凭据未配置")
1012
+ return
1013
+ }
1014
+ if err := n.sendCard(ctx, id, sec, to, toType, "【测试】Magic Agent 飞书推送", "blue", body, link); err != nil {
1015
+ writeErr(w, http.StatusBadGateway, err.Error())
1016
+ return
1017
+ }
1018
+ n.logPush(feishuPushLog{At: time.Now(), Event: "test", Title: "测试", OK: true, Attempt: 1})
1019
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true})
1020
+
1021
+ case "push":
1022
+ st, err := n.load()
1023
+ if err != nil {
1024
+ writeErr(w, http.StatusInternalServerError, err.Error())
1025
+ return
1026
+ }
1027
+ id, sec, _ := s.credsOf(st)
1028
+ title, ws := req.Title, ""
1029
+ if title == "" {
1030
+ title, ws = s.feishuAskTitle(req.AskID, req.SessionID)
1031
+ }
1032
+ if title == "" {
1033
+ title = "会话"
1034
+ }
1035
+ body := req.Body
1036
+ if body == "" {
1037
+ var lines []string
1038
+ if ws != "" {
1039
+ lines = append(lines, "工作空间:`"+filepath.Base(ws)+"`")
1040
+ }
1041
+ switch req.Event {
1042
+ case "result":
1043
+ lines = append(lines, "这条会话跑完了一轮,打开 App 看结果。")
1044
+ case "confirm":
1045
+ lines = append(lines, "这条会话在等你确认,打开 App 处理。")
1046
+ }
1047
+ body = strings.Join(lines, "\n")
1048
+ }
1049
+ link := s.feishuLink(ctx, req.URL)
1050
+ if link != "" {
1051
+ /* 纯文本地址,不用 markdown 的 [文字](url):飞书取回的消息里把它摊成
1052
+ `[文字]($1)`,看着像坏链;裸链接反而会被自动识别成可点的。 */
1053
+ body += "\n\n地址:" + link
1054
+ }
1055
+ res := n.push(ctx, id, sec, st, req.Event, req.AskID, title, body, link)
1056
+ if res.Disabled {
1057
+ // 没开 / 没配不是错误:手机端照样会发,服务端回 200 + disabled,免得日志里全是红字。
1058
+ writeJSON(w, http.StatusOK, res)
1059
+ return
1060
+ }
1061
+ if !res.OK {
1062
+ writeErr(w, http.StatusBadGateway, "飞书推送失败:"+res.Reason)
1063
+ return
1064
+ }
1065
+ writeJSON(w, http.StatusOK, res)
1066
+
1067
+ default:
1068
+ writeErr(w, http.StatusNotFound, "未知的飞书接口:"+action)
1069
+ }
1070
+ }
1071
+
1072
+ // feishuDetectTarget 判断「推给谁」那串东西是什么,并挡掉明显填错的。
1073
+ //
1074
+ // 为什么允许邮箱(2026-10-26 用户实测反馈):open_id **是应用维度的** —— 同一个人在
1075
+ // 不同飞书应用下 open_id 不同,而且飞书客户端任何界面都不显示它。让用户手抄 open_id
1076
+ // 等于让他猜:他填了 `ou_me`,旧代码照收,绑成功、推送全失败,界面上还显示「已绑定」。
1077
+ // 邮箱是用户真知道的东西,im/v1/messages 原生支持 receive_id_type=email。
1078
+ func feishuDetectTarget(raw string) (id, typ string, err error) {
1079
+ v := strings.TrimSpace(raw)
1080
+ switch {
1081
+ case v == "":
1082
+ return "", "", fmt.Errorf("请填你的飞书邮箱(推荐)或 open_id")
1083
+ case strings.Contains(v, "@"):
1084
+ // 邮箱:@ 前后都要有东西,且域名带点。不做完整 RFC 校验,挡的是「手滑」。
1085
+ at := strings.LastIndexByte(v, '@')
1086
+ if at <= 0 || at == len(v)-1 || !strings.Contains(v[at+1:], ".") || strings.ContainsAny(v, " \t") {
1087
+ return "", "", fmt.Errorf("邮箱格式不对:%s", v)
1088
+ }
1089
+ return v, "email", nil
1090
+ case strings.HasPrefix(v, "oc_"):
1091
+ if len(v) < 10 {
1092
+ return "", "", fmt.Errorf("chat_id 看起来不完整(正常是 oc_ 开头的一长串)")
1093
+ }
1094
+ return v, "chat_id", nil
1095
+ case strings.HasPrefix(v, "ou_"):
1096
+ /* 正常 open_id 是 ou_ + 32 位。当初就是这里太宽松:`ou_me` 这种占位符
1097
+ 被当成合法 id 存了下来,之后每条推送都在飞书那边失败。 */
1098
+ if len(v) < 20 {
1099
+ return "", "", fmt.Errorf("open_id 看起来不完整(正常是 ou_ 开头 + 32 位)。建议改填飞书邮箱,不用找 open_id")
1100
+ }
1101
+ return v, "open_id", nil
1102
+ }
1103
+ return "", "", fmt.Errorf("认不出这是什么:请填飞书邮箱(如 you@company.com),或 ou_ 开头的 open_id,或 oc_ 开头的群 id")
1104
+ }
1105
+
1106
+ // maskTarget 状态接口里显示的收件人:邮箱留首字母与域名,open_id / chat_id 留末 6 位。
1107
+ func maskTarget(to, typ string) string {
1108
+ if typ == "email" {
1109
+ at := strings.LastIndexByte(to, '@')
1110
+ if at > 0 {
1111
+ return string([]rune(to)[:1]) + "***" + to[at:]
1112
+ }
1113
+ }
1114
+ return "…" + tail(to, 6)
1115
+ }
1116
+
1117
+ // feishuLink 拼「在飞书里点一下就回到 App」的地址。
1118
+ //
1119
+ // 优先用桌面端已知的**公网**入口(隧道由壳托管,重启不变,人在外面也能开);
1120
+ // 没有公网就用手机报上来的那个入口(它自己最清楚当前怎么连上的:局域网 / 隧道 / 域名)。
1121
+ // 都拿不到就返回空 —— 那张卡片只是没有按钮,其余照发,不能因此推不出去。
1122
+ //
1123
+ // ⚠️ 链接里带令牌:这条卡片等同把控制权交给能看见这个会话的人。
1124
+ // 所以正文里明写一句「别转发」,界面上也照实提示(见 index.html)。
1125
+ func (s *Service) feishuLink(ctx context.Context, fromClient string) string {
1126
+ /* 2026-10-26 用户实测:「发送的消息中的访问连接地址不对 无法访问」。
1127
+ 根因是**信了缓存**:实例里那个公网域名是上一轮隧道留下的,cloudflared 一重启就换域名,
1128
+ 老域名 TCP 都不通;而真正活着的那条隧道域名当时就在 metrics 里躺着。
1129
+ 所以这里不再「取一个像样的地址就发」,而是**逐个回打 /desk/health**,
1130
+ 只发能通的那个;一个都不通就不放链接(宁可没有按钮,也不给人一个打不开的门)。 */
1131
+ var cands []string
1132
+ add := func(u string) {
1133
+ u = strings.TrimRight(strings.TrimSpace(u), "/")
1134
+ if u == "" {
1135
+ return
1136
+ }
1137
+ for _, c := range cands {
1138
+ if c == u {
1139
+ return
1140
+ }
1141
+ }
1142
+ cands = append(cands, u)
1143
+ }
1144
+ // ① 手机此刻在用的入口:它至少对这台手机是通的。
1145
+ if strings.Contains(fromClient, "token=") {
1146
+ // 手机报上来的已经是完整链接,直接用(不再拼 /m/)。
1147
+ if healthOK(strings.SplitN(fromClient, "?", 2)[0], s.opts.Token, 2*time.Second) {
1148
+ return fromClient
1149
+ }
1150
+ } else {
1151
+ add(fromClient)
1152
+ }
1153
+ // ② 缓存里的公网域名 + 当前所有隧道域名(缓存可能是死的,隧道清单才是活的)。
1154
+ add(s.publicURLOrEmpty())
1155
+ for _, h := range quickTunnelHosts() {
1156
+ add("https://" + h)
1157
+ }
1158
+ /* ③ 局域网不再单独兜底:手机在自己报的 fromClient 里已经带了它所在的网段,
1159
+ 服务端不知道自己的监听地址(Options 没有这一项),瞎猜只会拼出打不开的地址。 */
1160
+
1161
+ /* 并发验(每个 4s 上限):quick tunnel 首包要过 DNS + TLS + 边缘转发,实测冷启动
1162
+ 一两秒很常见 —— 1.5s 会把**能用的**地址误判成死的(2026-10-26 实测:探针日志里
1163
+ 「没有验证通过的可用地址」紧接着就是「公网入口:https://…」,只是验得太急)。
1164
+ 并发是为了不让「任务完成」的推送为了拼一个按钮多等十几秒。 */
1165
+ okIdx := make([]bool, len(cands))
1166
+ var wg sync.WaitGroup
1167
+ for i, base := range cands {
1168
+ wg.Add(1)
1169
+ go func(i int, base string) {
1170
+ defer wg.Done()
1171
+ okIdx[i] = healthOK(base, s.opts.Token, 4*time.Second)
1172
+ }(i, base)
1173
+ }
1174
+ wg.Wait()
1175
+ for i, base := range cands {
1176
+ if !okIdx[i] {
1177
+ continue
1178
+ }
1179
+ if ctx.Err() != nil {
1180
+ return ""
1181
+ }
1182
+ /* 公网域名验通了就顺手纠正缓存:否则界面上还会一直显示那个死域名。 */
1183
+ if strings.HasPrefix(base, "https://") && base != s.publicURLOrEmpty() {
1184
+ if strings.Contains(base, ".trycloudflare.com") {
1185
+ s.applyProbeResult(base)
1186
+ }
1187
+ }
1188
+ if s.opts.Token == "" {
1189
+ return base + "/m/"
1190
+ }
1191
+ return base + "/m/?token=" + s.opts.Token
1192
+ }
1193
+ if s.log != nil {
1194
+ s.log.Printf("飞书卡片:没有验证通过的可用地址,本次不带链接")
1195
+ }
1196
+ return ""
1197
+ }
1198
+
1199
+ // feishuWriteStatus 状态(**绝不回显 secret**)。Configured 只说明「能换到令牌」的前置条件在。
1200
+ func (s *Service) feishuWriteStatus(w http.ResponseWriter, n *feishuNotifier) {
1201
+ out := map[string]any{
1202
+ "configured": false,
1203
+ "source": "",
1204
+ "app_id": "",
1205
+ "bound": false,
1206
+ "enabled": false,
1207
+ "recent": []any{},
1208
+ }
1209
+ st, err := n.load()
1210
+ if err != nil {
1211
+ out["error"] = err.Error()
1212
+ writeJSON(w, http.StatusOK, out)
1213
+ return
1214
+ }
1215
+ id, sec, src := s.credsOf(st)
1216
+ if id != "" && sec != "" {
1217
+ out["configured"], out["source"] = true, src
1218
+ }
1219
+ if id != "" {
1220
+ out["app_id"] = id
1221
+ }
1222
+ to, toType := st.target()
1223
+ useHook := st.Webhook != ""
1224
+ out["bound"] = useHook || to != ""
1225
+ out["enabled"] = st.Enabled && (useHook || to != "")
1226
+ switch {
1227
+ case useHook:
1228
+ out["mode"] = "webhook"
1229
+ out["webhook_tail"] = tail(st.Webhook, 8)
1230
+ out["configured"] = true
1231
+ if out["source"] == "" {
1232
+ out["source"] = "群机器人"
1233
+ }
1234
+ case to != "":
1235
+ out["mode"] = "app"
1236
+ }
1237
+ out["bound_type"] = toType
1238
+ if to != "" {
1239
+ out["bound_to"] = maskTarget(to, toType)
1240
+ }
1241
+ if !st.BoundAt.IsZero() {
1242
+ out["bound_at"] = st.BoundAt
1243
+ }
1244
+ if to != "" && toType == "open_id" {
1245
+ out["open_id_tail"] = tail(to, 6) // 老字段,PC / 老手机还在读
1246
+ }
1247
+ if len(st.Recent) > 0 {
1248
+ out["recent"] = st.Recent
1249
+ }
1250
+ writeJSON(w, http.StatusOK, out)
1251
+ }
1252
+
1253
+ func tail(s string, n int) string {
1254
+ r := []rune(s)
1255
+ if len(r) <= n {
1256
+ return string(r)
1257
+ }
1258
+ return string(r[len(r)-n:])
1259
+ }