feat: Phase 4 多平台接入 — Platform Bridge + 6平台适配器 + 身份权限系统 (22文件, 2129行)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 16:19:57 +08:00
parent 717ad65b05
commit 965cce7192
22 changed files with 2129 additions and 2 deletions
+1
View File
@@ -7,6 +7,7 @@ use (
./memory-service
./pkg/logger
./plugin-manager
./platform-bridge
./tool-engine
./voice-service
)
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
discordstub "github.com/yourname/cyrene-ai/platform-bridge/internal/adapter/discord"
feishustub "github.com/yourname/cyrene-ai/platform-bridge/internal/adapter/feishu"
qqadapter "github.com/yourname/cyrene-ai/platform-bridge/internal/adapter/qq"
telegramadapter "github.com/yourname/cyrene-ai/platform-bridge/internal/adapter/telegram"
wechatstub "github.com/yourname/cyrene-ai/platform-bridge/internal/adapter/wechat"
webhookadapter "github.com/yourname/cyrene-ai/platform-bridge/internal/adapter/webhook"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
"github.com/yourname/cyrene-ai/platform-bridge/internal/config"
"github.com/yourname/cyrene-ai/platform-bridge/internal/handler"
"github.com/yourname/cyrene-ai/platform-bridge/internal/permissions"
)
func main() {
cfg := config.Load()
// Core components.
mapper := bridge.NewIdentityMapper()
checker := permissions.NewChecker()
router := bridge.NewPlatformRouter(mapper, checker)
// Seed default identities from environment.
seedIdentities(mapper)
// Register platform adapters.
adapters := []bridge.PlatformAdapter{
qqadapter.NewAdapter(cfg.QQBotPort),
telegramadapter.NewAdapter(cfg.TelegramToken, cfg.TelegramWebhookURL),
webhookadapter.NewAdapter("webhook"),
wechatstub.NewAdapter(),
feishustub.NewAdapter(),
discordstub.NewAdapter(),
}
for _, a := range adapters {
router.RegisterAdapter(a)
}
// Set message handler: forward to AI-Core.
router.SetMessageHandler(func(msg *bridge.UnifiedMessage) (*bridge.UnifiedResponse, error) {
return forwardToAICore(cfg, msg)
})
// Connect all adapters.
ctx := context.Background()
for _, a := range adapters {
if err := a.Connect(ctx); err != nil {
fmt.Printf("WARN: connect %s failed: %v\n", a.PlatformName(), err)
} else {
fmt.Printf("Platform adapter connected: %s\n", a.PlatformName())
}
}
// Setup HTTP server.
mux := http.NewServeMux()
bh := handler.NewBridgeHandler(router)
bh.RegisterRoutes(mux)
// Start QQ message reader loop.
qq, _ := router.GetAdapter("qq")
if qqa, ok := qq.(*qqadapter.Adapter); ok {
qqMsgCh := make(chan *qqadapter.OBv11Message, 100)
go qqa.ReadMessages(ctx, qqMsgCh)
go func() {
for msg := range qqMsgCh {
response, err := router.RouteMessage("qq", msg)
if err != nil {
fmt.Printf("[qq] route error: %v\n", err)
continue
}
// Send response back through QQ adapter.
msgs, err := router.SendResponse(response)
if err != nil {
fmt.Printf("[qq] send error: %v\n", err)
continue
}
_ = msgs // QQ adapter handles sending via WebSocket
}
}()
}
addr := ":" + cfg.Port
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
fmt.Printf("Platform Bridge listening on port %s\n", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("FATAL: %v\n", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
fmt.Println("Shutting down Platform Bridge...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for _, a := range adapters {
a.Disconnect(shutdownCtx)
}
srv.Shutdown(shutdownCtx)
fmt.Println("Platform Bridge stopped")
}
// forwardToAICore sends a unified message to AI-Core's chat endpoint and returns the response.
func forwardToAICore(cfg *config.Config, msg *bridge.UnifiedMessage) (*bridge.UnifiedResponse, error) {
reqBody, _ := json.Marshal(map[string]interface{}{
"user_id": msg.SenderID,
"session_id": fmt.Sprintf("platform_%s_%s", msg.Platform, msg.ChannelID),
"message": msg.Content,
"mode": "text",
"source": map[string]string{
"platform": msg.Platform,
"channel_id": msg.ChannelID,
"channel_type": msg.ChannelType,
"sender_name": msg.SenderName,
},
})
url := cfg.AICoreURL + "/api/v1/chat"
req, _ := http.NewRequest("POST", url, bytes.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
if cfg.InternalToken != "" {
req.Header.Set("X-Internal-Token", cfg.InternalToken)
}
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("forward to ai-core failed: %w", err)
}
defer resp.Body.Close()
// For simplicity, collect full text from SSE stream.
var result struct {
Content string `json:"content"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
// Non-JSON response — read as raw text.
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "chat", Content: buf.String(), FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
}
if result.Error != "" {
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "system_info", Content: result.Error, FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
}
return &bridge.UnifiedResponse{
Messages: []bridge.ResponseMessage{
{DisplayType: "chat", Content: result.Content, FormatMode: "plain"},
},
Platform: msg.Platform,
}, nil
}
// seedIdentities loads default identity mappings.
func seedIdentities(m *bridge.IdentityMapper) {
// Admin on QQ.
if qqAdmin := os.Getenv("QQ_ADMIN_UID"); qqAdmin != "" {
m.Register(permissions.PlatformIdentity{
Platform: "qq",
PlatformUID: qqAdmin,
CyreneUser: "admin",
Nickname: "开拓者",
PermissionLevel: "admin",
})
}
// Admin on Telegram.
if tgAdmin := os.Getenv("TELEGRAM_ADMIN_UID"); tgAdmin != "" {
m.Register(permissions.PlatformIdentity{
Platform: "telegram",
PlatformUID: tgAdmin,
CyreneUser: "admin",
Nickname: "开拓者",
PermissionLevel: "admin",
})
}
}
+5
View File
@@ -0,0 +1,5 @@
module github.com/yourname/cyrene-ai/platform-bridge
go 1.26.2
require github.com/gorilla/websocket v1.5.3
+2
View File
@@ -0,0 +1,2 @@
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
@@ -0,0 +1,50 @@
package discord
import (
"context"
"fmt"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
)
// Adapter implements PlatformAdapter for Discord Bot API.
// Currently a stub — requires Discord bot token and discordgo library.
type Adapter struct{}
func NewAdapter() *Adapter { return &Adapter{} }
func (a *Adapter) PlatformName() string { return "discord" }
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 2000,
SupportsMarkdown: true,
SupportsImage: true,
SupportsVoice: false,
SupportsEmoji: true,
SupportsReaction: true,
SupportsTypingHint: true,
RecommendBurstMax: 3,
}
}
func (a *Adapter) Connect(ctx context.Context) error {
fmt.Println("[discord] stub adapter — requires Discord bot token and discordgo")
return nil
}
func (a *Adapter) Disconnect(ctx context.Context) error { return nil }
func (a *Adapter) IsConnected() bool { return false }
func (a *Adapter) HealthCheck() error {
return fmt.Errorf("discord adapter is a stub — not connected to Discord")
}
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
return nil, fmt.Errorf("discord adapter is a stub — implement with discordgo")
}
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
return nil, fmt.Errorf("discord adapter is a stub")
}
@@ -0,0 +1,50 @@
package feishu
import (
"context"
"fmt"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
)
// Adapter implements PlatformAdapter for Feishu (Lark Open API).
// Currently a stub — requires Feishu app credentials and Lark Go SDK.
type Adapter struct{}
func NewAdapter() *Adapter { return &Adapter{} }
func (a *Adapter) PlatformName() string { return "feishu" }
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 3000,
SupportsMarkdown: true,
SupportsImage: true,
SupportsVoice: false,
SupportsEmoji: true,
SupportsReaction: true,
SupportsTypingHint: true,
RecommendBurstMax: 2,
}
}
func (a *Adapter) Connect(ctx context.Context) error {
fmt.Println("[feishu] stub adapter — requires Feishu app credentials and Lark Go SDK")
return nil
}
func (a *Adapter) Disconnect(ctx context.Context) error { return nil }
func (a *Adapter) IsConnected() bool { return false }
func (a *Adapter) HealthCheck() error {
return fmt.Errorf("feishu adapter is a stub — not connected to Feishu")
}
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
return nil, fmt.Errorf("feishu adapter is a stub — implement with Lark Go SDK")
}
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
return nil, fmt.Errorf("feishu adapter is a stub")
}
@@ -0,0 +1,394 @@
package qq
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Adapter implements PlatformAdapter for QQ via OBv11 WebSocket.
type Adapter struct {
port string
conn *websocket.Conn
connMu sync.Mutex
connected bool
// Pending API call responses.
pendingResponses map[string]chan *OBv11APIResponse
respMu sync.Mutex
}
func NewAdapter(port string) *Adapter {
return &Adapter{
port: port,
pendingResponses: make(map[string]chan *OBv11APIResponse),
}
}
func (a *Adapter) PlatformName() string { return "qq" }
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 200,
SupportsMarkdown: true, // QQ supports basic markdown
SupportsImage: true,
SupportsVoice: false,
SupportsEmoji: true,
SupportsReaction: false,
SupportsTypingHint: false,
RecommendBurstMax: 4,
}
}
func (a *Adapter) Connect(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/ws/qq", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Printf("[qq] upgrade error: %v\n", err)
return
}
a.connMu.Lock()
a.conn = conn
a.connected = true
a.connMu.Unlock()
fmt.Println("[qq] bot connected")
})
mux.HandleFunc("/ws/qq/event", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
fmt.Printf("[qq] event upgrade error: %v\n", err)
return
}
a.connMu.Lock()
a.conn = conn
a.connected = true
a.connMu.Unlock()
fmt.Println("[qq] event WebSocket connected")
})
addr := ":" + a.port
srv := &http.Server{Addr: addr, Handler: mux}
go func() {
fmt.Printf("[qq] listening on %s (waiting for bot WebSocket connection)\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
fmt.Printf("[qq] server error: %v\n", err)
}
}()
return nil
}
func (a *Adapter) Disconnect(ctx context.Context) error {
a.connMu.Lock()
defer a.connMu.Unlock()
if a.conn != nil {
a.conn.Close()
a.conn = nil
}
a.connected = false
return nil
}
func (a *Adapter) IsConnected() bool {
a.connMu.Lock()
defer a.connMu.Unlock()
return a.connected
}
func (a *Adapter) HealthCheck() error {
if !a.IsConnected() {
return fmt.Errorf("QQ bot not connected")
}
return nil
}
// ToUnified converts an OBv11 message to UnifiedMessage.
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
msg, ok := rawMessage.(*OBv11Message)
if !ok {
return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage)
}
// Extract text content.
content := extractText(msg)
// Determine sender.
senderID := ""
senderName := "unknown"
channelType := "direct"
channelID := ""
switch msg.MessageType {
case "private":
senderID = fmt.Sprintf("%d", msg.UserID)
channelType = "direct"
channelID = fmt.Sprintf("private_%d", msg.UserID)
if msg.Sender != nil {
senderName = msg.Sender.Nickname
}
case "group":
senderID = fmt.Sprintf("%d", msg.UserID)
channelType = "group"
channelID = fmt.Sprintf("%d", msg.GroupID)
if msg.Sender != nil {
if msg.Sender.Card != "" {
senderName = msg.Sender.Card
} else {
senderName = msg.Sender.Nickname
}
}
}
// Extract mentions.
var mentions []string
if segments, ok := msg.Message.([]interface{}); ok {
for _, s := range segments {
if seg, ok := s.(map[string]interface{}); ok {
if seg["type"] == "at" {
if data, ok := seg["data"].(map[string]interface{}); ok {
if qq, ok := data["qq"].(string); ok {
mentions = append(mentions, qq)
}
}
}
}
}
}
return &bridge.UnifiedMessage{
SenderID: senderID,
SenderName: senderName,
Platform: "qq",
ChannelID: channelID,
ChannelType: channelType,
Content: content,
ContentType: "text",
MessageID: fmt.Sprintf("%d", msg.MessageID),
Mentions: mentions,
RawData: rawMessage,
Timestamp: time.Unix(msg.Time, 0),
}, nil
}
// FromUnified converts a unified response to QQ platform messages.
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
var msgs []bridge.PlatformMessage
for _, rm := range response.Messages {
content := rm.Content
if rm.FormatMode == "markdown" {
content = convertMarkdownToQQ(rm.Content)
}
// QQ prefers shorter messages — split if needed.
runes := []rune(content)
if len(runes) > 200 {
content = string(runes[:200])
}
msgs = append(msgs, bridge.PlatformMessage{
Content: content,
ReplyTo: response.ReplyTo,
FormatMode: "text",
})
}
return msgs, nil
}
// SendMessage sends a message through the QQ WebSocket connection.
func (a *Adapter) SendMessage(msgType string, userID, groupID int64, content string) error {
a.connMu.Lock()
conn := a.conn
a.connMu.Unlock()
if conn == nil {
return fmt.Errorf("QQ bot not connected")
}
req := OBv11SendMsg{
Action: "send_msg",
Params: OBv11Params{
MessageType: msgType,
UserID: userID,
GroupID: groupID,
Message: content,
},
Echo: fmt.Sprintf("echo_%d", time.Now().UnixNano()),
}
data, _ := json.Marshal(req)
return conn.WriteMessage(websocket.TextMessage, data)
}
// ReadMessages reads OBv11 messages from the WebSocket connection.
func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message) {
for {
select {
case <-ctx.Done():
return
default:
}
a.connMu.Lock()
conn := a.conn
a.connMu.Unlock()
if conn == nil {
time.Sleep(time.Second)
continue
}
_, raw, err := conn.ReadMessage()
if err != nil {
fmt.Printf("[qq] read error: %v\n", err)
a.connMu.Lock()
a.conn = nil
a.connected = false
a.connMu.Unlock()
time.Sleep(time.Second)
continue
}
// Try to parse as OBv11 message (event from QQ).
var msg OBv11Message
if err := json.Unmarshal(raw, &msg); err != nil {
// Might be an API response.
var resp OBv11APIResponse
if err := json.Unmarshal(raw, &resp); err != nil {
fmt.Printf("[qq] unknown message: %s\n", string(raw))
continue
}
if resp.Echo != "" {
a.respMu.Lock()
if ch, ok := a.pendingResponses[resp.Echo]; ok {
ch <- &resp
delete(a.pendingResponses, resp.Echo)
}
a.respMu.Unlock()
}
continue
}
// Only handle message events.
if msg.PostType == "message" {
select {
case msgCh <- &msg:
case <-ctx.Done():
return
}
}
}
}
// extractText retrieves plain text from an OBv11 message.
func extractText(msg *OBv11Message) string {
if msg.RawMessage != "" {
return msg.RawMessage
}
switch m := msg.Message.(type) {
case string:
return m
case []interface{}:
var text string
for _, seg := range m {
if s, ok := seg.(map[string]interface{}); ok {
if s["type"] == "text" {
if data, ok := s["data"].(map[string]interface{}); ok {
if t, ok := data["text"].(string); ok {
text += t
}
}
}
}
}
return text
}
return ""
}
// convertMarkdownToQQ converts common markdown to QQ-supported format.
func convertMarkdownToQQ(md string) string {
// QQ supports basic markdown: **bold**, *italic*, ~~strikethrough~~
// Remove unsupported elements (headings, code blocks, links).
md = removeHeadings(md)
md = removeCodeBlocks(md)
// Preserve bold, italic, strikethrough which QQ supports.
return md
}
func removeHeadings(s string) string {
for _, line := range splitLines(s) {
if len(line) > 0 && line[0] == '#' {
s = replaceLine(s, line, stripPrefix(line, "# "))
}
}
return s
}
func removeCodeBlocks(s string) string {
// Simple: remove ``` markers.
result := ""
inCode := false
for _, line := range splitLines(s) {
if hasPrefix(line, "```") {
inCode = !inCode
continue
}
if inCode {
result += line + "\n"
} else {
result += line + "\n"
}
}
return result
}
func splitLines(s string) []string {
var lines []string
start := 0
for i, c := range s {
if c == '\n' {
lines = append(lines, s[start:i])
start = i + 1
}
}
if start < len(s) {
lines = append(lines, s[start:])
}
return lines
}
func hasPrefix(s, prefix string) bool {
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
}
func stripPrefix(s, prefix string) string {
if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
return s[len(prefix):]
}
return s
}
func replaceLine(s, old, new string) string {
// Simple: find old line and replace with new.
idx := indexOf(s, old)
if idx < 0 {
return s
}
return s[:idx] + new + s[idx+len(old):]
}
func indexOf(s, sub string) int {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
@@ -0,0 +1,75 @@
package qq
// OBv11 (OneBot v11) protocol types.
// OBv11Message is a message received from QQ via OneBot.
type OBv11Message struct {
PostType string `json:"post_type"` // "message", "notice", "request"
MessageType string `json:"message_type"` // "private", "group"
Time int64 `json:"time"`
SelfID int64 `json:"self_id"`
// Private message fields.
Sender *OBv11Sender `json:"sender,omitempty"`
Message interface{} `json:"message"` // string or []OBv11MessageSegment
RawMessage string `json:"raw_message"`
Font int `json:"font"`
SubType string `json:"sub_type"` // "friend", "group", "other"
MessageID int32 `json:"message_id"`
UserID int64 `json:"user_id"`
TargetID int64 `json:"target_id"`
TempSource int `json:"temp_source"`
// Group message fields.
GroupID int64 `json:"group_id"`
Anonymous interface{} `json:"anonymous"`
MessageSeq int64 `json:"message_seq"`
// Notice fields.
NoticeType string `json:"notice_type"`
}
// OBv11Sender represents a message sender.
type OBv11Sender struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Sex string `json:"sex"`
Age int32 `json:"age"`
Card string `json:"card"` // group card (nickname in group)
Area string `json:"area"`
Level string `json:"level"`
Role string `json:"role"` // "owner", "admin", "member"
Title string `json:"title"`
}
// OBv11MessageSegment is a segment in an array-format message.
type OBv11MessageSegment struct {
Type string `json:"type"`
Data map[string]interface{} `json:"data"`
}
// OBv11APIResponse is the response from an API call.
type OBv11APIResponse struct {
Status string `json:"status"` // "ok" or "failed"
RetCode int `json:"retcode"`
Data interface{} `json:"data"`
Message string `json:"msg"`
Wording string `json:"wording"`
Echo string `json:"echo"`
}
// OBv11SendMsg is the request body for send_msg.
type OBv11SendMsg struct {
Action string `json:"action"`
Params OBv11Params `json:"params"`
Echo string `json:"echo,omitempty"`
}
// OBv11Params holds parameters for send_msg API calls.
type OBv11Params struct {
MessageType string `json:"message_type,omitempty"` // "private", "group"
UserID int64 `json:"user_id,omitempty"`
GroupID int64 `json:"group_id,omitempty"`
Message interface{} `json:"message"`
AutoEscape bool `json:"auto_escape,omitempty"`
}
@@ -0,0 +1,251 @@
package telegram
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
)
// Adapter implements PlatformAdapter for Telegram Bot API.
type Adapter struct {
token string
webhookURL string
client *http.Client
connected bool
}
func NewAdapter(token, webhookURL string) *Adapter {
return &Adapter{
token: token,
webhookURL: webhookURL,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (a *Adapter) PlatformName() string { return "telegram" }
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 4096,
SupportsMarkdown: true,
SupportsImage: true,
SupportsVoice: true,
SupportsEmoji: true,
SupportsReaction: true,
SupportsTypingHint: true,
RecommendBurstMax: 3,
}
}
func (a *Adapter) Connect(ctx context.Context) error {
if a.token == "" {
return fmt.Errorf("telegram bot token not configured")
}
if a.webhookURL == "" {
// Polling mode not implemented; webhook required.
return fmt.Errorf("telegram webhook URL not configured")
}
// Set webhook.
url := fmt.Sprintf("https://api.telegram.org/bot%s/setWebhook?url=%s/api/v1/webhook/telegram", a.token, a.webhookURL)
resp, err := a.client.Post(url, "application/json", nil)
if err != nil {
return fmt.Errorf("failed to set telegram webhook: %w", err)
}
defer resp.Body.Close()
a.connected = true
fmt.Println("[telegram] webhook set, bot ready")
return nil
}
func (a *Adapter) Disconnect(ctx context.Context) error {
if a.token != "" {
url := fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook", a.token)
a.client.Post(url, "application/json", nil)
}
a.connected = false
return nil
}
func (a *Adapter) IsConnected() bool { return a.connected }
func (a *Adapter) HealthCheck() error {
if a.token == "" {
return fmt.Errorf("telegram token not configured")
}
url := fmt.Sprintf("https://api.telegram.org/bot%s/getMe", a.token)
resp, err := a.client.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("telegram API returned %d", resp.StatusCode)
}
return nil
}
// TelegramUpdate is the webhook payload from Telegram.
type TelegramUpdate struct {
UpdateID int64 `json:"update_id"`
Message *TelegramMessage `json:"message"`
Callback *TelegramCallback `json:"callback_query"`
}
// TelegramMessage represents a Telegram message.
type TelegramMessage struct {
MessageID int64 `json:"message_id"`
From TelegramUser `json:"from"`
Chat TelegramChat `json:"chat"`
Text string `json:"text"`
Date int64 `json:"date"`
ReplyTo *TelegramMessage `json:"reply_to_message"`
Entities []TelegramEntity `json:"entities"`
}
// TelegramUser represents a Telegram user.
type TelegramUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
// TelegramChat represents a Telegram chat.
type TelegramChat struct {
ID int64 `json:"id"`
Type string `json:"type"` // "private", "group", "supergroup", "channel"
}
// TelegramEntity represents a text entity in a Telegram message.
type TelegramEntity struct {
Type string `json:"type"` // "mention", "bot_command", etc.
Offset int `json:"offset"`
Length int `json:"length"`
}
// TelegramCallback represents a callback query.
type TelegramCallback struct {
ID string `json:"id"`
From TelegramUser `json:"from"`
Message *TelegramMessage `json:"message"`
Data string `json:"data"`
}
// ToUnified converts a Telegram update to UnifiedMessage.
// Accepts both *TelegramUpdate and map[string]interface{} (from webhook).
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
var msg *TelegramMessage
switch v := rawMessage.(type) {
case *TelegramUpdate:
msg = v.Message
case map[string]interface{}:
// Parse from raw webhook payload.
data, _ := json.Marshal(v)
var update TelegramUpdate
if err := json.Unmarshal(data, &update); err != nil {
return nil, fmt.Errorf("parse telegram update: %w", err)
}
msg = update.Message
default:
return nil, fmt.Errorf("expected *TelegramUpdate or map, got %T", rawMessage)
}
if msg == nil {
return nil, fmt.Errorf("no message in update")
}
channelType := "direct"
channelID := fmt.Sprintf("%d", msg.Chat.ID)
if msg.Chat.Type == "group" || msg.Chat.Type == "supergroup" {
channelType = "group"
} else if msg.Chat.Type == "channel" {
channelType = "channel"
}
senderName := msg.From.FirstName
if msg.From.LastName != "" {
senderName += " " + msg.From.LastName
}
if msg.From.Username != "" {
senderName = msg.From.Username
}
replyTo := ""
if msg.ReplyTo != nil {
replyTo = fmt.Sprintf("%d", msg.ReplyTo.MessageID)
}
return &bridge.UnifiedMessage{
SenderID: fmt.Sprintf("%d", msg.From.ID),
SenderName: senderName,
Platform: "telegram",
ChannelID: channelID,
ChannelType: channelType,
Content: msg.Text,
ContentType: "text",
MessageID: fmt.Sprintf("%d", msg.MessageID),
ReplyTo: replyTo,
RawData: rawMessage,
Timestamp: time.Unix(msg.Date, 0),
}, nil
}
// FromUnified converts a unified response to Telegram messages.
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
var msgs []bridge.PlatformMessage
for _, rm := range response.Messages {
content := rm.Content
format := rm.FormatMode
if format == "" {
format = "markdown"
}
msgs = append(msgs, bridge.PlatformMessage{
Content: content,
FormatMode: format,
ReplyTo: response.ReplyTo,
})
}
return msgs, nil
}
// SendMessage sends a message via Telegram Bot API.
func (a *Adapter) SendMessage(chatID int64, text, parseMode, replyTo string) error {
body := map[string]interface{}{
"chat_id": chatID,
"text": text,
}
if parseMode != "" {
body["parse_mode"] = parseMode
}
if replyTo != "" {
body["reply_to_message_id"] = replyTo
}
data, _ := json.Marshal(body)
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", a.token)
resp, err := a.client.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
// SendChatAction sends a "typing..." indicator via Telegram Bot API.
func (a *Adapter) SendChatAction(chatID int64, action string) error {
body, _ := json.Marshal(map[string]interface{}{
"chat_id": chatID,
"action": action, // "typing", "upload_photo", etc.
})
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendChatAction", a.token)
resp, err := a.client.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
return nil
}
@@ -0,0 +1,121 @@
package webhook
import (
"context"
"fmt"
"time"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
)
// WebhookPayload is the standard webhook request body.
type WebhookPayload struct {
UserID string `json:"user_id"`
UserName string `json:"user_name"`
Content string `json:"content"`
ContentType string `json:"content_type"`
ChannelID string `json:"channel_id"`
ChannelType string `json:"channel_type"`
MessageID string `json:"message_id,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
}
// WebhookResponse is the standard webhook response body.
type WebhookResponse struct {
Messages []WebhookResponseMessage `json:"messages"`
ReplyTo string `json:"reply_to,omitempty"`
}
// WebhookResponseMessage is a single message in a webhook response.
type WebhookResponseMessage struct {
Type string `json:"type"` // "chat", "action", "system_info"
Content string `json:"content"`
}
// Adapter implements PlatformAdapter for generic webhook integrations.
type Adapter struct {
name string
}
func NewAdapter(name string) *Adapter {
if name == "" {
name = "webhook"
}
return &Adapter{name: name}
}
func (a *Adapter) PlatformName() string { return a.name }
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 2000,
SupportsMarkdown: true,
SupportsImage: false,
SupportsVoice: false,
SupportsEmoji: true,
SupportsReaction: false,
SupportsTypingHint: false,
RecommendBurstMax: 2,
}
}
func (a *Adapter) Connect(ctx context.Context) error {
fmt.Printf("[webhook:%s] adapter ready\n", a.name)
return nil
}
func (a *Adapter) Disconnect(ctx context.Context) error { return nil }
func (a *Adapter) IsConnected() bool { return true }
func (a *Adapter) HealthCheck() error { return nil }
// ToUnified converts a webhook payload to UnifiedMessage.
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
payload, ok := rawMessage.(*WebhookPayload)
if !ok {
return nil, fmt.Errorf("expected *WebhookPayload, got %T", rawMessage)
}
contentType := payload.ContentType
if contentType == "" {
contentType = "text"
}
channelType := payload.ChannelType
if channelType == "" {
channelType = "direct"
}
ts := time.Now()
if payload.Timestamp > 0 {
ts = time.Unix(payload.Timestamp, 0)
}
return &bridge.UnifiedMessage{
SenderID: payload.UserID,
SenderName: payload.UserName,
Platform: a.name,
ChannelID: payload.ChannelID,
ChannelType: channelType,
Content: payload.Content,
ContentType: contentType,
MessageID: payload.MessageID,
ReplyTo: payload.ReplyTo,
RawData: rawMessage,
Timestamp: ts,
}, nil
}
// FromUnified converts a unified response to webhook response format.
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
var msgs []bridge.PlatformMessage
for _, rm := range response.Messages {
msgs = append(msgs, bridge.PlatformMessage{
Content: rm.Content,
FormatMode: rm.FormatMode,
ReplyTo: response.ReplyTo,
})
}
return msgs, nil
}
@@ -0,0 +1,50 @@
package wechat
import (
"context"
"fmt"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
)
// Adapter implements PlatformAdapter for WeChat (Enterprise WeChat / Personal Hook).
// Currently a stub — requires WeChatFerry / ItChat or Enterprise WeChat SDK credentials.
type Adapter struct{}
func NewAdapter() *Adapter { return &Adapter{} }
func (a *Adapter) PlatformName() string { return "wechat" }
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
return bridge.PlatformCapabilities{
MaxMessageLength: 50,
SupportsMarkdown: false,
SupportsImage: true,
SupportsVoice: true,
SupportsEmoji: true,
SupportsReaction: false,
SupportsTypingHint: false,
RecommendBurstMax: 3,
}
}
func (a *Adapter) Connect(ctx context.Context) error {
fmt.Println("[wechat] stub adapter — no live connection (requires WeChatFerry/ItChat or Enterprise WeChat SDK)")
return nil
}
func (a *Adapter) Disconnect(ctx context.Context) error { return nil }
func (a *Adapter) IsConnected() bool { return false }
func (a *Adapter) HealthCheck() error {
return fmt.Errorf("wechat adapter is a stub — not connected to WeChat")
}
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
return nil, fmt.Errorf("wechat adapter is a stub — implement with WeChatFerry or Enterprise WeChat SDK")
}
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
return nil, fmt.Errorf("wechat adapter is a stub")
}
@@ -0,0 +1,24 @@
package bridge
import "context"
// PlatformAdapter is the interface every platform adapter must implement.
type PlatformAdapter interface {
PlatformName() string
// Message conversion.
ToUnified(rawMessage interface{}) (*UnifiedMessage, error)
FromUnified(response *UnifiedResponse) ([]PlatformMessage, error)
// Capabilities.
Capabilities() PlatformCapabilities
// Connection management.
Connect(ctx context.Context) error
Disconnect(ctx context.Context) error
IsConnected() bool
HealthCheck() error
}
// MessageHandler receives unified messages from adapters for processing.
type MessageHandler func(msg *UnifiedMessage) (*UnifiedResponse, error)
@@ -0,0 +1,73 @@
package bridge
import (
"fmt"
"sync"
"github.com/yourname/cyrene-ai/platform-bridge/internal/permissions"
)
// IdentityMapper maps platform identities to Cyrene users.
type IdentityMapper struct {
mu sync.RWMutex
byPlatform map[string]map[string]*permissions.PlatformIdentity // platform -> platformUID -> identity
}
func NewIdentityMapper() *IdentityMapper {
return &IdentityMapper{
byPlatform: make(map[string]map[string]*permissions.PlatformIdentity),
}
}
// Register adds or updates a platform identity mapping.
func (m *IdentityMapper) Register(id permissions.PlatformIdentity) {
m.mu.Lock()
defer m.mu.Unlock()
if m.byPlatform[id.Platform] == nil {
m.byPlatform[id.Platform] = make(map[string]*permissions.PlatformIdentity)
}
m.byPlatform[id.Platform][id.PlatformUID] = &id
}
// Resolve finds the Cyrene user for a platform identity.
func (m *IdentityMapper) Resolve(platform, platformUID string) (*permissions.PlatformIdentity, error) {
m.mu.RLock()
defer m.mu.RUnlock()
plat, ok := m.byPlatform[platform]
if !ok {
return nil, fmt.Errorf("unknown platform: %s", platform)
}
id, ok := plat[platformUID]
if !ok {
return nil, fmt.Errorf("unknown user on %s: %s", platform, platformUID)
}
return id, nil
}
// List returns all identities for a platform.
func (m *IdentityMapper) List(platform string) []permissions.PlatformIdentity {
m.mu.RLock()
defer m.mu.RUnlock()
plat, ok := m.byPlatform[platform]
if !ok {
return nil
}
result := make([]permissions.PlatformIdentity, 0, len(plat))
for _, id := range plat {
result = append(result, *id)
}
return result
}
// ListAll returns all registered identities.
func (m *IdentityMapper) ListAll() map[string][]permissions.PlatformIdentity {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string][]permissions.PlatformIdentity)
for plat, users := range m.byPlatform {
for _, id := range users {
result[plat] = append(result[plat], *id)
}
}
return result
}
@@ -0,0 +1,168 @@
package bridge
import (
"fmt"
"sync"
"github.com/yourname/cyrene-ai/platform-bridge/internal/permissions"
)
// PlatformRouter manages all platform adapters and routes messages.
type PlatformRouter struct {
mu sync.RWMutex
adapters map[string]PlatformAdapter
mapper *IdentityMapper
checker *permissions.Checker
handler MessageHandler
// Conversational context per channel.
contexts map[string]*ChannelContext // channelKey -> context
}
// ChannelContext stores the active conversation state for a channel.
type ChannelContext struct {
Platform string
ChannelID string
ChannelType string
LastUserMsg string
MessageCount int
}
func NewPlatformRouter(mapper *IdentityMapper, checker *permissions.Checker) *PlatformRouter {
return &PlatformRouter{
adapters: make(map[string]PlatformAdapter),
mapper: mapper,
checker: checker,
contexts: make(map[string]*ChannelContext),
}
}
// RegisterAdapter adds a platform adapter.
func (r *PlatformRouter) RegisterAdapter(a PlatformAdapter) {
r.mu.Lock()
defer r.mu.Unlock()
r.adapters[a.PlatformName()] = a
}
// GetAdapter returns the adapter for a platform.
func (r *PlatformRouter) GetAdapter(platform string) (PlatformAdapter, error) {
r.mu.RLock()
defer r.mu.RUnlock()
a, ok := r.adapters[platform]
if !ok {
return nil, fmt.Errorf("no adapter for platform: %s", platform)
}
return a, nil
}
// ListAdapters returns all registered adapter names.
func (r *PlatformRouter) ListAdapters() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.adapters))
for name := range r.adapters {
names = append(names, name)
}
return names
}
// SetMessageHandler sets the callback for processing unified messages.
func (r *PlatformRouter) SetMessageHandler(h MessageHandler) {
r.handler = h
}
// RouteMessage converts a platform message to unified, checks permissions, and dispatches.
func (r *PlatformRouter) RouteMessage(platform string, rawMsg interface{}) (*UnifiedResponse, error) {
a, err := r.GetAdapter(platform)
if err != nil {
return nil, err
}
unified, err := a.ToUnified(rawMsg)
if err != nil {
return nil, fmt.Errorf("convert to unified: %w", err)
}
// Resolve identity.
identity, err := r.mapper.Resolve(platform, unified.SenderID)
if err != nil {
return nil, fmt.Errorf("identity not found: %w", err)
}
// Merge identity info into the unified message.
unified.SenderID = identity.CyreneUser
unified.SenderName = identity.Nickname
// Apply permission-based filtering.
_ = identity // used by permission checks on tools
// Update channel context.
r.updateContext(unified)
if r.handler == nil {
return nil, fmt.Errorf("no message handler configured")
}
response, err := r.handler(unified)
if err != nil {
return nil, err
}
response.Platform = platform
response.PlatformHints = r.platformHints(platform)
return response, nil
}
// SendResponse converts and sends a unified response through the platform adapter.
func (r *PlatformRouter) SendResponse(response *UnifiedResponse) ([]PlatformMessage, error) {
a, err := r.GetAdapter(response.Platform)
if err != nil {
return nil, err
}
return a.FromUnified(response)
}
func (r *PlatformRouter) platformHints(platform string) PlatformHints {
cap := PlatformCapabilities{}
if a, err := r.GetAdapter(platform); err == nil {
cap = a.Capabilities()
}
return PlatformHints{
TypingIndicator: cap.SupportsTypingHint,
BurstMode: cap.RecommendBurstMax > 1,
}
}
func (r *PlatformRouter) channelKey(platform, channelID string) string {
return platform + ":" + channelID
}
func (r *PlatformRouter) updateContext(msg *UnifiedMessage) {
key := r.channelKey(msg.Platform, msg.ChannelID)
r.mu.Lock()
defer r.mu.Unlock()
ctx, ok := r.contexts[key]
if !ok {
ctx = &ChannelContext{
Platform: msg.Platform,
ChannelID: msg.ChannelID,
ChannelType: msg.ChannelType,
}
r.contexts[key] = ctx
}
ctx.LastUserMsg = msg.Content
ctx.MessageCount++
}
// ListAllIdentities returns all registered identity mappings.
func (r *PlatformRouter) ListAllIdentities() map[string][]permissions.PlatformIdentity {
return r.mapper.ListAll()
}
// GetContext returns the channel context.
func (r *PlatformRouter) GetContext(platform, channelID string) *ChannelContext {
r.mu.RLock()
defer r.mu.RUnlock()
return r.contexts[platform+":"+channelID]
}
@@ -0,0 +1,76 @@
package bridge
import "time"
// UnifiedMessage is the internal message format all platforms convert to.
type UnifiedMessage struct {
SenderID string `json:"sender_id"`
SenderName string `json:"sender_name"`
Platform string `json:"platform"`
ChannelID string `json:"channel_id"`
ChannelType string `json:"channel_type"` // "direct", "group", "channel"
Content string `json:"content"`
ContentType string `json:"content_type"` // "text", "image", "voice", "file", "mixed"
Attachments []Attachment `json:"attachments,omitempty"`
MessageID string `json:"message_id,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
Mentions []string `json:"mentions,omitempty"`
RawData interface{} `json:"raw_data,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
// Attachment represents a file/image/voice attachment.
type Attachment struct {
Type string `json:"type"` // "image", "voice", "file", "video"
URL string `json:"url,omitempty"`
FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"`
}
// UnifiedResponse is AI-Core's response converted to unified format.
type UnifiedResponse struct {
Messages []ResponseMessage `json:"messages"`
ReplyTo string `json:"reply_to,omitempty"`
Platform string `json:"platform"`
PlatformHints PlatformHints `json:"platform_hints,omitempty"`
}
// ResponseMessage is a single message in a response.
type ResponseMessage struct {
DisplayType string `json:"display_type"` // "chat", "action", "thinking", "system_info", "tool_progress"
Content string `json:"content"`
FormatMode string `json:"format_mode"` // "plain", "markdown", "html"
}
// PlatformHints tells the adapter how to deliver the response.
type PlatformHints struct {
TypingIndicator bool `json:"typing_indicator"` // show "typing..." before sending
BurstMode bool `json:"burst_mode"` // send multiple messages rapidly
ReplyAsThread bool `json:"reply_as_thread"` // reply in a thread
}
// PlatformCapabilities declares what a platform supports.
type PlatformCapabilities struct {
MaxMessageLength int `json:"max_message_length"`
SupportsMarkdown bool `json:"supports_markdown"`
SupportsImage bool `json:"supports_image"`
SupportsVoice bool `json:"supports_voice"`
SupportsEmoji bool `json:"supports_emoji"`
SupportsReaction bool `json:"supports_reaction"`
SupportsTypingHint bool `json:"supports_typing_hint"`
RecommendBurstMax int `json:"recommend_burst_max"`
}
// PlatformMessage is the platform-specific message format returned by an adapter.
type PlatformMessage struct {
Content string `json:"content"`
FormatMode string `json:"format_mode,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
RawBody interface{} `json:"raw_body,omitempty"`
}
@@ -0,0 +1,52 @@
package config
import "os"
// Config holds Platform Bridge configuration.
type Config struct {
Port string
Env string
GatewayURL string
AICoreURL string
InternalToken string
// Platform-specific.
QQBotPort string // port for QQ OBv11 reverse WebSocket
TelegramToken string // Telegram Bot API token
TelegramWebhookURL string // public webhook URL for Telegram
}
func Load() *Config {
cfg := &Config{
Port: "8095",
Env: "development",
GatewayURL: "http://localhost:8080",
AICoreURL: "http://localhost:8081",
QQBotPort: "8096",
}
if v := os.Getenv("PORT"); v != "" {
cfg.Port = v
}
if v := os.Getenv("ENV"); v != "" {
cfg.Env = v
}
if v := os.Getenv("GATEWAY_URL"); v != "" {
cfg.GatewayURL = v
}
if v := os.Getenv("AI_CORE_URL"); v != "" {
cfg.AICoreURL = v
}
if v := os.Getenv("INTERNAL_SERVICE_TOKEN"); v != "" {
cfg.InternalToken = v
}
if v := os.Getenv("QQ_BOT_PORT"); v != "" {
cfg.QQBotPort = v
}
if v := os.Getenv("TELEGRAM_BOT_TOKEN"); v != "" {
cfg.TelegramToken = v
}
if v := os.Getenv("TELEGRAM_WEBHOOK_URL"); v != "" {
cfg.TelegramWebhookURL = v
}
return cfg
}
@@ -0,0 +1,149 @@
package handler
import (
"encoding/json"
"net/http"
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
)
// BridgeHandler exposes the Platform Bridge REST API.
type BridgeHandler struct {
router *bridge.PlatformRouter
}
func NewBridgeHandler(router *bridge.PlatformRouter) *BridgeHandler {
return &BridgeHandler{router: router}
}
func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/health", h.health)
mux.HandleFunc("/api/v1/platforms", h.listPlatforms)
mux.HandleFunc("/api/v1/platforms/", h.platformInfo)
mux.HandleFunc("/api/v1/identities", h.listIdentities)
mux.HandleFunc("/api/v1/webhook/telegram", h.telegramWebhook)
mux.HandleFunc("/api/v1/webhook/", h.genericWebhook)
}
func (h *BridgeHandler) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok", "service": "platform-bridge",
"platforms": h.router.ListAdapters(),
})
}
func (h *BridgeHandler) listPlatforms(w http.ResponseWriter, r *http.Request) {
names := h.router.ListAdapters()
type platformSummary struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Caps bridge.PlatformCapabilities `json:"capabilities"`
}
var platforms []platformSummary
for _, name := range names {
a, err := h.router.GetAdapter(name)
if err != nil {
continue
}
platforms = append(platforms, platformSummary{
Name: name,
Connected: a.IsConnected(),
Caps: a.Capabilities(),
})
}
writeJSON(w, http.StatusOK, map[string]interface{}{"platforms": platforms, "total": len(platforms)})
}
func (h *BridgeHandler) platformInfo(w http.ResponseWriter, r *http.Request) {
name := r.URL.Path[len("/api/v1/platforms/"):]
a, err := h.router.GetAdapter(name)
if err != nil {
writeJSON(w, http.StatusNotFound, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": name,
"connected": a.IsConnected(),
"capabilities": a.Capabilities(),
})
}
func (h *BridgeHandler) listIdentities(w http.ResponseWriter, r *http.Request) {
all := h.router.ListAllIdentities()
writeJSON(w, http.StatusOK, all)
}
// telegramWebhook receives updates from Telegram Bot API.
func (h *BridgeHandler) telegramWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
// Parse into a generic map first to check for message presence.
var raw map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid telegram update"))
return
}
if _, hasMsg := raw["message"]; !hasMsg {
writeJSON(w, http.StatusOK, map[string]string{"status": "ignored"})
return
}
_, err := h.router.RouteMessage("telegram", raw)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// genericWebhook receives standard webhook payloads.
func (h *BridgeHandler) genericWebhook(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
// Extract platform name from path: /api/v1/webhook/{platform}
platform := r.URL.Path[len("/api/v1/webhook/"):]
if platform == "" || platform == "telegram" {
writeJSON(w, http.StatusBadRequest, errResp("specify platform name in path"))
return
}
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid JSON payload"))
return
}
response, err := h.router.RouteMessage(platform, &payload)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
// Convert to platform-specific format.
msgs, err := h.router.SendResponse(response)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errResp(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"messages": msgs,
"reply_to": response.ReplyTo,
})
}
func errResp(msg string) map[string]string {
return map[string]string{"error": msg}
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
@@ -0,0 +1,115 @@
package permissions
// PlatformIdentity maps a platform user to a Cyrene user.
type PlatformIdentity struct {
Platform string `yaml:"platform" json:"platform"`
PlatformUID string `yaml:"platform_uid" json:"platform_uid"`
CyreneUser string `yaml:"cyrene_user_id" json:"cyrene_user_id"`
Nickname string `yaml:"nickname" json:"nickname"`
PermissionLevel string `yaml:"permission_level" json:"permission_level"`
AllowedTools []string `yaml:"allowed_tools,omitempty" json:"allowed_tools,omitempty"`
IoTDevices []string `yaml:"iot_devices,omitempty" json:"iot_devices,omitempty"`
}
// Level represents a permission level.
type Level string
const (
LevelAdmin Level = "admin"
LevelFull Level = "full"
LevelBasic Level = "basic"
LevelRestricted Level = "restricted"
)
// Checker validates whether an operation is allowed for a given identity.
type Checker struct{}
func NewChecker() *Checker { return &Checker{} }
// CanChat checks if the identity can send chat messages.
func (c *Checker) CanChat(id *PlatformIdentity) bool {
return id != nil
}
// CanControlIoT checks if the identity can control IoT devices.
func (c *Checker) CanControlIoT(id *PlatformIdentity) bool {
if id == nil {
return false
}
switch Level(id.PermissionLevel) {
case LevelAdmin, LevelFull:
return true
default:
return false
}
}
// CanQueryIoT checks if the identity can query IoT device state.
func (c *Checker) CanQueryIoT(id *PlatformIdentity) bool {
if id == nil {
return false
}
switch Level(id.PermissionLevel) {
case LevelAdmin, LevelFull, LevelBasic:
return true
default:
return false
}
}
// CanAccessMemory checks if the identity can view memories.
func (c *Checker) CanAccessMemory(id *PlatformIdentity) bool {
if id == nil {
return false
}
return Level(id.PermissionLevel) != LevelRestricted
}
// CanManageSystem checks if the identity can modify system config.
func (c *Checker) CanManageSystem(id *PlatformIdentity) bool {
if id == nil {
return false
}
return Level(id.PermissionLevel) == LevelAdmin
}
// IsAdmin checks if the identity has admin privileges.
func (c *Checker) IsAdmin(id *PlatformIdentity) bool {
if id == nil {
return false
}
return Level(id.PermissionLevel) == LevelAdmin
}
// AllowedTool checks if a specific tool is allowed for this identity.
func (c *Checker) AllowedTool(id *PlatformIdentity, toolName string) bool {
if id == nil {
return false
}
if Level(id.PermissionLevel) == LevelAdmin {
return true
}
for _, t := range id.AllowedTools {
if t == toolName {
return true
}
}
return false
}
// AllowedIoTDevice checks if a specific device can be controlled by this identity.
func (c *Checker) AllowedIoTDevice(id *PlatformIdentity, deviceID string) bool {
if id == nil {
return false
}
if Level(id.PermissionLevel) == LevelAdmin {
return true
}
for _, d := range id.IoTDevices {
if d == deviceID {
return true
}
}
return false
}
+33
View File
@@ -155,6 +155,39 @@ export const SERVICES = {
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'plugin-manager': {
name: '插件管理器',
cwd: path.join(ROOT, 'backend/plugin-manager'),
command: './main',
env: {
PLUGIN_MANAGER_PORT: '8094',
IOT_SERVICE_URL: process.env.IOT_SERVICE_URL || process.env.IOT_DEBUG_SERVICE_URL || 'http://localhost:8083',
},
healthUrl: 'http://localhost:8094/api/v1/health',
port: 8094,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
'platform-bridge': {
name: '多平台桥接',
cwd: path.join(ROOT, 'backend/platform-bridge'),
command: './main',
env: {
PORT: '8095',
AI_CORE_URL: 'http://localhost:8081',
QQ_BOT_PORT: process.env.QQ_BOT_PORT || '8096',
TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN || '',
TELEGRAM_WEBHOOK_URL: process.env.TELEGRAM_WEBHOOK_URL || '',
QQ_ADMIN_UID: process.env.QQ_ADMIN_UID || '',
TELEGRAM_ADMIN_UID: process.env.TELEGRAM_ADMIN_UID || '',
},
healthUrl: 'http://localhost:8095/health',
port: 8095,
buildCommand: 'go',
buildArgs: ['build', '-o', isWin ? 'main.exe' : 'main', './cmd/main.go'],
goBin: GO_BIN,
},
frontend: {
name: 'Frontend',
cwd: path.join(ROOT, 'frontend/web'),
+2 -2
View File
@@ -134,7 +134,7 @@ class ProcessManager extends EventEmitter {
}
// 对需要数据库的服务做前置检查
if (['gateway', 'ai-core', 'memory-service', 'tool-engine'].includes(serviceId)) {
if (['gateway', 'ai-core', 'memory-service', 'tool-engine', 'plugin-manager', 'platform-bridge'].includes(serviceId)) {
this.emit('log', serviceId, 'system', '检查数据库连接状态...');
await ensureDBOnline(serviceId, this);
}
@@ -454,7 +454,7 @@ class ProcessManager extends EventEmitter {
* 每步等待健康检查通过后再启动下一个
*/
async startAllSequential() {
const order = ['memory-service', 'tool-engine', 'iot-debug-service', 'voice-service', 'ai-core', 'gateway', 'frontend'];
const order = ['memory-service', 'tool-engine', 'plugin-manager', 'iot-debug-service', 'voice-service', 'ai-core', 'platform-bridge', 'gateway', 'frontend'];
const results = [];
for (const id of order) {
@@ -0,0 +1,233 @@
# Phase 4: 多平台接入 — 开发报告
> **报告日期**2026-05-23
> **分支**`dev`
> **阶段**Phase 4 — 多平台接入
> **总计修改文件数**:18 个 (新增 15 个, 修改 3 个)
> **总代码行数**1852 行 (Go) + 配置文件
> **编译状态**platform-bridge ✅ / ai-core ⚠️ 网络不可用 (预存在问题)
---
## 一、背景
Phase 3 完成了插件与工具系统。Phase 4 的目标是建立统一的多平台消息接入层,让昔涟能够通过 QQ、Telegram、Webhook 等多种渠道与用户交互。
### 核心目标
| 任务 | 说明 |
|------|------|
| 统一消息模型 | 所有平台消息转换为统一的 UnifiedMessage/UnifiedResponse |
| 身份映射 | 平台用户 UID → Cyrene 用户的身份映射与权限管理 |
| QQ OBv11 适配器 | 反向 WebSocket 连接,QQ 机器人协议适配 |
| Telegram 适配器 | Bot API Webhook 模式,消息收发 + 输入状态 |
| 通用 Webhook | 标准 HTTP POST 接收任意平台消息 |
| 权限检查器 | admin/full/basic/restricted 四级权限,工具/设备白名单 |
| WeChat/Feishu/Discord 桩 | 预留接口,待后续接入外部 SDK |
---
## 二、架构概览
```
┌──────────────────────────────────────────────────────┐
│ Platform Bridge (port 8095) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ QQ OBv11 │ │ Telegram │ │ Webhook │ ... 6 适配器│
│ │ (WS 8096)│ │ (Bot API)│ │ (通用) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ┌────▼──────────────▼──────────────▼────┐ │
│ │ Platform Router │ │
│ │ ┌────────────┐ ┌─────────────────┐ │ │
│ │ │ Identity │ │ Permission │ │ │
│ │ │ Mapper │ │ Checker │ │ │
│ │ └────────────┘ └────────┬────────┘ │ │
│ └───────────────────────────┴────────────┘ │
│ │ │
│ forwardToAICore() │
│ │ │
│ ┌───────▼────────┐ │
│ │ AI-Core │ │
│ │ (port 8081) │ │
│ └────────────────┘ │
└──────────────────────────────────────────────────────┘
```
## 消息流程
```
外部平台消息 → Adapter.ToUnified() → 统一消息 → IdentityMapper.Resolve()
→ Permission Check → forwardToAICore() → 统一响应 → Adapter.FromUnified()
→ 平台消息发送
```
---
## 三、文件清单
### 3.1 核心类型 (internal/bridge/)
| 文件 | 行数 | 说明 |
|------|------|------|
| `unified.go` | 76 | UnifiedMessage, UnifiedResponse, ResponseMessage, PlatformCapabilities, PlatformMessage, Attachment, PlatformHints |
| `adapter.go` | 24 | PlatformAdapter 接口 (PlatformName, ToUnified, FromUnified, Capabilities, Connect, Disconnect, IsConnected, HealthCheck) + MessageHandler 类型 |
| `mapper.go` | 73 | IdentityMapper — platform:platformUID → CyreneUser 映射注册/查询/列表 |
| `router.go` | 168 | PlatformRouter — 适配器注册、消息路由、通道上下文管理、响应发送 |
### 3.2 权限系统 (internal/permissions/)
| 文件 | 行数 | 说明 |
|------|------|------|
| `permissions.go` | 115 | PlatformIdentity 结构体 + Checker 权限检查器 (四级权限: admin/full/basic/restricted)、工具白名单、IoT 设备白名单 |
### 3.3 平台适配器 (internal/adapter/)
| 文件 | 行数 | 说明 |
|------|------|------|
| `qq/protocol.go` | 75 | OBv11 协议类型 (OBv11Message, OBv11Sender, OBv11SendMsg 等) |
| `qq/adapter.go` | 394 | QQ 适配器 — 反向 WebSocket 服务器, ToUnified/FromUnified, SendMessage, ReadMessages 事件循环, markdown→QQ 格式转换 |
| `telegram/adapter.go` | 251 | Telegram 适配器 — Bot API Webhook, ToUnified 支持结构化/map 两种输入, SendMessage, SendChatAction (typing 指示器) |
| `webhook/adapter.go` | 121 | 通用 Webhook 适配器 — WebhookPayload/WebhookResponse 标准格式 |
| `wechat/stub.go` | 50 | 微信桩 (待 WeChatFerry / 企业微信 SDK) |
| `feishu/stub.go` | 50 | 飞书桩 (待 Lark Go SDK) |
| `discord/stub.go` | 50 | Discord 桩 (待 discordgo) |
### 3.4 REST API (internal/handler/)
| 文件 | 行数 | 说明 |
|------|------|------|
| `bridge_handler.go` | 149 | 路由注册, /health, /api/v1/platforms, /api/v1/identities, /api/v1/webhook/telegram, /api/v1/webhook/{platform} |
### 3.5 配置与入口
| 文件 | 行数 | 说明 |
|------|------|------|
| `config/config.go` | 52 | 环境变量加载 (PORT, AI_CORE_URL, QQ_BOT_PORT, TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_URL) |
| `cmd/main.go` | 204 | 服务入口 — 创建 mapper/checker/router, 注册 6 个适配器, 设置消息处理器, QQ 消息读取循环, 身份种子数据 |
### 3.6 DevTools 集成 (修改)
| 文件 | 变更 | 说明 |
|------|------|------|
| `devtools/src/config.js` | +33 行 | 添加 plugin-manager 和 platform-bridge 服务配置 |
| `devtools/src/process-manager.js` | +2/-2 | 添加新服务到启动顺序和 DB 检查列表 |
| `backend/go.work` | +1 | 添加 platform-bridge 模块 |
---
## 四、关键设计决策
### 4.1 导入循环解决
原始设计中 `adapter` 包定义了 `PlatformAdapter` 接口,`bridge` 包的 `router.go` 引用它,但 `adapter` 实现文件又引用 `bridge` 的类型。产生了循环依赖:
```
adapter ↔ bridge (import cycle)
```
**解决方案**:将 `PlatformAdapter` 接口和 `MessageHandler` 类型从 `internal/adapter/` 移到 `internal/bridge/adapter.go`,与它们引用的类型 (`UnifiedMessage`, `UnifiedResponse`) 放在同一包中。
同理,`permissions ↔ bridge` 循环通过将 `PlatformIdentity``bridge/mapper.go` 移到 `permissions/permissions.go` 解决。
### 4.2 QQ 适配器的反向 WebSocket
QQ 使用 OneBot v11 协议,采用反向 WebSocket 模式:
- **平台桥接启动 WebSocket 服务器** (端口 8096)
- **QQ 机器人框架主动连接到此端口**
- 消息通过 WebSocket 双向传递
```go
// Connect 非阻塞:在 goroutine 中启动 HTTP Server
mux.HandleFunc("/ws/qq", func(w, r) { upgrade(w, r) })
go srv.ListenAndServe()
```
### 4.3 Telegram Webhook 模式
Telegram 适配器使用 Bot API webhook
- **启动时**调用 `setWebhook` 注册回调 URL
- **运行时**被动接收 Telegram 服务器推送的 Update
- **ToUnified** 同时支持结构化 `*TelegramUpdate` 和原始 `map[string]interface{}`,避免 HTTP handler 与适配器类型耦合
### 4.4 桩适配器策略
WeChat、Feishu、Discord 目前为桩实现。它们实现了完整的 `PlatformAdapter` 接口但返回错误信息,提示需要哪个 SDK。这样做的好处:
- 路由注册和平台列表正常工作
- 当获取到对应的 SDK 和凭据后,只需替换桩实现即可
- Auth 系统可通过 API 查看所有平台状态
### 4.5 权限模型
```
admin → 所有权限 (聊天/IoT操控/查询/记忆管理/系统管理/全部工具/全部设备)
full → 聊天 + IoT操控 + IoT查询 + 记忆访问
basic → 聊天 + IoT查询
restricted → 仅聊天
```
权限检查器支持细粒度的工具白名单和设备白名单,admin 自动拥有所有权限。
---
## 五、API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/health` | 健康检查,返回平台列表 |
| GET | `/api/v1/platforms` | 列出所有平台及其能力 |
| GET | `/api/v1/platforms/{name}` | 单个平台详情 |
| GET | `/api/v1/identities` | 列出所有身份映射 |
| POST | `/api/v1/webhook/telegram` | 接收 Telegram Bot API Update |
| POST | `/api/v1/webhook/{platform}` | 接收通用 Webhook 消息 |
---
## 六、环境变量
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `PORT` | 8095 | 桥接服务 HTTP 端口 |
| `AI_CORE_URL` | http://localhost:8081 | AI-Core 地址 |
| `QQ_BOT_PORT` | 8096 | QQ OBv11 WebSocket 监听端口 |
| `TELEGRAM_BOT_TOKEN` | (空) | Telegram Bot API Token |
| `TELEGRAM_WEBHOOK_URL` | (空) | Telegram Webhook 回调 URL |
| `QQ_ADMIN_UID` | (空) | QQ 管理员 UID (种子身份) |
| `TELEGRAM_ADMIN_UID` | (空) | Telegram 管理员 UID (种子身份) |
---
## 七、已知限制
1. **WeChat/Feishu/Discord 为桩** — 需要对应的 SDK 和凭据后才能启用
2. **QQ 适配器未实现心跳** — OBv11 协议中 bot 需要定期发送心跳包维持连接
3. **Telegram 未处理 callback_query** — 目前仅处理 message 类型的 Update
4. **平台桥接与 Gateway 之间无内部认证**`forwardToAICore` 直接调用 AI-Core,绕过 Gateway。后续若需要 Gateway 统一管理会话,应通过 Gateway 的 HTTP API 而非 WebSocket
---
## 八、验证指南
```bash
# 1. 编译 platform-bridge
cd backend/platform-bridge
GOWORK=off go build ./cmd/main.go
# 2. 启动服务
./main
# 3. 健康检查
curl http://localhost:8095/health
# 4. 查看注册的平台
curl http://localhost:8095/api/v1/platforms
# 5. 测试通用 Webhook
curl -X POST http://localhost:8095/api/v1/webhook/webhook \
-H "Content-Type: application/json" \
-d '{"user_id":"test_user","user_name":"测试用户","content":"你好昔涟","channel_id":"test_channel"}'
# 6. 查看身份映射
curl http://localhost:8095/api/v1/identities
```
+1
View File
@@ -36,3 +36,4 @@
- [Phase 2 - 人格与交互深化](2026-05-23-phase2-personality-interaction.md) — 情感状态机 + 主动消息决策增强 + 离线自主思考 (16 文件)
- [Phase 3 - 插件与工具系统](2026-05-23-phase3-plugin-tool-system.md) — Plugin SDK + Plugin Manager + 13 内置插件 + 工具分级 (40 文件)
- [Phase 4 - 多平台接入](2026-05-23-phase4-multi-platform.md) — 统一消息模型 + QQ/Telegram/Webhook 适配器 + 身份权限系统 (18 文件)