package handler import ( "encoding/json" "fmt" "log" "net/http" "os" "strconv" "strings" "sync" "regexp" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge" ) // Regex patterns for markdown stripping. var ( mdBoldRe = regexp.MustCompile(`\*\*(.+?)\*\*`) mdItalicRe = regexp.MustCompile(`\*(.+?)\*`) mdStrikethroughRe = regexp.MustCompile(`~~(.+?)~~`) mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+`) ) // BridgeHandler exposes the Platform Bridge REST API. type BridgeHandler struct { router *bridge.PlatformRouter internalToken string channelsMu sync.RWMutex cachedChannels []bridge.ChannelInfo logFn func(platform, channelID, senderID, content string, success bool) // outgoing message logger } func NewBridgeHandler(router *bridge.PlatformRouter) *BridgeHandler { return &BridgeHandler{ router: router, internalToken: os.Getenv("INTERNAL_SERVICE_TOKEN"), } } // SetLogFunc sets the outgoing message logger callback. func (h *BridgeHandler) SetLogFunc(fn func(platform, channelID, senderID, content string, success bool)) { h.logFn = fn } func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/health", h.health) mux.HandleFunc("/api/v1/platforms", h.listPlatforms) mux.HandleFunc("/api/v1/platforms/", h.platformInfo) mux.HandleFunc("/api/v1/identities", h.listIdentities) mux.HandleFunc("/api/v1/internal/send-proactive", h.sendProactive) mux.HandleFunc("/api/v1/channels", h.listChannels) mux.HandleFunc("/api/v1/webhook/telegram", h.telegramWebhook) mux.HandleFunc("/api/v1/webhook/", h.genericWebhook) } func (h *BridgeHandler) health(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "service": "platform-bridge", "platforms": h.router.ListAdapters(), }) } func (h *BridgeHandler) listPlatforms(w http.ResponseWriter, r *http.Request) { names := h.router.ListAdapters() type platformSummary struct { Name string `json:"name"` Connected bool `json:"connected"` Caps bridge.PlatformCapabilities `json:"capabilities"` } var platforms []platformSummary for _, name := range names { a, err := h.router.GetAdapter(name) if err != nil { continue } platforms = append(platforms, platformSummary{ Name: name, Connected: a.IsConnected(), Caps: a.Capabilities(), }) } writeJSON(w, http.StatusOK, map[string]interface{}{"platforms": platforms, "total": len(platforms)}) } func (h *BridgeHandler) platformInfo(w http.ResponseWriter, r *http.Request) { name := r.URL.Path[len("/api/v1/platforms/"):] a, err := h.router.GetAdapter(name) if err != nil { writeJSON(w, http.StatusNotFound, errResp(err.Error())) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "name": name, "connected": a.IsConnected(), "capabilities": a.Capabilities(), }) } func (h *BridgeHandler) listIdentities(w http.ResponseWriter, r *http.Request) { all := h.router.ListAllIdentities() writeJSON(w, http.StatusOK, all) } // telegramWebhook receives updates from Telegram Bot API. func (h *BridgeHandler) telegramWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) return } // Parse into a generic map first to check for message presence. var raw map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid telegram update")) return } if _, hasMsg := raw["message"]; !hasMsg { writeJSON(w, http.StatusOK, map[string]string{"status": "ignored"}) return } _, err := h.router.RouteMessage("telegram", raw) if err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } // genericWebhook receives standard webhook payloads. func (h *BridgeHandler) genericWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) return } // Extract platform name from path: /api/v1/webhook/{platform} platform := r.URL.Path[len("/api/v1/webhook/"):] if platform == "" || platform == "telegram" { writeJSON(w, http.StatusBadRequest, errResp("specify platform name in path")) return } var payload map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid JSON payload")) return } response, err := h.router.RouteMessage(platform, &payload) if err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } // Convert to platform-specific format. msgs, err := h.router.SendResponse(response) if err != nil { writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "messages": msgs, "reply_to": response.ReplyTo, }) } // sendProactive handles internal proactive message delivery to platform adapters. // POST /api/v1/internal/send-proactive func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed")) return } // Validate internal token token := r.Header.Get("X-Internal-Token") if h.internalToken == "" || token != h.internalToken { writeJSON(w, http.StatusUnauthorized, errResp("unauthorized")) return } var req struct { Platform string `json:"platform"` ChatType string `json:"chat_type"` UserID string `json:"user_id"` GroupID string `json:"group_id"` AtUserID string `json:"at_user_id"` Content string `json:"content"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errResp("invalid request body")) return } if req.Platform == "" || req.ChatType == "" || req.Content == "" { writeJSON(w, http.StatusBadRequest, errResp("platform, chat_type, and content are required")) return } // Map chat type to OBv11 message_type msgType := req.ChatType if msgType != "private" && msgType != "group" { writeJSON(w, http.StatusBadRequest, errResp("chat_type must be private or group")) return } userID := parseIntSafe(req.UserID) groupID := parseIntSafe(req.GroupID) // 过滤 / 标签,去除 markdown 标记 content := filterActions(req.Content) content = convertMarkdownPlain(content) // 按 \n\n 和 ♪ 拆分为多条消息 messages := splitProactiveContent(content) // Prepend CQ @mention tag if at_user_id is specified atPrefix := "" if req.AtUserID != "" { atPrefix = fmt.Sprintf("[CQ:at,qq=%s] ", req.AtUserID) } // Resolve adapter adapterName := req.Platform var sendErr error for i, msg := range messages { fullMsg := atPrefix + msg if i > 0 { atPrefix = "" // only first message gets @mention } sendErr = h.router.SendProactive(adapterName, msgType, userID, groupID, fullMsg) if sendErr != nil { // Fallback: try other adapters with same platform name for _, name := range h.router.ListAdapters() { if a, aErr := h.router.GetAdapter(name); aErr == nil && a.PlatformName() == req.Platform && a.IsConnected() { adapterName = name sendErr = h.router.SendProactive(name, msgType, userID, groupID, fullMsg) break } } } if sendErr != nil { log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, sendErr) break } log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d msg=%d/%d", adapterName, msgType, userID, groupID, req.AtUserID, len(fullMsg), i+1, len(messages)) if h.logFn != nil { chID := req.GroupID if msgType == "private" { chID = req.UserID } h.logFn(adapterName, chID, "Cyrene", fullMsg, true) } } if sendErr != nil { writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+sendErr.Error())) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "success": true, "message": "消息已发送", }) } func parseIntSafe(s string) int64 { if s == "" { return 0 } n, err := strconv.ParseInt(s, 10, 64) if err != nil { return 0 } return n } func errResp(msg string) map[string]string { return map[string]string{"error": msg} } func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(data) } // filterActions removes and tags and their content. func filterActions(text string) string { tags := [][2]string{ {"", ""}, {"", ""}, } for _, t := range tags { openTag, closeTag := t[0], t[1] for { start := strings.Index(text, openTag) if start == -1 { break } end := strings.Index(text[start:], closeTag) if end == -1 { text = text[:start] + text[start+len(openTag):] continue } text = text[:start] + text[start+end+len(closeTag):] } } return strings.TrimSpace(text) } // convertMarkdownPlain strips basic markdown formatting. func convertMarkdownPlain(md string) string { md = mdBoldRe.ReplaceAllString(md, "$1") md = mdItalicRe.ReplaceAllString(md, "$1") md = mdStrikethroughRe.ReplaceAllString(md, "$1") md = mdHeadingRe.ReplaceAllString(md, "") return md } // splitProactiveContent splits long proactive content into multiple messages. // Strategy same as splitContent in cmd/main.go: split by \n\n, then by ♪. func splitProactiveContent(text string) []string { rawParts := strings.Split(text, "\n\n") var parts []string for _, p := range rawParts { p = strings.TrimSpace(p) if p == "" { continue } if strings.Contains(p, "♪") { for _, sub := range strings.Split(p, "♪") { sub = strings.TrimSpace(sub) if sub != "" { parts = append(parts, sub) } } } else { parts = append(parts, p) } } // Merge very short segments with neighbors (min 8 runes). const minRunes = 8 var merged []string for _, part := range parts { if len([]rune(part)) < minRunes && len(merged) > 0 { merged[len(merged)-1] = merged[len(merged)-1] + part } else { merged = append(merged, part) } } return merged }