feat: Phase 4 多平台接入 — Platform Bridge + 6平台适配器 + 身份权限系统 (22文件, 2129行)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user