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:
@@ -75,7 +75,8 @@ func main() {
|
||||
Platform: msg.Platform,
|
||||
ChannelID: msg.ChannelID,
|
||||
SenderID: msg.OriginalSenderUID,
|
||||
SenderName: msg.SenderName,
|
||||
SenderName: msg.OriginalSenderName,
|
||||
GroupName: msg.GroupName,
|
||||
Content: msg.Content,
|
||||
ContentType: msg.ContentType,
|
||||
MessageID: msg.MessageID,
|
||||
@@ -88,6 +89,29 @@ func main() {
|
||||
isBotMentioned := msg.BotUID != "" && containsString(msg.Mentions, msg.BotUID)
|
||||
isSilent := cfg.PlatformSilentEnabled && !isAdmin && !isBotMentioned
|
||||
|
||||
// Add message timestamp for AI context.
|
||||
if !msg.Timestamp.IsZero() {
|
||||
timeAgo := time.Since(msg.Timestamp)
|
||||
timeLabel := fmt.Sprintf("【消息时间: %s (%s前)】\n", msg.Timestamp.Format("15:04:05"), formatDuration(timeAgo))
|
||||
msg.Content = timeLabel + msg.Content
|
||||
}
|
||||
|
||||
// Enrich group messages with group name and sender info.
|
||||
if msg.ChannelType == "group" {
|
||||
groupLabel := msg.ChannelID
|
||||
if msg.GroupName != "" {
|
||||
groupLabel = truncateString(msg.GroupName, 8) + " " + msg.ChannelID
|
||||
}
|
||||
senderLabel := msg.OriginalSenderName
|
||||
if senderLabel == "" {
|
||||
senderLabel = msg.SenderName
|
||||
}
|
||||
if isAdmin {
|
||||
senderLabel = "【管理员】" + msg.OriginalSenderName
|
||||
}
|
||||
msg.Content = fmt.Sprintf("[群聊 %s] %s (%s):\n%s", groupLabel, senderLabel, msg.OriginalSenderUID, msg.Content)
|
||||
}
|
||||
|
||||
// Blocklist/whitelist check (admin always bypasses).
|
||||
if blocked := blocklistStore.IsBlocked(msg.ChannelType, msg.ChannelID, msg.OriginalSenderUID, isAdmin); blocked {
|
||||
msgLogger.Log(logging.LogEntry{
|
||||
@@ -122,6 +146,11 @@ func main() {
|
||||
groupSessionID := fmt.Sprintf("platform_%s_%s", msg.Platform, msg.ChannelID)
|
||||
|
||||
switch {
|
||||
case isMessageHistorical(msg, router):
|
||||
msg.RouteType = "silent"
|
||||
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
||||
response, routeErr = forwardToAICore(cfg, msg, "platform_silent", namespace, namespace, nil)
|
||||
|
||||
case isAdmin && !isBotMentioned && shouldAdminBeSilent(msg, router):
|
||||
msg.RouteType = "silent"
|
||||
namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID)
|
||||
@@ -189,6 +218,7 @@ func main() {
|
||||
ChannelID: msg.ChannelID,
|
||||
SenderID: msg.BotUID,
|
||||
SenderName: "Cyrene",
|
||||
GroupName: msg.GroupName,
|
||||
Content: rm.Content,
|
||||
ContentType: "text",
|
||||
Success: true,
|
||||
@@ -678,20 +708,30 @@ func parseSSEAndAccumulate(body string) string {
|
||||
return strings.Join(deltas, "")
|
||||
}
|
||||
|
||||
// splitContent splits text by \n\n into multiple ResponseMessage segments.
|
||||
// splitContent splits text by ♪ (sentence-break marker), then by \n\n within each segment.
|
||||
// Non-empty segments are each wrapped as a chat message; empty input returns a single empty message.
|
||||
func splitContent(text string) []bridge.ResponseMessage {
|
||||
parts := strings.Split(text, "\n\n")
|
||||
// First split by ♪ sentence-break marker.
|
||||
var rawParts []string
|
||||
if strings.Contains(text, "♪") {
|
||||
rawParts = strings.Split(text, "♪")
|
||||
} else {
|
||||
rawParts = strings.Split(text, "\n\n")
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range rawParts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
var msgs []bridge.ResponseMessage
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
msgs = append(msgs, bridge.ResponseMessage{
|
||||
DisplayType: "chat",
|
||||
Content: part,
|
||||
FormatMode: "plain",
|
||||
})
|
||||
}
|
||||
msgs = append(msgs, bridge.ResponseMessage{
|
||||
DisplayType: "chat",
|
||||
Content: part,
|
||||
FormatMode: "plain",
|
||||
})
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return []bridge.ResponseMessage{
|
||||
@@ -843,3 +883,49 @@ func syncAdminUIDs(m *bridge.IdentityMapper, platform string, fields map[string]
|
||||
}
|
||||
fmt.Printf("Synced admin identities for %s from config: %s\n", platform, raw)
|
||||
}
|
||||
|
||||
// formatDuration returns a human-readable duration string like "1h2m3s".
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
m := int(d.Minutes())
|
||||
s := int(d.Seconds()) % 60
|
||||
if s > 0 {
|
||||
return fmt.Sprintf("%dm%ds", m, s)
|
||||
}
|
||||
return fmt.Sprintf("%dm", m)
|
||||
}
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dh%dm", h, m)
|
||||
}
|
||||
return fmt.Sprintf("%dh", h)
|
||||
}
|
||||
|
||||
// truncateString truncates a string to maxRunes runes, appending "…" if truncated.
|
||||
func truncateString(s string, maxRunes int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxRunes]) + "…"
|
||||
}
|
||||
|
||||
// isMessageHistorical returns true if the message timestamp is before the adapter's connection time,
|
||||
// indicating it is a replayed historical message that should be silently observed.
|
||||
func isMessageHistorical(msg *bridge.UnifiedMessage, router *bridge.PlatformRouter) bool {
|
||||
if msg.Timestamp.IsZero() {
|
||||
return false
|
||||
}
|
||||
for _, a := range router.GetAdaptersByPlatform(msg.Platform) {
|
||||
if connectedAt, ok := a.(interface{ ConnectedAt() time.Time }); ok {
|
||||
if msg.Timestamp.Before(connectedAt.ConnectedAt()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user