refactor: QQ → OBv11 重命名 + 平台格式统一抽象

- 所有对外称呼从 QQ 改为 OBv11(注释/提示词/日志/配置项)
- 新增 PlatformFormat 结构体,统一管理平台消息标记格式
- defaultPlatformFormats() 注册表替代硬编码 qqTargetRe
- extractProactiveMessage 改为 Thinker 方法,遍历格式注册表匹配
- 配置项重命名: QQ_BOT_PORT → OBV11_BOT_PORT, QQBotPort → OBv11BotPort
- 标记格式: 【QQ群聊】→【OBv11群聊】、【QQ私聊】→【OBv11私聊】

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:48:07 +08:00
parent 70dbb23234
commit 41f653b672
25 changed files with 1609 additions and 82 deletions
+92 -51
View File
@@ -30,24 +30,59 @@ type PendingThought struct {
Consumed bool `json:"consumed"`
}
// ProactiveTarget describes the target for proactive platform messages (QQ, etc.).
// ProactiveTarget describes the target for proactive platform messages (OBv11, etc.).
// When nil, the message goes through the existing Web push path.
type ProactiveTarget struct {
Platform string // "qq"
ChatType string // "private" or "group"
UserID string // QQ user number
GroupID string // QQ group number (group chat)
AtUserID string // QQ number to @mention (optional)
UserID string // OBv11 user number
GroupID string // OBv11 group number (group chat)
AtUserID string // OBv11 number to @mention (optional)
}
// PlatformChannel represents a platform channel to observe for background thinking.
type PlatformChannel struct {
Platform string // qq, telegram, etc.
ChannelType string // group, private
ChannelID string // group ID or user QQ number
ChannelID string // group ID or user OBv11 number
ChannelName string // group name or user display name (resolved at runtime)
}
// PlatformFormat describes how proactive message targets are formatted for a platform.
type PlatformFormat struct {
// Human-readable platform label (e.g. "OBv11", "Telegram").
Label string
// Regex pattern with 3 capture groups: (chatType, channelID, atUserID).
// chatType group must contain the platform-specific chat type keyword (e.g. "群聊", "私聊").
TargetRegex string
// Compiled regex (populated at registration time).
compiled *regexp.Regexp
// Format verbs for building markers in prompts.
GroupMarkerFmt string // e.g. "【OBv11群聊:%s】"
PrivateMarkerFmt string // e.g. "【OBv11私聊:%s】"
GroupAtMarkerFmt string // e.g. "【OBv11群聊:%s@%s】"
// Chat type strings used by this platform in regex and markers.
GroupKeyword string // e.g. "群聊"
PrivateKeyword string // e.g. "私聊"
}
// defaultPlatformFormats returns the built-in platform format registry.
func defaultPlatformFormats() map[string]*PlatformFormat {
formats := make(map[string]*PlatformFormat)
qq := &PlatformFormat{
Label: "OBv11",
TargetRegex: `【OBv11(私聊|群聊):(\d+)(?:@(\d+))?】`,
GroupMarkerFmt: "【OBv11群聊:%s】",
PrivateMarkerFmt: "【OBv11私聊:%s】",
GroupAtMarkerFmt: "【OBv11群聊:%s@%s】",
GroupKeyword: "群聊",
PrivateKeyword: "私聊",
}
qq.compiled = regexp.MustCompile(qq.TargetRegex)
formats["qq"] = qq
return formats
}
// ParsePlatformChannels parses PLATFORM_CHANNELS env var.
// Format: "qq:group:123456,telegram:group:789012"
// Optional 4th field for display name: "qq:group:123456:群名称"
@@ -193,6 +228,9 @@ type Thinker struct {
// 平台 Bot UID (platform -> bot's own UID, e.g. "qq" -> "123456789")
botUIDs map[string]string
// 平台格式注册表 (platform -> format)
platformFormats map[string]*PlatformFormat
}
// AutonomousToolPolicy 自主思考工具调用安全策略
@@ -237,14 +275,14 @@ func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) {
t.messagePusher = pusher
}
// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (QQ, etc.).
// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (OBv11, etc.).
func (t *Thinker) SetPlatformMessagePusher(pusher func(ProactiveTarget, string)) {
t.mu.Lock()
defer t.mu.Unlock()
t.platformMessagePusher = pusher
}
// SetBotUID sets the bot's own platform UID (e.g., QQ number).
// SetBotUID sets the bot's own platform UID (e.g., OBv11 account).
func (t *Thinker) SetBotUID(platform, uid string) {
t.mu.Lock()
defer t.mu.Unlock()
@@ -412,6 +450,7 @@ func NewThinker(
proactiveGuard: DefaultProactiveGuard(),
platformChannels: cfg.PlatformChannels,
platformThinkInterval: cfg.PlatformSilentThinkInterval,
platformFormats: defaultPlatformFormats(),
}
}
@@ -1481,7 +1520,7 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("\n")
}
// QQ platform identity and available channels for proactive messaging.
// OBv11 platform identity and available channels for proactive messaging.
t.mu.Lock()
qqChannels := t.platformChannels
botUIDs := t.botUIDs
@@ -1489,11 +1528,11 @@ func (t *Thinker) buildThinkingUserPrompt(
lastMsgTime := t.lastUserMessage
t.mu.Unlock()
if len(qqChannels) > 0 {
sb.WriteString("\n\n【你的QQ平台身份与可用频道】\n")
sb.WriteString("\n\n【你的平台身份与可用频道】\n")
// Bot's own identity.
if qqBotUID, ok := botUIDs["qq"]; ok && qqBotUID != "" {
sb.WriteString(fmt.Sprintf("你在QQ上的账号是: %s(昔涟),这就是你。\n", qqBotUID))
sb.WriteString(fmt.Sprintf("你在OBv11上的账号是: %s(昔涟),这就是你。\n", qqBotUID))
}
// Active session context with time, group name, and trigger-aware guidance.
@@ -1518,23 +1557,23 @@ func (t *Thinker) buildThinkingUserPrompt(
if ch.ChannelType == "group" {
if triggerReason == "post_chat" {
if timeHint != "" && timeHint != "刚刚" {
sb.WriteString(fmt.Sprintf("【%s】你当前正在QQ群聊 %s 中。刚刚群里有人说了话——如果你想回应,用【主动消息】【QQ群聊:%s】格式输出。\n", timeHint, chLabel, activeChID))
sb.WriteString(fmt.Sprintf("【%s】你当前正在OBv11群聊 %s 中。刚刚群里有人说了话——如果你想回应,用【主动消息】【OBv11群聊:%s】格式输出。\n", timeHint, chLabel, activeChID))
} else {
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在QQ群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【QQ群聊:%s】格式输出。\n", chLabel, activeChID))
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在OBv11群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【OBv11群聊:%s】格式输出。\n", chLabel, activeChID))
}
} else {
// Autonomous thinking (periodic, silence, startup).
if timeHint != "" {
sb.WriteString(fmt.Sprintf("【上次活跃: %s】你当前在QQ群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【QQ群聊:%s】格式。\n", timeHint, chLabel, activeChID))
sb.WriteString(fmt.Sprintf("【上次活跃: %s】你当前在OBv11群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【OBv11群聊:%s】格式。\n", timeHint, chLabel, activeChID))
} else {
sb.WriteString(fmt.Sprintf("你当前在QQ群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【QQ群聊:%s】格式。\n", chLabel, activeChID))
sb.WriteString(fmt.Sprintf("你当前在OBv11群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【OBv11群聊:%s】格式。\n", chLabel, activeChID))
}
}
} else {
if triggerReason == "post_chat" {
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在与QQ用户 %s 私聊。想回应ta的话用【主动消息】【QQ私聊:%s】格式。\n", chLabel, activeChID))
sb.WriteString(fmt.Sprintf("【刚刚】你当前正在与OBv11用户 %s 私聊。想回应ta的话用【主动消息】【OBv11私聊:%s】格式。\n", chLabel, activeChID))
} else {
sb.WriteString(fmt.Sprintf("你当前在与QQ用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【QQ私聊:%s】格式。\n", chLabel, activeChID))
sb.WriteString(fmt.Sprintf("你当前在与OBv11用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【OBv11私聊:%s】格式。\n", chLabel, activeChID))
}
}
break
@@ -1543,10 +1582,10 @@ func (t *Thinker) buildThinkingUserPrompt(
}
sb.WriteString("\n发送主动消息的格式:\n")
sb.WriteString("- QQ私聊: 【主动消息】【QQ私聊:QQ号】消息内容\n")
sb.WriteString("- QQ群聊: 【主动消息】【QQ群聊:群号】消息内容\n")
sb.WriteString("- QQ群聊@某人: 【主动消息】【QQ群聊:群号@QQ号】消息内容\n")
sb.WriteString("\n可用的QQ频道列表:\n")
sb.WriteString("- OBv11私聊: 【主动消息】【OBv11私聊:号】消息内容\n")
sb.WriteString("- OBv11群聊: 【主动消息】【OBv11群聊:群号】消息内容\n")
sb.WriteString("- OBv11群聊@某人: 【主动消息】【OBv11群聊:群号@号】消息内容\n")
sb.WriteString("\n可用的平台频道列表:\n")
for _, ch := range qqChannels {
if ch.Platform != "qq" {
continue
@@ -1567,7 +1606,7 @@ func (t *Thinker) buildThinkingUserPrompt(
sb.WriteString("- 群聊消息要有公共价值,不要当成私聊\n")
sb.WriteString("- @某人时:确认这个人在群里有发言过,你的@内容是对他之前说的话的回应\n")
if triggerReason == "post_chat" {
sb.WriteString("- 重要:如果你在反思中想往QQ群发消息,必须用【主动消息】【QQ群聊:群号】格式,不要省略群号!\n")
sb.WriteString("- 重要:如果你在反思中想往平台群发消息,必须用【主动消息】【OBv11群聊:群号】格式,不要省略群号!\n")
}
}
@@ -1639,8 +1678,8 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:]
}
// Extract proactive message and optional QQ target.
proactiveMsg, proactiveTarget := extractProactiveMessage(content)
// Extract proactive message and optional platform target.
proactiveMsg, proactiveTarget := t.extractProactiveMessage(content)
// Prefer active session, fall back to admin main session.
pushSessionID := t.activeSessionID
if pushSessionID == "" {
@@ -1729,13 +1768,10 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou
//
// 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及
// 当作指令(如 "不需要写【主动消息】" 这类否定表述)。
// qqTargetRe matches QQ target markers like 【QQ私聊:123456】, 【QQ群聊:789012】, 【QQ群聊:789012@123456】
var qqTargetRe = regexp.MustCompile(`【QQ(私聊|群聊):(\d+)(?:@(\d+))?】`)
// extractProactiveMessage extracts the 【主动消息】 marker and optional QQ target from thinking content.
// extractProactiveMessage extracts the 【主动消息】 marker and optional platform target from thinking content.
// Returns the message content and an optional ProactiveTarget for platform delivery.
// When target is nil, the message goes through the existing Web push path.
func extractProactiveMessage(content string) (string, *ProactiveTarget) {
func (t *Thinker) extractProactiveMessage(content string) (string, *ProactiveTarget) {
marker := "【主动消息】"
// Scan each line; only accept lines where the marker starts the line (ignoring leading whitespace).
@@ -1756,33 +1792,38 @@ func extractProactiveMessage(content string) (string, *ProactiveTarget) {
continue
}
// Parse optional QQ target marker right after 【主动消息】.
// Parse optional platform target marker right after 【主动消息】.
msg := raw
var target *ProactiveTarget
if loc := qqTargetRe.FindStringSubmatchIndex(raw); loc != nil && loc[0] == 0 {
m := qqTargetRe.FindStringSubmatch(raw)
if len(m) == 4 {
chatType := "private"
if m[1] == "群聊" {
chatType = "group"
}
target = &ProactiveTarget{
Platform: "qq",
ChatType: chatType,
UserID: m[2],
GroupID: m[2],
AtUserID: m[3],
}
// For private chat, UserID is the QQ number; GroupID stays empty.
if chatType == "private" {
target.GroupID = ""
}
}
// Remove the QQ target marker from the message content.
msg = strings.TrimSpace(raw[loc[1]:])
if msg == "" {
// Try each registered platform format.
for platform, pf := range t.platformFormats {
if pf.compiled == nil {
continue
}
if loc := pf.compiled.FindStringSubmatchIndex(raw); loc != nil && loc[0] == 0 {
m := pf.compiled.FindStringSubmatch(raw)
if len(m) == 4 {
chatType := "private"
if m[1] == pf.GroupKeyword {
chatType = "group"
}
target = &ProactiveTarget{
Platform: platform,
ChatType: chatType,
UserID: m[2],
GroupID: m[2],
AtUserID: m[3],
}
if chatType == "private" {
target.GroupID = ""
}
}
msg = strings.TrimSpace(raw[loc[1]:])
if msg == "" {
continue
}
break
}
}
// Limit message length (200 chars max, keep it short).
+1 -1
View File
@@ -96,7 +96,7 @@ func inferAudioFormat(urlStr, contentType string) string {
if strings.Contains(contentType, "audio/ogg") || strings.Contains(contentType, "opus") {
return "ogg"
}
return "amr" // default for QQ voice messages
return "amr" // default for OBv11 voice messages
}
func (p *DashScopeASRProvider) Transcribe(ctx context.Context, audioURL, language string) (string, error) {
+1 -1
View File
@@ -83,7 +83,7 @@ func (e *Extractor) extractObservationsWithLLM(ctx context.Context, message stri
观察到的消息: %s
请以JSON格式返回提取的记忆。这条消息来自群聊/频道,昔涟只是旁观者。
消息格式为:[群聊 群号] 发送者昵称 (QQ号):消息内容
消息格式为:[群聊 群号] 发送者昵称 (OBv11账号):消息内容
提取角度:这条消息中包含了什么关于消息发送者、讨论主题、事件或氛围的信息?
重要:请以实际发送者的名字为主语(如"某某说..."),不要统一用"开拓者"称呼所有发言者。
@@ -138,8 +138,8 @@ type ProcessParams struct {
Mode string // text / voice_msg / voice_assistant
Nickname string
ChannelType string // direct / group
ChannelID string // platform channel ID (group ID or private QQ number)
BotUID string // bot's own platform UID (e.g., QQ number)
ChannelID string // platform channel ID (group ID or private OBv11 number)
BotUID string // bot's own platform UID (e.g., OBv11 account)
}
// ProcessResult 处理结果
@@ -216,7 +216,7 @@ func (s *Synthesizer) buildSynthesizeMessages(params SynthesizeParams) []model.L
if params.ChannelType == "group" {
messages = append(messages, model.LLMMessage{
Role: model.RoleSystem,
Content: "【群聊上下文】这条消息来自QQ群聊。消息前缀 [群聊 群号] 昵称 (QQ号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请根据当前这条消息前缀中的发送者名字来称呼对方——即使你之前在历史对话中称呼过别人,也不要把之前用的称呼套在当前发送者身上。不同的人有不同的名字。只在对你说话或延续已有对话时才回复。",
Content: "【群聊上下文】这条消息来自OBv11群聊。消息前缀 [群聊 群号] 昵称 (OBv11账号) 标注了真实发送者。你不是在和开拓者一对一私聊,而是在群聊中和不同成员交流。请根据当前这条消息前缀中的发送者名字来称呼对方——即使你之前在历史对话中称呼过别人,也不要把之前用的称呼套在当前发送者身上。不同的人有不同的名字。只在对你说话或延续已有对话时才回复。",
})
}