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 44048b333a fix: 死锁修复、管理员权限集中管控、群聊频率控制、表情映射修复
thinker.go: 移除所有 defer t.muUnlock() 持锁跨阻塞调用的模式,消除8处死锁点
- performThink: defer→立即解锁,LLM调用不再持锁
- lightThinkLoop: defer在for循环内→第2次迭代自死锁
- resetSilenceTimer: defer持锁调performThink
- UpdatePresence: defer持锁调time.Sleep+performThink
- storeThought: defer+panic→锁泄露; 移除extractProactiveMessage嵌套锁

is_admin三层防御:
- synthesizer: 系统提示词注入管理员/非管理员身份标签
- iot_provider: 非管理员直接拒绝IoT操作
- plugin-manager: ToolDefinition.AdminOnly自动拦截,集中管控

群聊优化:
- group_ambient: 强化审查指令,【不发送】自审查标签
- 群聊间隔4s→3s,最多2条/轮
- 工具失败也推跟进消息,避免沉默

平台桥接:
- 日志文件名使用适配器唯一标识符(ConfigName)
- QQ表情映射替换为官方116条目数据
- CQ表情保留名称/ID
2026-06-28 20:22:09 +08:00

358 lines
10 KiB
Go

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):]
}
}
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
}