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
+97 -11
View File
@@ -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
}