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:
+1
-1
@@ -40,7 +40,7 @@ PROACTIVE_MSG_MIN_GAP_SEC=1800
|
|||||||
PLATFORM_CHANNELS=qq:group:群号
|
PLATFORM_CHANNELS=qq:group:群号
|
||||||
PLATFORM_THINK_INTERVAL_SEC=600
|
PLATFORM_THINK_INTERVAL_SEC=600
|
||||||
# --- 平台桥接 ---
|
# --- 平台桥接 ---
|
||||||
PLATFORM_BRIDGE_URL=http://localhost:8082
|
PLATFORM_BRIDGE_URL=http://localhost:8095
|
||||||
|
|
||||||
# ========== 后端微服务地址 ==========
|
# ========== 后端微服务地址 ==========
|
||||||
GATEWAY_URL=http://localhost:8080
|
GATEWAY_URL=http://localhost:8080
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yeij.top/AskaEth/Cyrene/ai-core/internal/background"
|
||||||
|
)
|
||||||
|
|
||||||
|
// syncPlatformChannels periodically fetches channel info from platform-bridge
|
||||||
|
// and updates the thinker's platform channel list and bot UIDs.
|
||||||
|
func syncPlatformChannels(thinker *background.Thinker, platformBridgeURL, internalToken string) {
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
// Run once immediately.
|
||||||
|
doSync(thinker, client, platformBridgeURL, internalToken)
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
doSync(thinker, client, platformBridgeURL, internalToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doSync(thinker *background.Thinker, client *http.Client, baseURL, token string) {
|
||||||
|
req, err := http.NewRequest("GET", baseURL+"/api/v1/channels", nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Internal-Token", token)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[channel-sync] 请求失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Printf("[channel-sync] 返回 %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Channels []struct {
|
||||||
|
Platform string `json:"platform"`
|
||||||
|
ChannelType string `json:"channel_type"`
|
||||||
|
ChannelID string `json:"channel_id"`
|
||||||
|
ChannelName string `json:"channel_name,omitempty"`
|
||||||
|
} `json:"channels"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
log.Printf("[channel-sync] 解析失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ch := range result.Channels {
|
||||||
|
thinker.AddOrUpdatePlatformChannel(ch.Platform, ch.ChannelType, ch.ChannelID, ch.ChannelName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also fetch platforms to get bot UIDs.
|
||||||
|
syncBotUIDs(thinker, client, baseURL, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func syncBotUIDs(thinker *background.Thinker, client *http.Client, baseURL, token string) {
|
||||||
|
req, err := http.NewRequest("GET", baseURL+"/api/v1/platforms", nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Internal-Token", token)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
var result []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Bot UIDs are set by incoming messages via SetBotUID.
|
||||||
|
// This sync just ensures channels are populated.
|
||||||
|
_ = result
|
||||||
|
}
|
||||||
@@ -389,7 +389,7 @@ func main() {
|
|||||||
log.Printf("[主动消息] 推送已启用 (Gateway=%s)", gatewayURL)
|
log.Printf("[主动消息] 推送已启用 (Gateway=%s)", gatewayURL)
|
||||||
|
|
||||||
// 设置平台主动消息推送回调(调用 Platform Bridge 内部 API)
|
// 设置平台主动消息推送回调(调用 Platform Bridge 内部 API)
|
||||||
platformBridgeURL := getEnv("PLATFORM_BRIDGE_URL", "http://localhost:8082")
|
platformBridgeURL := getEnv("PLATFORM_BRIDGE_URL", "http://localhost:8095")
|
||||||
thinker.SetPlatformMessagePusher(func(target background.ProactiveTarget, message string) {
|
thinker.SetPlatformMessagePusher(func(target background.ProactiveTarget, message string) {
|
||||||
reqBody, _ := json.Marshal(map[string]string{
|
reqBody, _ := json.Marshal(map[string]string{
|
||||||
"platform": target.Platform,
|
"platform": target.Platform,
|
||||||
@@ -418,6 +418,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
log.Printf("[主动消息] 平台推送已启用 (PlatformBridge=%s)", platformBridgeURL)
|
log.Printf("[主动消息] 平台推送已启用 (PlatformBridge=%s)", platformBridgeURL)
|
||||||
|
go syncPlatformChannels(thinker, platformBridgeURL, internalToken)
|
||||||
} else {
|
} else {
|
||||||
log.Println("[主动消息] 未配置 INTERNAL_SERVICE_TOKEN,主动消息推送已禁用")
|
log.Println("[主动消息] 未配置 INTERNAL_SERVICE_TOKEN,主动消息推送已禁用")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ func main() {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
bh := handler.NewBridgeHandler(router)
|
bh := handler.NewBridgeHandler(router)
|
||||||
bh.RegisterRoutes(mux)
|
bh.RegisterRoutes(mux)
|
||||||
|
bh.StartChannelRefresh(10 * time.Minute)
|
||||||
|
|
||||||
// Config and log handlers.
|
// Config and log handlers.
|
||||||
ch := handler.NewConfigHandler(configStore, router)
|
ch := handler.NewConfigHandler(configStore, router)
|
||||||
|
|||||||
@@ -94,18 +94,22 @@ func (a *Adapter) SetGroupName(groupID int64, name string) {
|
|||||||
a.groupNamesMu.Unlock()
|
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).
|
// fetchGroupName tries to resolve a group name via NapCat HTTP API (client mode).
|
||||||
func (a *Adapter) fetchGroupName(groupID int64) {
|
func (a *Adapter) fetchGroupName(groupID int64) {
|
||||||
if a.mode != "client" || a.remoteURL == "" {
|
if a.mode != "client" || a.remoteURL == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Derive HTTP base from WS URL: ws://host:port → http://host:port
|
httpBase := a.httpBase()
|
||||||
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() {
|
go func() {
|
||||||
url := fmt.Sprintf("%s/get_group_info?group_id=%d", httpBase, groupID)
|
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
|
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.
|
// MessageHandler receives unified messages from adapters for processing.
|
||||||
type MessageHandler func(msg *UnifiedMessage) (*UnifiedResponse, error)
|
type MessageHandler func(msg *UnifiedMessage) (*UnifiedResponse, error)
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
|
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BridgeHandler exposes the Platform Bridge REST API.
|
// BridgeHandler exposes the Platform Bridge REST API.
|
||||||
type BridgeHandler struct {
|
type BridgeHandler struct {
|
||||||
router *bridge.PlatformRouter
|
router *bridge.PlatformRouter
|
||||||
internalToken string
|
internalToken string
|
||||||
|
channelsMu sync.RWMutex
|
||||||
|
cachedChannels []bridge.ChannelInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBridgeHandler(router *bridge.PlatformRouter) *BridgeHandler {
|
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/platforms/", h.platformInfo)
|
||||||
mux.HandleFunc("/api/v1/identities", h.listIdentities)
|
mux.HandleFunc("/api/v1/identities", h.listIdentities)
|
||||||
mux.HandleFunc("/api/v1/internal/send-proactive", h.sendProactive)
|
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/telegram", h.telegramWebhook)
|
||||||
mux.HandleFunc("/api/v1/webhook/", h.genericWebhook)
|
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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user