This repository has been archived on 2026-08-12. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Cyrene/backend/platform-bridge/internal/handler/bridge_handler.go
T
AskaEth 88755e131a fix: 工具名过滤、消息拆分提示词优化
- filterActions: 过滤DeepSeek直接输出工具名(如vision_analyze)的怪话
- 系统提示词: 明确告知用双换行(空行)拆分多条消息
- 群聊规则: 加入独立想法用双换行分隔的说明
2026-06-29 12:57:21 +08:00

367 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
// 过滤 <action>/<app> 标签,去除 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 <action> and <app> tags and their content.
func filterActions(text string) string {
tags := [][2]string{
{"<action>", "</action>"},
{"<app>", "</app>"},
}
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):]
}
}
// 过滤 LLM 直接输出工具名的情况(DeepSeek 有时不调用函数而是直接输出函数名)
text = strings.TrimSpace(text)
toolNames := []string{"vision_analyze", "video_analyze", "web_search", "web_fetch",
"iot_control", "iot_query", "host_exec", "os_exec", "knowledge_search"}
for _, name := range toolNames {
if text == name || strings.HasPrefix(text, name+"\n") || strings.HasPrefix(text, name+"") || strings.HasPrefix(text, name+":") {
return ""
}
}
return 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
}