feat: 频道自动发现 — platform-bridge定时刷新频道路由 + ai-core周期同步

platform-bridge:
- QQ adapter: FetchGroups/FetchFriends 从NapCat API获取
- ChannelLister接口 + ChannelInfo结构体
- GET /api/v1/channels 端点 + cachedChannels缓存
- StartChannelRefresh 每10分钟自动刷新

ai-core:
- channel_sync.go: 每5分钟从platform-bridge拉取频道
- 自动更新thinker的platformChannels + botUIDs
- 不再依赖PLATFORM_CHANNELS环境变量手动配置

修复:
- SendMessage显式设AutoEscape:false
- PLATFORM_BRIDGE_URL默认端口8082→8095
- OBv11Params.AutoEscape去掉omitempty

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-23 13:12:54 +08:00
parent c9bf839945
commit 6bf59f7eee
9 changed files with 316 additions and 11 deletions
@@ -94,18 +94,22 @@ func (a *Adapter) SetGroupName(groupID int64, name string) {
a.groupNamesMu.Unlock()
}
// httpBase derives the HTTP base URL from the WebSocket remote URL.
func (a *Adapter) httpBase() string {
httpBase := strings.Replace(a.remoteURL, "ws://", "http://", 1)
httpBase = strings.Replace(httpBase, "wss://", "https://", 1)
if idx := strings.LastIndex(httpBase, "/"); idx > 8 {
httpBase = httpBase[:idx]
}
return httpBase
}
// 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]
}
httpBase := a.httpBase()
go func() {
url := fmt.Sprintf("%s/get_group_info?group_id=%d", httpBase, groupID)
@@ -0,0 +1,135 @@
package qq
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)
// ListChannels implements bridge.ChannelLister — fetches groups + friends from NapCat.
func (a *Adapter) ListChannels() []bridge.ChannelInfo {
var all []bridge.ChannelInfo
all = append(all, a.FetchGroups()...)
all = append(all, a.FetchFriends()...)
return all
}
// FetchGroups fetches the group list from NapCat HTTP API and caches group names.
// Returns a list of channel info for all groups the bot is in.
func (a *Adapter) FetchGroups() []bridge.ChannelInfo {
if a.mode != "client" || a.remoteURL == "" {
return nil
}
httpBase := a.httpBase()
if httpBase == "" {
return nil
}
url := fmt.Sprintf("%s/get_group_list", httpBase)
if a.accessToken != "" {
url += "?access_token=" + a.accessToken
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
fmt.Printf("[qq:%s] get_group_list request failed: %v\n", a.configName, err)
return nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("[qq:%s] get_group_list failed: %v\n", a.configName, err)
return nil
}
defer resp.Body.Close()
var result struct {
Data []struct {
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Printf("[qq:%s] get_group_list parse failed: %v\n", a.configName, err)
return nil
}
var channels []bridge.ChannelInfo
for _, g := range result.Data {
if g.GroupName != "" {
a.SetGroupName(g.GroupID, g.GroupName)
}
channels = append(channels, bridge.ChannelInfo{
Platform: "qq",
ChannelType: "group",
ChannelID: fmt.Sprintf("%d", g.GroupID),
ChannelName: g.GroupName,
})
}
fmt.Printf("[qq:%s] 获取到 %d 个群聊\n", a.configName, len(channels))
return channels
}
// FetchFriends fetches the friend list from NapCat HTTP API.
// Returns a list of channel info for private chats.
func (a *Adapter) FetchFriends() []bridge.ChannelInfo {
if a.mode != "client" || a.remoteURL == "" {
return nil
}
httpBase := a.httpBase()
if httpBase == "" {
return nil
}
url := fmt.Sprintf("%s/get_friend_list", httpBase)
if a.accessToken != "" {
url += "?access_token=" + a.accessToken
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
fmt.Printf("[qq:%s] get_friend_list request failed: %v\n", a.configName, err)
return nil
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("[qq:%s] get_friend_list failed: %v\n", a.configName, err)
return nil
}
defer resp.Body.Close()
var result struct {
Data []struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Remark string `json:"remark"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
fmt.Printf("[qq:%s] get_friend_list parse failed: %v\n", a.configName, err)
return nil
}
var channels []bridge.ChannelInfo
for _, f := range result.Data {
name := f.Remark
if name == "" {
name = f.Nickname
}
channels = append(channels, bridge.ChannelInfo{
Platform: "qq",
ChannelType: "private",
ChannelID: fmt.Sprintf("%d", f.UserID),
ChannelName: name,
})
}
fmt.Printf("[qq:%s] 获取到 %d 个好友\n", a.configName, len(channels))
return channels
}
@@ -26,5 +26,19 @@ type ProactiveSender interface {
SendProactive(chatType string, userID, groupID int64, content string) error
}
// ChannelInfo describes a known chat channel (group or private).
type ChannelInfo struct {
Platform string `json:"platform"`
ChannelType string `json:"channel_type"` // "group", "private"
ChannelID string `json:"channel_id"`
ChannelName string `json:"channel_name,omitempty"`
}
// ChannelLister is an optional interface for adapters that can enumerate
// their channels (groups and friends) from the platform API.
type ChannelLister interface {
ListChannels() []ChannelInfo
}
// MessageHandler receives unified messages from adapters for processing.
type MessageHandler func(msg *UnifiedMessage) (*UnifiedResponse, error)
@@ -7,14 +7,17 @@ import (
"net/http"
"os"
"strconv"
"sync"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)
// BridgeHandler exposes the Platform Bridge REST API.
type BridgeHandler struct {
router *bridge.PlatformRouter
internalToken string
router *bridge.PlatformRouter
internalToken string
channelsMu sync.RWMutex
cachedChannels []bridge.ChannelInfo
}
func NewBridgeHandler(router *bridge.PlatformRouter) *BridgeHandler {
@@ -30,6 +33,7 @@ func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) {
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)
}
@@ -0,0 +1,59 @@
package handler
import (
"log"
"net/http"
"time"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)
// listChannels returns cached channels from all adapters (groups + friends).
// GET /api/v1/channels
func (h *BridgeHandler) listChannels(w http.ResponseWriter, r *http.Request) {
h.channelsMu.RLock()
channels := h.cachedChannels
h.channelsMu.RUnlock()
if channels == nil {
channels = []bridge.ChannelInfo{}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"channels": channels,
"total": len(channels),
})
}
// StartChannelRefresh periodically queries all adapters for their channel lists.
func (h *BridgeHandler) StartChannelRefresh(interval time.Duration) {
if interval <= 0 {
interval = 10 * time.Minute
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
log.Printf("[channel-refresh] 频道缓存刷新已启动 (间隔=%v)", interval)
h.refreshChannels()
for range ticker.C {
h.refreshChannels()
}
}()
}
// refreshChannels queries all ChannelLister adapters and updates the cache.
func (h *BridgeHandler) refreshChannels() {
var all []bridge.ChannelInfo
for _, name := range h.router.ListAdapters() {
a, err := h.router.GetAdapter(name)
if err != nil {
continue
}
if lister, ok := a.(bridge.ChannelLister); ok {
channels := lister.ListChannels()
all = append(all, channels...)
}
}
h.channelsMu.Lock()
h.cachedChannels = all
h.channelsMu.Unlock()
log.Printf("[channel-refresh] 频道缓存已更新: %d 个频道", len(all))
}