fix: 消息日志增强 + 历史消息抑制 + SSE实时追踪 + 群聊上下文优化

- 日志:收/发消息均显示群名称,管理员显示真实QQ昵称而非"开拓者"
- 历史消息:服务重启后NapCat回放的历史消息不再触发回复,静默注入上下文
- 消息时间戳:转发给AI时附带【消息时间: HH:MM:SS (XmXs前)】标记
- ♪ 分割符:QQ消息支持♪作为句子断点
- AI-Core SSE端点:全链路追踪实时推送,ethend不再5秒轮询
- 群聊上下文:AI-Core明确被告知消息来自群聊,以实际发送者为主语

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 11:49:36 +08:00
parent 677385ec17
commit 3ad728406e
13 changed files with 587 additions and 156 deletions
@@ -34,8 +34,12 @@ type Adapter struct {
conn *websocket.Conn
connMu sync.Mutex
connected bool
connectedAt time.Time // when the connection was established (for historical message detection)
srv *http.Server // HTTP server for WS upgrades (server mode only)
groupNames map[int64]string // group ID → group name cache
groupNamesMu sync.RWMutex
pendingResponses map[string]chan *OBv11APIResponse
respMu sync.Mutex
}
@@ -52,6 +56,7 @@ func NewAdapter(configName, mode, port, accessToken, remoteURL string, sendInter
remoteURL: remoteURL,
sendIntervalMs: sendIntervalMs,
pendingResponses: make(map[string]chan *OBv11APIResponse),
groupNames: make(map[int64]string),
}
}
@@ -60,6 +65,76 @@ func (a *Adapter) ConfigName() string { return a.configName }
func (a *Adapter) SendIntervalMs() int { return a.sendIntervalMs }
func (a *Adapter) SelfID() string { return a.selfID }
// ConnectedAt returns the time the connection was established, for historical message detection.
func (a *Adapter) ConnectedAt() time.Time {
a.connMu.Lock()
defer a.connMu.Unlock()
return a.connectedAt
}
// GroupName resolves a group ID to its name. Returns empty string if unknown.
func (a *Adapter) GroupName(groupID int64) string {
a.groupNamesMu.RLock()
n, ok := a.groupNames[groupID]
a.groupNamesMu.RUnlock()
if ok {
return n
}
// Try to resolve via API.
a.fetchGroupName(groupID)
return ""
}
// SetGroupName caches a group name for a group ID.
func (a *Adapter) SetGroupName(groupID int64, name string) {
a.groupNamesMu.Lock()
a.groupNames[groupID] = name
a.groupNamesMu.Unlock()
}
// fetchGroupName tries to resolve a group name via NapCat HTTP API (client mode).
func (a *Adapter) fetchGroupName(groupID int64) {
if a.mode != "client" || a.remoteURL == "" {
return
}
// Derive HTTP base from WS URL: ws://host:port → http://host:port
httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
// Strip path suffix if present
if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
httpBase = httpBase[:idx]
}
go func() {
url := fmt.Sprintf("%s/get_group_info?group_id=%d", httpBase, groupID)
if a.accessToken != "" {
url += "&access_token=" + a.accessToken
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
var result struct {
Data struct {
GroupName string `json:"group_name"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return
}
if result.Data.GroupName != "" {
a.SetGroupName(groupID, result.Data.GroupName)
}
}()
}
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 200,
@@ -109,6 +184,7 @@ func (a *Adapter) wsHandler(w http.ResponseWriter, r *http.Request) {
}
a.conn = conn
a.connected = true
a.connectedAt = time.Now()
a.connMu.Unlock()
fmt.Println("[qq] NapCat/OneBot connected (正向WS)")
}
@@ -148,6 +224,7 @@ func (a *Adapter) connectClient(ctx context.Context) error {
}
a.conn = conn
a.connected = true
a.connectedAt = time.Now()
a.connMu.Unlock()
fmt.Printf("[qq] connected to NapCat OneBot WS (client mode): %s\n", a.remoteURL)
@@ -257,6 +334,26 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
}
}
}
// Fallback: parse CQ at codes from string format (e.g. [CQ:at,qq=2254389756]).
if len(mentions) == 0 {
raw := msg.RawMessage
if raw == "" {
if s, ok := msg.Message.(string); ok {
raw = s
}
}
for _, m := range cqAtRegex.FindAllStringSubmatch(raw, -1) {
if len(m) >= 2 {
mentions = append(mentions, m[1])
}
}
}
// Resolve group name for group messages.
groupName := ""
if msg.MessageType == "group" {
groupName = a.GroupName(msg.GroupID)
}
attachments := extractAttachments(msg)
@@ -273,6 +370,7 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
Attachments: attachments,
RawData: rawMessage,
Timestamp: time.Unix(msg.Time, 0),
GroupName: groupName,
}, nil
}
@@ -416,6 +514,7 @@ func extractText(msg *OBv11Message) string {
return ""
}
var cqAtRegex = regexp.MustCompile(`\[CQ:at,qq=(\d+)\]`)
var cqImageRegex = regexp.MustCompile(`\[CQ:image,[^\]]*\]`)
var cqURLRegex = regexp.MustCompile(`\burl=([^,\]]+)`)
var boldRegex = regexp.MustCompile(`\*\*(.+?)\*\*`)
@@ -4,10 +4,14 @@ import (
"context"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions"
)
const participantTTL = 5 * time.Minute
// adapterKey returns the unique key for an adapter in the router map.
// Uses ConfigName() if the adapter implements it, otherwise PlatformName().
func adapterKey(a PlatformAdapter) string {
@@ -31,13 +35,14 @@ type PlatformRouter struct {
// ChannelContext stores the active conversation state for a channel.
type ChannelContext struct {
Platform string
ChannelID string
ChannelType string
LastUserMsg string
LastSenderUID string
RecentSenders []string // last 5 sender UIDs (original platform UIDs)
MessageCount int
Platform string
ChannelID string
ChannelType string
LastUserMsg string
LastSenderUID string
RecentSenders []string // last 5 sender UIDs (original platform UIDs)
ActiveParticipants map[string]time.Time // UID -> last bot reply time (for multi-user conversation continuity)
MessageCount int
}
func NewPlatformRouter(mapper *IdentityMapper, checker *permissions.Checker) *PlatformRouter {
@@ -137,6 +142,7 @@ func (r *PlatformRouter) RouteMessage(adapterKey string, rawMsg interface{}) (*U
// Preserve original platform UID before identity mapping.
unified.OriginalSenderUID = unified.SenderID
unified.OriginalSenderName = unified.SenderName
unified.OriginalRawMessage = rawMsg
// Capture bot's own UID for @mention detection.
@@ -228,3 +234,37 @@ func (r *PlatformRouter) GetContext(platform, channelID string) *ChannelContext
defer r.mu.RUnlock()
return r.contexts[platform+":"+channelID]
}
// NoteBotReply records that the bot just replied to a specific user in a channel.
// Used for conversation continuity: subsequent messages from this user continue the
// conversation even without an explicit @mention, within the participant TTL window.
func (r *PlatformRouter) NoteBotReply(platform, channelID, recipientUID string) {
r.mu.Lock()
defer r.mu.Unlock()
key := r.channelKey(platform, channelID)
ctx, ok := r.contexts[key]
if !ok {
return
}
if ctx.ActiveParticipants == nil {
ctx.ActiveParticipants = make(map[string]time.Time)
}
ctx.ActiveParticipants[recipientUID] = time.Now()
}
// IsActiveParticipant checks if a user was recently engaged by the bot.
// TTL controls how long the continuity window stays open after the last bot reply.
func (r *PlatformRouter) IsActiveParticipant(platform, channelID, uid string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
key := r.channelKey(platform, channelID)
ctx, ok := r.contexts[key]
if !ok || ctx.ActiveParticipants == nil {
return false
}
t, ok := ctx.ActiveParticipants[uid]
if !ok {
return false
}
return time.Since(t) < participantTTL
}
@@ -23,10 +23,12 @@ type UnifiedMessage struct {
Timestamp time.Time `json:"timestamp"`
// Routing metadata.
RouteType string `json:"route_type,omitempty"` // "normal", "silent", "admin_mention"
OriginalSenderUID string `json:"original_sender_uid,omitempty"` // preserved before identity mapping
OriginalRawMessage interface{} `json:"-"` // preserved for SendMessage wiring
BotUID string `json:"-"` // bot's own platform UID, set by router
RouteType string `json:"route_type,omitempty"` // "normal", "silent", "admin_mention"
OriginalSenderUID string `json:"original_sender_uid,omitempty"` // preserved before identity mapping
OriginalSenderName string `json:"original_sender_name,omitempty"` // preserved before identity mapping
GroupName string `json:"group_name,omitempty"` // resolved group name for group chats
OriginalRawMessage interface{} `json:"-"` // preserved for SendMessage wiring
BotUID string `json:"-"` // bot's own platform UID, set by router
}
// Attachment represents a file/image/voice attachment.
@@ -18,6 +18,7 @@ type LogEntry struct {
ChannelID string `json:"channel_id"`
SenderID string `json:"sender_id"`
SenderName string `json:"sender_name"`
GroupName string `json:"group_name,omitempty"`
Content string `json:"content"`
ContentType string `json:"content_type"`
MessageID string `json:"message_id,omitempty"`