package core import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" ) const ( feishuAccountsBaseURL = "https://accounts.feishu.cn" larkAccountsBaseURL = "https://accounts.larksuite.com" weixinDefaultAPIURL = "https://ilinkai.weixin.qq.com" ) // ── Request types for setup save callbacks ────────────────── type FeishuSetupSaveRequest struct { ProjectName string `json:"project"` AppID string `json:"app_id"` AppSecret string `json:"app_secret"` PlatformType string `json:"platform_type"` OwnerOpenID string `json:"owner_open_id"` WorkDir string `json:"work_dir"` AgentType string `json:"agent_type"` } type WeixinSetupSaveRequest struct { ProjectName string `json:"project"` Token string `json:"token"` BaseURL string `json:"base_url"` IlinkBotID string `json:"ilink_bot_id"` IlinkUserID string `json:"ilink_user_id"` WorkDir string `json:"work_dir"` AgentType string `json:"agent_type"` } // ── Feishu / Lark QR Setup ────────────────────────────────── func (m *ManagementServer) handleSetupFeishuBegin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { mgmtError(w, http.StatusMethodNotAllowed, "POST only") return } client := &http.Client{Timeout: 15 * time.Second} initResp, err := feishuRegistrationCall(client, feishuAccountsBaseURL, "init", nil) if err != nil { mgmtError(w, http.StatusBadGateway, "feishu init: "+err.Error()) return } if errMsg, _ := initResp["error"].(string); errMsg != "" { desc, _ := initResp["error_description"].(string) mgmtError(w, http.StatusBadGateway, fmt.Sprintf("feishu init: %s: %s", errMsg, desc)) return } beginResp, err := feishuRegistrationCall(client, feishuAccountsBaseURL, "begin", map[string]string{ "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id", }) if err != nil { mgmtError(w, http.StatusBadGateway, "feishu begin: "+err.Error()) return } if errMsg, _ := beginResp["error"].(string); errMsg != "" { desc, _ := beginResp["error_description"].(string) mgmtError(w, http.StatusBadGateway, fmt.Sprintf("feishu begin: %s: %s", errMsg, desc)) return } deviceCode, _ := beginResp["device_code"].(string) qrURL, _ := beginResp["verification_uri_complete"].(string) interval, _ := beginResp["interval"].(float64) expiresIn, _ := beginResp["expire_in"].(float64) if deviceCode == "" || qrURL == "" { mgmtError(w, http.StatusBadGateway, "feishu begin: incomplete response") return } mgmtJSON(w, http.StatusOK, map[string]any{ "device_code": deviceCode, "qr_url": qrURL, "interval": int(interval), "expires_in": int(expiresIn), }) } func (m *ManagementServer) handleSetupFeishuPoll(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { mgmtError(w, http.StatusMethodNotAllowed, "POST only") return } var req struct { DeviceCode string `json:"device_code"` BaseURL string `json:"base_url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { mgmtError(w, http.StatusBadRequest, "invalid JSON") return } if req.DeviceCode == "" { mgmtError(w, http.StatusBadRequest, "device_code required") return } baseURL := feishuAccountsBaseURL if req.BaseURL != "" { baseURL = req.BaseURL } client := &http.Client{Timeout: 15 * time.Second} // Retry up to 2 times to handle feishu→lark base URL auto-switch for attempt := 0; attempt < 2; attempt++ { pollResp, err := feishuRegistrationCall(client, baseURL, "poll", map[string]string{ "device_code": req.DeviceCode, }) if err != nil { mgmtError(w, http.StatusBadGateway, "feishu poll: "+err.Error()) return } if userInfo, ok := pollResp["user_info"].(map[string]any); ok { if brand, _ := userInfo["tenant_brand"].(string); strings.EqualFold(brand, "lark") { if baseURL != larkAccountsBaseURL { baseURL = larkAccountsBaseURL continue } } } result := map[string]any{ "status": "pending", "base_url": baseURL, } clientID, _ := pollResp["client_id"].(string) clientSecret, _ := pollResp["client_secret"].(string) if clientID != "" && clientSecret != "" { platform := "feishu" if userInfo, ok := pollResp["user_info"].(map[string]any); ok { if brand, _ := userInfo["tenant_brand"].(string); strings.EqualFold(brand, "lark") { platform = "lark" } if oid, _ := userInfo["open_id"].(string); oid != "" { result["owner_open_id"] = oid } } result["status"] = "completed" result["app_id"] = clientID result["app_secret"] = clientSecret result["platform"] = platform mgmtJSON(w, http.StatusOK, result) return } if errCode, _ := pollResp["error"].(string); errCode != "" { switch errCode { case "authorization_pending": // still pending case "slow_down": result["slow_down"] = true case "access_denied": result["status"] = "denied" case "expired_token": result["status"] = "expired" default: desc, _ := pollResp["error_description"].(string) result["status"] = "error" result["error"] = fmt.Sprintf("%s: %s", errCode, desc) } } mgmtJSON(w, http.StatusOK, result) return } mgmtJSON(w, http.StatusOK, map[string]any{"status": "pending", "base_url": baseURL}) } func (m *ManagementServer) handleSetupFeishuSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { mgmtError(w, http.StatusMethodNotAllowed, "POST only") return } var req FeishuSetupSaveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if req.ProjectName == "" || req.AppID == "" || req.AppSecret == "" { mgmtError(w, http.StatusBadRequest, "project, app_id, app_secret required") return } if m.setupFeishuSave == nil { mgmtError(w, http.StatusServiceUnavailable, "feishu setup save not configured") return } if err := m.setupFeishuSave(req); err != nil { mgmtError(w, http.StatusInternalServerError, "save: "+err.Error()) return } mgmtJSON(w, http.StatusOK, map[string]any{ "message": fmt.Sprintf("feishu platform configured for project %q", req.ProjectName), "restart_required": true, }) } func feishuRegistrationCall(client *http.Client, baseURL, action string, params map[string]string) (map[string]any, error) { form := url.Values{} form.Set("action", action) for k, v := range params { form.Set(k, v) } req, err := http.NewRequest(http.MethodPost, baseURL+"/oauth/v1/app/registration", strings.NewReader(form.Encode())) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return nil, err } var result map[string]any if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("decode: %w", err) } return result, nil } // ── Weixin (ilink) QR Setup ───────────────────────────────── func (m *ManagementServer) handleSetupWeixinBegin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { mgmtError(w, http.StatusMethodNotAllowed, "POST only") return } var req struct { APIURL string `json:"api_url"` } _ = json.NewDecoder(r.Body).Decode(&req) apiBase := weixinDefaultAPIURL if req.APIURL != "" { apiBase = strings.TrimRight(req.APIURL, "/") } u, err := url.Parse(apiBase + "/") if err != nil { mgmtError(w, http.StatusBadRequest, "invalid api_url") return } u = u.JoinPath("ilink", "bot", "get_bot_qrcode") q := u.Query() q.Set("bot_type", "3") u.RawQuery = q.Encode() ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { mgmtError(w, http.StatusInternalServerError, err.Error()) return } client := &http.Client{Timeout: 15 * time.Second} resp, err := client.Do(httpReq) if err != nil { mgmtError(w, http.StatusBadGateway, "weixin get_bot_qrcode: "+err.Error()) return } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if resp.StatusCode != http.StatusOK { mgmtError(w, http.StatusBadGateway, fmt.Sprintf("weixin get_bot_qrcode: http %d", resp.StatusCode)) return } var qrResp struct { QRCode string `json:"qrcode"` QRCodeImgContent string `json:"qrcode_img_content"` } if err := json.Unmarshal(body, &qrResp); err != nil { mgmtError(w, http.StatusBadGateway, "weixin decode: "+err.Error()) return } if qrResp.QRCodeImgContent == "" { slog.Warn("weixin begin: empty qrcode_img_content", "raw_body", string(body)) mgmtError(w, http.StatusBadGateway, "weixin: empty qrcode_img_content") return } slog.Info("weixin begin: QR generated", "qr_key", qrResp.QRCode, "qr_url_len", len(qrResp.QRCodeImgContent), "qr_url_prefix", truncateStr(strings.TrimSpace(qrResp.QRCodeImgContent), 80), ) mgmtJSON(w, http.StatusOK, map[string]any{ "qr_key": qrResp.QRCode, "qr_url": strings.TrimSpace(qrResp.QRCodeImgContent), }) } func (m *ManagementServer) handleSetupWeixinPoll(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { mgmtError(w, http.StatusMethodNotAllowed, "POST only") return } var req struct { QRKey string `json:"qr_key"` APIURL string `json:"api_url"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { mgmtError(w, http.StatusBadRequest, "invalid JSON") return } if req.QRKey == "" { mgmtError(w, http.StatusBadRequest, "qr_key required") return } apiBase := weixinDefaultAPIURL if req.APIURL != "" { apiBase = strings.TrimRight(req.APIURL, "/") } // Validate api_url before dereferencing — url.Parse returns a nil URL for // inputs like "://" or "%zz", and the previous `u, _ := url.Parse(...)` // then crashed the management server with a nil-pointer panic on the // next u.JoinPath call. handleSetupWeixinBegin already validates the // same field; mirror that here. u, err := url.Parse(apiBase + "/") if err != nil { mgmtError(w, http.StatusBadRequest, "invalid api_url") return } u = u.JoinPath("ilink", "bot", "get_qrcode_status") q := u.Query() q.Set("qrcode", req.QRKey) u.RawQuery = q.Encode() ctx, cancel := context.WithTimeout(r.Context(), 37*time.Second) defer cancel() httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { mgmtError(w, http.StatusInternalServerError, err.Error()) return } httpReq.Header.Set("iLink-App-ClientVersion", "1") slog.Info("weixin poll: calling ilink", "url", u.String(), "qr_key", req.QRKey) client := &http.Client{Timeout: 40 * time.Second} resp, err := client.Do(httpReq) if err != nil { slog.Warn("weixin poll: request error", "error", err) mgmtJSON(w, http.StatusOK, map[string]any{"status": "wait"}) return } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) slog.Info("weixin poll: ilink raw response", "http_status", resp.StatusCode, "body", truncateStr(string(body), 500), ) if resp.StatusCode != http.StatusOK { mgmtError(w, http.StatusBadGateway, fmt.Sprintf("weixin poll: http %d", resp.StatusCode)) return } var status struct { Status string `json:"status"` BotToken string `json:"bot_token"` IlinkBotID string `json:"ilink_bot_id"` BaseURL string `json:"baseurl"` IlinkUserID string `json:"ilink_user_id"` } if err := json.Unmarshal(body, &status); err != nil { slog.Warn("weixin poll: JSON decode failed", "error", err, "body", truncateStr(string(body), 300)) mgmtError(w, http.StatusBadGateway, "weixin decode: "+err.Error()) return } slog.Info("weixin poll: parsed status", "status", status.Status) result := map[string]any{"status": status.Status} if status.Status == "" { slog.Warn("weixin poll: empty status field, raw body", "body", truncateStr(string(body), 500)) result["status"] = "wait" } if status.Status == "confirmed" { result["bot_token"] = strings.TrimSpace(status.BotToken) result["ilink_bot_id"] = strings.TrimSpace(status.IlinkBotID) result["base_url"] = strings.TrimSpace(status.BaseURL) result["ilink_user_id"] = strings.TrimSpace(status.IlinkUserID) } mgmtJSON(w, http.StatusOK, result) } func (m *ManagementServer) handleSetupWeixinSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { mgmtError(w, http.StatusMethodNotAllowed, "POST only") return } var req WeixinSetupSaveRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if req.ProjectName == "" || req.Token == "" { mgmtError(w, http.StatusBadRequest, "project and token required") return } if m.setupWeixinSave == nil { mgmtError(w, http.StatusServiceUnavailable, "weixin setup save not configured") return } if err := m.setupWeixinSave(req); err != nil { mgmtError(w, http.StatusInternalServerError, "save: "+err.Error()) return } mgmtJSON(w, http.StatusOK, map[string]any{ "message": fmt.Sprintf("weixin platform configured for project %q", req.ProjectName), "restart_required": true, }) } // ── Generic platform add (manual config) ───────────────────── type AddPlatformRequest struct { Type string `json:"type"` Options map[string]any `json:"options"` WorkDir string `json:"work_dir"` AgentType string `json:"agent_type"` } func (m *ManagementServer) handleProjectAddPlatform(w http.ResponseWriter, r *http.Request, projectName string) { if r.Method != http.MethodPost { mgmtError(w, http.StatusMethodNotAllowed, "POST only") return } var req AddPlatformRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } if req.Type == "" { mgmtError(w, http.StatusBadRequest, "type is required") return } if m.addPlatformToProject == nil { mgmtError(w, http.StatusServiceUnavailable, "config persistence not available") return } if err := m.addPlatformToProject(projectName, req.Type, req.Options, req.WorkDir, req.AgentType); err != nil { mgmtError(w, http.StatusInternalServerError, "save config: "+err.Error()) return } mgmtJSON(w, http.StatusCreated, map[string]any{ "message": fmt.Sprintf("platform %q added to project %q", req.Type, projectName), "restart_required": true, }) }