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 4ebdac42a1 fix: 【不发送】标签在sendProactive路径失效 + 群聊长消息截断防封控
- sendProactive端点新增【不发送】过滤(之前只有主回复路径有)
- sendProactive和主回复路径均增加群聊500字截断+最多2条限制
- 防止第三方平台适配器因过长消息触发封控

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-05 14:18:39 +08:00

394 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)
// 过滤 【不发送】 自我审查标签
if strings.Contains(content, "【不发送】") {
trimmed := content
if len([]rune(trimmed)) > 80 {
trimmed = string([]rune(trimmed)[:80]) + "..."
}
log.Printf("[send-proactive] 昔涟自我审查,跳过发送: %s", trimmed)
writeJSON(w, http.StatusOK, map[string]string{"status": "self_censored"})
return
}
// 按 \n\n 和 ♪ 拆分为多条消息
messages := splitProactiveContent(content)
// 群聊长消息截断:超过 500 字的单条消息截断,防止封控
if msgType == "group" {
const maxLen = 500
for i, msg := range messages {
runes := []rune(msg)
if len(runes) > maxLen {
messages[i] = string(runes[:maxLen]) + "\n…(内容过长已截断)"
log.Printf("[send-proactive] 群聊消息过长截断: %d→%d 字", len(runes), maxLen)
}
}
// 群聊最多2条
if len(messages) > 2 {
messages = messages[:2]
}
}
// 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
}