4ebdac42a1
- sendProactive端点新增【不发送】过滤(之前只有主回复路径有) - sendProactive和主回复路径均增加群聊500字截断+最多2条限制 - 防止第三方平台适配器因过长消息触发封控 Co-Authored-By: Claude <noreply@anthropic.com>
394 lines
11 KiB
Go
394 lines
11 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)
|
||
|
||
// 过滤 【不发送】 自我审查标签
|
||
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
|
||
}
|