1063 lines
27 KiB
Go
1063 lines
27 KiB
Go
package wpsxiezuo
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/hmac"
|
|
"crypto/md5"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var (
|
|
wsEndpoint = "wss://openapi.wps.cn/v7/event/ws"
|
|
tokenEndpoint = "https://openapi.wps.cn"
|
|
maxBackoff = 60 * time.Second
|
|
)
|
|
|
|
// Platform implements core.Platform for WPS Xiezuo (WPS 协作).
|
|
type Platform struct {
|
|
appID string
|
|
appSecret string
|
|
baseURL string
|
|
cleanReply bool
|
|
allowFrom string
|
|
handler core.MessageHandler
|
|
cancel context.CancelFunc
|
|
conn *websocket.Conn
|
|
mu sync.Mutex // protects conn access
|
|
writeCh chan any // serializes all WebSocket writes (ACK, reactions, etc.)
|
|
dedup core.MessageDedup
|
|
token string
|
|
tokenExpire time.Time
|
|
tokenMu sync.Mutex
|
|
stopOnce sync.Once
|
|
stopped bool
|
|
}
|
|
|
|
// replyContext holds the context needed to reply to a specific message.
|
|
type replyContext struct {
|
|
ChatID string `json:"chat_id"`
|
|
ChatType string `json:"chat_type"`
|
|
CompanyID string `json:"company_id"`
|
|
MessageID string `json:"message_id"`
|
|
SenderID string `json:"sender_id"`
|
|
}
|
|
|
|
// --- WPS event frame types ---
|
|
|
|
// wpsEventFrame represents an event frame from the WPS WebSocket.
|
|
type wpsEventFrame struct {
|
|
Topic string `json:"topic"`
|
|
Operation string `json:"operation"`
|
|
Time int64 `json:"time"`
|
|
Nonce string `json:"nonce"`
|
|
Signature string `json:"signature"`
|
|
EncryptedData string `json:"encrypted_data"`
|
|
AccessKey string `json:"access_key"`
|
|
}
|
|
|
|
// wpsGoAwayFrame represents a goaway control frame.
|
|
type wpsGoAwayFrame struct {
|
|
Type string `json:"type"`
|
|
Reason string `json:"reason"`
|
|
Message string `json:"message"`
|
|
ReconnectMs int64 `json:"reconnect_ms,omitempty"`
|
|
}
|
|
|
|
// pongFrame is sent through writeCh to reply to a server ping.
|
|
type pongFrame struct {
|
|
Type string `json:"-"` // not JSON-encoded, used for type switch
|
|
Data string
|
|
}
|
|
|
|
// pingControl is sent through writeCh to send a client ping.
|
|
type pingControl struct{}
|
|
|
|
// wpsMessageData represents the decrypted message event data.
|
|
type wpsMessageData struct {
|
|
Chat wpsChatInfo `json:"chat"`
|
|
CompanyID string `json:"company_id"`
|
|
Message wpsMessageInfo `json:"message"`
|
|
SendTime int64 `json:"send_time"`
|
|
Sender wpsSenderInfo `json:"sender"`
|
|
}
|
|
|
|
type wpsChatInfo struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type wpsMessageInfo struct {
|
|
Content json.RawMessage `json:"content"`
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type wpsSenderInfo struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// --- Message create API ---
|
|
|
|
type sendMessageRequest struct {
|
|
Type string `json:"type"`
|
|
Receiver receiverInfo `json:"receiver"`
|
|
Content messageContent `json:"content"`
|
|
}
|
|
|
|
type receiverInfo struct {
|
|
Type string `json:"type"`
|
|
ReceiverID string `json:"receiver_id"`
|
|
}
|
|
|
|
type messageContent struct {
|
|
Text textContent `json:"text"`
|
|
}
|
|
|
|
type textContent struct {
|
|
Content string `json:"content"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// --- Token API ---
|
|
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
}
|
|
|
|
// --- Reaction API ---
|
|
|
|
type reactionRequest struct {
|
|
ReactionType string `json:"reaction_type"`
|
|
}
|
|
|
|
// --- Factory ---
|
|
|
|
func init() {
|
|
core.RegisterPlatform("wps-xiezuo", New)
|
|
}
|
|
|
|
// New creates a new WPS Xiezuo platform from config options.
|
|
func New(opts map[string]any) (core.Platform, error) {
|
|
appID, _ := opts["app_id"].(string)
|
|
appSecret, _ := opts["app_secret"].(string)
|
|
if appID == "" || appSecret == "" {
|
|
return nil, fmt.Errorf("wps-xiezuo: app_id and app_secret are required")
|
|
}
|
|
|
|
baseURL := tokenEndpoint
|
|
if v, ok := opts["base_url"].(string); ok && v != "" {
|
|
baseURL = strings.TrimRight(v, "/")
|
|
}
|
|
|
|
cleanReply, _ := opts["clean_reply"].(bool)
|
|
allowFrom, _ := opts["allow_from"].(string)
|
|
|
|
core.CheckAllowFrom("wps-xiezuo", allowFrom)
|
|
|
|
return &Platform{
|
|
appID: appID,
|
|
appSecret: appSecret,
|
|
baseURL: baseURL,
|
|
cleanReply: cleanReply,
|
|
allowFrom: allowFrom,
|
|
}, nil
|
|
}
|
|
|
|
func (p *Platform) Name() string { return "wps-xiezuo" }
|
|
|
|
// Start begins the WebSocket connection loop.
|
|
func (p *Platform) Start(handler core.MessageHandler) error {
|
|
p.handler = handler
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
p.cancel = cancel
|
|
go p.connectLoop(ctx)
|
|
return nil
|
|
}
|
|
|
|
// Stop cancels the context and closes the WebSocket connection.
|
|
func (p *Platform) Stop() error {
|
|
p.stopOnce.Do(func() {
|
|
p.stopped = true
|
|
if p.cancel != nil {
|
|
p.cancel()
|
|
}
|
|
p.mu.Lock()
|
|
if p.conn != nil {
|
|
p.conn.Close()
|
|
p.conn = nil
|
|
}
|
|
p.mu.Unlock()
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// --- WebSocket connection loop with exponential backoff ---
|
|
|
|
func (p *Platform) connectLoop(ctx context.Context) {
|
|
backoff := time.Second
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
start := time.Now()
|
|
err := p.runConnection(ctx)
|
|
if p.stopped || ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
// Reset backoff if connection was alive long enough
|
|
if time.Since(start) > 2*time.Minute {
|
|
backoff = time.Second
|
|
}
|
|
|
|
slog.Warn("wps-xiezuo: connection lost, reconnecting", "error", err, "backoff", backoff)
|
|
select {
|
|
case <-time.After(backoff):
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
|
|
backoff *= 2
|
|
if backoff > maxBackoff {
|
|
backoff = maxBackoff
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *Platform) runConnection(ctx context.Context) error {
|
|
slog.Info("wps-xiezuo: connecting", "endpoint", wsEndpoint)
|
|
|
|
header, err := p.signWSHeader()
|
|
if err != nil {
|
|
return fmt.Errorf("sign header: %w", err)
|
|
}
|
|
|
|
dialer := websocket.DefaultDialer
|
|
conn, _, err := dialer.DialContext(ctx, wsEndpoint, header)
|
|
if err != nil {
|
|
return fmt.Errorf("dial: %w", err)
|
|
}
|
|
|
|
p.mu.Lock()
|
|
p.conn = conn
|
|
writeCh := make(chan any, 64)
|
|
p.writeCh = writeCh
|
|
p.mu.Unlock()
|
|
|
|
defer func() {
|
|
p.mu.Lock()
|
|
p.conn = nil
|
|
p.writeCh = nil
|
|
p.mu.Unlock()
|
|
close(writeCh)
|
|
conn.Close()
|
|
}()
|
|
|
|
slog.Info("wps-xiezuo: connected")
|
|
|
|
// Set up control frame handlers.
|
|
// CRITICAL: Both handlers must reset the read deadline, otherwise
|
|
// the connection times out even though heartbeats are flowing.
|
|
const pingTimeout = 90 * time.Second
|
|
conn.SetPingHandler(func(appData string) error {
|
|
slog.Debug("wps-xiezuo: server ping received")
|
|
_ = conn.SetReadDeadline(time.Now().Add(pingTimeout))
|
|
p.mu.Lock()
|
|
ch := p.writeCh
|
|
p.mu.Unlock()
|
|
if ch != nil {
|
|
select {
|
|
case ch <- pongFrame{Type: "pong", Data: appData}:
|
|
default:
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
conn.SetPongHandler(func(appData string) error {
|
|
slog.Debug("wps-xiezuo: server pong received")
|
|
_ = conn.SetReadDeadline(time.Now().Add(pingTimeout))
|
|
return nil
|
|
})
|
|
|
|
// Start writer goroutine to serialize all WebSocket writes
|
|
writeCtx, writeCancel := context.WithCancel(ctx)
|
|
defer writeCancel()
|
|
go p.writeLoop(writeCtx, conn, writeCh)
|
|
|
|
// Send client pings every 25s to keep the connection alive
|
|
go func() {
|
|
ticker := time.NewTicker(25 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-writeCtx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
p.mu.Lock()
|
|
ch := p.writeCh
|
|
p.mu.Unlock()
|
|
if ch != nil {
|
|
select {
|
|
case ch <- pingControl{}:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Read deadline: 90s for PING timeout (matching Node.js SDK)
|
|
_ = conn.SetReadDeadline(time.Now().Add(pingTimeout))
|
|
|
|
// Read loop
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
msgType, raw, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return fmt.Errorf("read: %w", err)
|
|
}
|
|
slog.Info("wps-xiezuo: message received", "type", msgType, "len", len(raw), "data", string(raw))
|
|
|
|
// Reset deadline on successful read
|
|
_ = conn.SetReadDeadline(time.Now().Add(pingTimeout))
|
|
|
|
p.handleRawMessage(ctx, raw)
|
|
}
|
|
}
|
|
|
|
// writeLoop serializes all WebSocket writes (ACK frames, pongs, pings, etc.) on a single goroutine.
|
|
// gorilla/websocket requires all writes to be serialized.
|
|
func (p *Platform) writeLoop(ctx context.Context, conn *websocket.Conn, writeCh chan any) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case msg, ok := <-writeCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
switch v := msg.(type) {
|
|
case pongFrame:
|
|
if err := conn.WriteControl(websocket.PongMessage, []byte(v.Data), time.Now().Add(5*time.Second)); err != nil {
|
|
slog.Debug("wps-xiezuo: pong write error", "error", err)
|
|
return
|
|
}
|
|
slog.Debug("wps-xiezuo: pong sent")
|
|
case pingControl:
|
|
if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err != nil {
|
|
slog.Debug("wps-xiezuo: ping write error", "error", err)
|
|
return
|
|
}
|
|
slog.Debug("wps-xiezuo: client ping sent")
|
|
default:
|
|
if err := conn.WriteJSON(msg); err != nil {
|
|
slog.Debug("wps-xiezuo: write error", "error", err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- KSO-1 HMAC-SHA256 signing ---
|
|
|
|
func (p *Platform) signWSHeader() (http.Header, error) {
|
|
u, err := url.Parse(wsEndpoint)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse ws url: %w", err)
|
|
}
|
|
|
|
uri := u.RequestURI()
|
|
dateStr := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT")
|
|
|
|
// stringToSign = "KSO-1" + method + uri + contentType + date + sha256Hex
|
|
// For WebSocket: method=GET, contentType="", sha256Hex=""
|
|
stringToSign := "KSO-1" + "GET" + uri + "" + dateStr + ""
|
|
|
|
mac := hmac.New(sha256.New, []byte(p.appSecret))
|
|
mac.Write([]byte(stringToSign))
|
|
signature := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
header := http.Header{
|
|
"X-Kso-Date": {dateStr},
|
|
"X-Kso-Authorization": {fmt.Sprintf("KSO-1 %s:%s", p.appID, signature)},
|
|
"X-Ack-Mode": {"required"},
|
|
}
|
|
|
|
return header, nil
|
|
}
|
|
|
|
// --- Raw message dispatch ---
|
|
|
|
func (p *Platform) handleRawMessage(ctx context.Context, raw []byte) {
|
|
// Try to detect frame type
|
|
var probe map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &probe); err != nil {
|
|
slog.Warn("wps-xiezuo: invalid json", "error", err)
|
|
return
|
|
}
|
|
|
|
// GoAway frame: has "type": "goaway"
|
|
if t, ok := probe["type"]; ok {
|
|
var typeStr string
|
|
if err := json.Unmarshal(t, &typeStr); err == nil {
|
|
if typeStr == "goaway" {
|
|
var goAway wpsGoAwayFrame
|
|
if err := json.Unmarshal(raw, &goAway); err == nil {
|
|
p.handleGoAway(goAway)
|
|
}
|
|
return
|
|
}
|
|
// Ignore other control frames (ack, etc.)
|
|
slog.Debug("wps-xiezuo: control frame", "type", typeStr)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Event frame: has "topic" and "operation"
|
|
if _, hasTopic := probe["topic"]; hasTopic {
|
|
var event wpsEventFrame
|
|
if err := json.Unmarshal(raw, &event); err != nil {
|
|
slog.Warn("wps-xiezuo: parse event frame failed", "error", err)
|
|
return
|
|
}
|
|
p.handleEvent(event)
|
|
return
|
|
}
|
|
|
|
slog.Debug("wps-xiezuo: unknown frame", "data", string(raw))
|
|
}
|
|
|
|
// --- GoAway handling ---
|
|
|
|
func (p *Platform) handleGoAway(goAway wpsGoAwayFrame) {
|
|
slog.Warn("wps-xiezuo: goaway received", "reason", goAway.Reason, "message", goAway.Message)
|
|
|
|
if goAway.Reason == "connection_replaced" {
|
|
slog.Warn("wps-xiezuo: connection replaced, stopping reconnect")
|
|
p.stopped = true
|
|
_ = p.Stop()
|
|
return
|
|
}
|
|
|
|
// For other reasons (server_shutdown etc.), normal reconnect will happen
|
|
if goAway.ReconnectMs > 0 {
|
|
time.Sleep(time.Duration(goAway.ReconnectMs) * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
// --- Event handling ---
|
|
|
|
func (p *Platform) handleEvent(event wpsEventFrame) {
|
|
// Verify signature
|
|
if !p.verifyEventSignature(event) {
|
|
slog.Warn("wps-xiezuo: signature verification failed", "topic", event.Topic, "nonce", event.Nonce)
|
|
return
|
|
}
|
|
|
|
// Decrypt data
|
|
plain, err := p.decryptEventData(event.Nonce, event.EncryptedData)
|
|
if err != nil {
|
|
slog.Warn("wps-xiezuo: decrypt failed", "error", err, "topic", event.Topic)
|
|
return
|
|
}
|
|
|
|
// Dispatch by topic+operation. Avoid logging decrypted user content.
|
|
slog.Info("wps-xiezuo: decrypted event", "topic", event.Topic, "operation", event.Operation, "payload_bytes", len(plain))
|
|
switch {
|
|
case event.Topic == "kso.app_chat.message" && event.Operation == "create":
|
|
p.sendAck(event.Nonce, nil)
|
|
p.handleChatMessage(plain)
|
|
case event.Topic == "kso.app_chat.message.recall":
|
|
p.sendAck(event.Nonce, nil)
|
|
p.handleChatMessageRecall(plain)
|
|
default:
|
|
p.sendAck(event.Nonce, nil)
|
|
slog.Debug("wps-xiezuo: unhandled event", "topic", event.Topic, "operation", event.Operation)
|
|
}
|
|
}
|
|
|
|
// --- Signature verification ---
|
|
|
|
func (p *Platform) verifyEventSignature(event wpsEventFrame) bool {
|
|
content := fmt.Sprintf("%s:%s:%s:%d:%s", p.appID, event.Topic, event.Nonce, event.Time, event.EncryptedData)
|
|
mac := hmac.New(sha256.New, []byte(p.appSecret))
|
|
mac.Write([]byte(content))
|
|
expectedSig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
expectedSig = strings.TrimRight(expectedSig, "=")
|
|
|
|
return hmac.Equal([]byte(event.Signature), []byte(expectedSig))
|
|
}
|
|
|
|
// --- AES-256-CBC decryption ---
|
|
|
|
func (p *Platform) decryptEventData(nonce, encryptedData string) ([]byte, error) {
|
|
// key = MD5(appSecret).hexdigest() → 32 bytes
|
|
hash := md5.Sum([]byte(p.appSecret))
|
|
key := []byte(hex.EncodeToString(hash[:])) // 32 bytes
|
|
|
|
// iv = nonce[:16]
|
|
iv := []byte(nonce)
|
|
if len(iv) > 16 {
|
|
iv = iv[:16]
|
|
}
|
|
if len(iv) < 16 {
|
|
// Pad with zeros if nonce is shorter than 16 bytes
|
|
iv = append(iv, make([]byte, 16-len(iv))...)
|
|
}
|
|
|
|
// Base64 decode ciphertext
|
|
ciphertext, err := base64.StdEncoding.DecodeString(encryptedData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("base64 decode: %w", err)
|
|
}
|
|
|
|
// AES-CBC decrypt
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("aes cipher: %w", err)
|
|
}
|
|
|
|
if len(ciphertext)%aes.BlockSize != 0 {
|
|
return nil, fmt.Errorf("ciphertext not multiple of block size")
|
|
}
|
|
|
|
mode := cipher.NewCBCDecrypter(block, iv)
|
|
plaintext := make([]byte, len(ciphertext))
|
|
mode.CryptBlocks(plaintext, ciphertext)
|
|
|
|
// PKCS7 unpadding
|
|
plaintext, err = pkcs7Unpad(plaintext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pkcs7 unpad: %w", err)
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
func pkcs7Unpad(data []byte) ([]byte, error) {
|
|
if len(data) == 0 {
|
|
return nil, fmt.Errorf("empty data")
|
|
}
|
|
padLen := int(data[len(data)-1])
|
|
if padLen > len(data) || padLen > aes.BlockSize {
|
|
return nil, fmt.Errorf("invalid padding length %d", padLen)
|
|
}
|
|
for i := len(data) - padLen; i < len(data); i++ {
|
|
if data[i] != byte(padLen) {
|
|
return nil, fmt.Errorf("invalid padding byte at %d", i)
|
|
}
|
|
}
|
|
return data[:len(data)-padLen], nil
|
|
}
|
|
|
|
// --- ACK ---
|
|
|
|
func (p *Platform) sendAck(nonce string, err error) {
|
|
if nonce == "" {
|
|
return
|
|
}
|
|
ack := map[string]any{
|
|
"type": "ack",
|
|
"nonce": nonce,
|
|
"code": 200,
|
|
}
|
|
if err != nil {
|
|
ack["code"] = 500
|
|
ack["msg"] = err.Error()
|
|
if len(err.Error()) > 256 {
|
|
ack["msg"] = err.Error()[:256]
|
|
}
|
|
}
|
|
p.mu.Lock()
|
|
ch := p.writeCh
|
|
p.mu.Unlock()
|
|
if ch != nil {
|
|
select {
|
|
case ch <- ack:
|
|
slog.Info("wps-xiezuo: ack queued", "nonce", nonce)
|
|
default:
|
|
slog.Warn("wps-xiezuo: write channel full, dropping ack", "nonce", nonce)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Chat message handling ---
|
|
|
|
func (p *Platform) handleChatMessage(plain []byte) {
|
|
var msgData wpsMessageData
|
|
if err := json.Unmarshal(plain, &msgData); err != nil {
|
|
slog.Warn("wps-xiezuo: parse message data failed", "error", err)
|
|
return
|
|
}
|
|
|
|
if p.dedup.IsDuplicate(msgData.Message.ID) {
|
|
slog.Debug("wps-xiezuo: skipping duplicate message", "msg_id", msgData.Message.ID)
|
|
return
|
|
}
|
|
|
|
if !core.AllowList(p.allowFrom, msgData.Sender.ID) {
|
|
slog.Debug("wps-xiezuo: message from unauthorized user", "user", msgData.Sender.ID)
|
|
return
|
|
}
|
|
|
|
// Extract text content
|
|
text := extractText(msgData.Message.Content)
|
|
if text == "" {
|
|
slog.Debug("wps-xiezuo: no text content in message", "msg_id", msgData.Message.ID)
|
|
return
|
|
}
|
|
|
|
// Build session key. P2P sessions include both actual chat ID and sender ID:
|
|
// chat ID is needed for proactive sends, sender ID keeps the session user-scoped.
|
|
sessionKey := fmt.Sprintf("wps-xiezuo:%s:%s", msgData.CompanyID, msgData.Chat.ID)
|
|
if isP2P(msgData.Chat.Type) {
|
|
sessionKey = fmt.Sprintf("wps-xiezuo:%s:%s:%s", msgData.CompanyID, msgData.Chat.ID, msgData.Sender.ID)
|
|
}
|
|
|
|
rctx := replyContext{
|
|
ChatID: msgData.Chat.ID, // Always use actual chat ID for WPS API
|
|
ChatType: msgData.Chat.Type,
|
|
CompanyID: msgData.CompanyID,
|
|
MessageID: msgData.Message.ID,
|
|
SenderID: msgData.Sender.ID,
|
|
}
|
|
|
|
go p.handler(p, &core.Message{
|
|
SessionKey: sessionKey,
|
|
Platform: "wps-xiezuo",
|
|
MessageID: msgData.Message.ID,
|
|
UserID: msgData.Sender.ID,
|
|
UserName: msgData.Sender.ID, // WPS doesn't include name in event data
|
|
Content: text,
|
|
ReplyCtx: rctx,
|
|
})
|
|
}
|
|
|
|
func (p *Platform) handleChatMessageRecall(plain []byte) {
|
|
// Recall event has a flat structure: {"chat_id":"...","id":"...","operator":{...}}
|
|
var recallData struct {
|
|
ChatID string `json:"chat_id"`
|
|
ID string `json:"id"`
|
|
CompanyID string `json:"company_id"`
|
|
Operator struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
} `json:"operator"`
|
|
}
|
|
if err := json.Unmarshal(plain, &recallData); err != nil {
|
|
slog.Warn("wps-xiezuo: parse recall data failed", "error", err)
|
|
return
|
|
}
|
|
|
|
sessionKey := fmt.Sprintf("wps-xiezuo:%s:%s", recallData.CompanyID, recallData.ChatID)
|
|
|
|
rctx := replyContext{
|
|
ChatID: recallData.ChatID,
|
|
ChatType: "p2p",
|
|
CompanyID: recallData.CompanyID,
|
|
MessageID: recallData.ID,
|
|
SenderID: recallData.Operator.ID,
|
|
}
|
|
|
|
go p.handler(p, &core.Message{
|
|
SessionKey: sessionKey,
|
|
Platform: "wps-xiezuo",
|
|
MessageID: recallData.ID,
|
|
Recalled: true,
|
|
UserID: recallData.Operator.ID,
|
|
ReplyCtx: rctx,
|
|
})
|
|
}
|
|
|
|
// --- Text extraction ---
|
|
|
|
func extractText(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// WPS v7 format: {"text":{"content":"xxx"}}
|
|
var wpsContent struct {
|
|
Text struct {
|
|
Content string `json:"content"`
|
|
} `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wpsContent); err == nil && wpsContent.Text.Content != "" {
|
|
return strings.TrimSpace(wpsContent.Text.Content)
|
|
}
|
|
|
|
// Try {"type":"text","content":"xxx"}
|
|
var simple struct {
|
|
Type string `json:"type"`
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &simple); err == nil {
|
|
if simple.Type == "text" || simple.Type == "" {
|
|
return extractStringContent(simple.Content)
|
|
}
|
|
if simple.Type == "rich_text" {
|
|
return extractRichText(simple.Content)
|
|
}
|
|
}
|
|
|
|
// Fallback: try as plain string
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractStringContent(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
// Try as string
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err == nil {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
// Try as {"content":"xxx"}
|
|
var obj struct {
|
|
Content string `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &obj); err == nil {
|
|
return strings.TrimSpace(obj.Content)
|
|
}
|
|
return strings.TrimSpace(string(raw))
|
|
}
|
|
|
|
func extractRichText(raw json.RawMessage) string {
|
|
var blocks []struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(raw, &blocks); err != nil {
|
|
return ""
|
|
}
|
|
parts := make([]string, 0, len(blocks))
|
|
for _, b := range blocks {
|
|
if b.Text != "" {
|
|
parts = append(parts, b.Text)
|
|
}
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func isP2P(chatType string) bool {
|
|
return chatType == "p2p" || chatType == "single" || chatType == "direct"
|
|
}
|
|
|
|
// --- Reply/Send ---
|
|
|
|
// Reply sends a message back to the WPS chat via REST API.
|
|
func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
|
|
return p.sendWPSMessage(ctx, rctx, content)
|
|
}
|
|
|
|
// Send sends a proactive message to the WPS chat via REST API.
|
|
func (p *Platform) Send(ctx context.Context, rctx any, content string) error {
|
|
return p.sendWPSMessage(ctx, rctx, content)
|
|
}
|
|
|
|
func (p *Platform) sendWPSMessage(ctx context.Context, rctx any, content string) error {
|
|
rc, ok := rctx.(replyContext)
|
|
if !ok {
|
|
return fmt.Errorf("wps-xiezuo: invalid reply context type %T", rctx)
|
|
}
|
|
if content == "" {
|
|
return nil
|
|
}
|
|
|
|
if p.cleanReply {
|
|
content = cleanReplyContent(content)
|
|
}
|
|
|
|
token, err := p.getToken(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("wps-xiezuo: get token: %w", err)
|
|
}
|
|
|
|
reqBody := sendMessageRequest{
|
|
Type: "text",
|
|
Receiver: receiverInfo{
|
|
Type: "chat",
|
|
ReceiverID: rc.ChatID,
|
|
},
|
|
Content: messageContent{
|
|
Text: textContent{
|
|
Content: content,
|
|
Type: "markdown",
|
|
},
|
|
},
|
|
}
|
|
|
|
body, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return fmt.Errorf("wps-xiezuo: marshal request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/v7/messages/create", bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("wps-xiezuo: create request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("wps-xiezuo: send request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("wps-xiezuo: send failed: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
slog.Debug("wps-xiezuo: message sent", "chat_id", rc.ChatID, "len", len(content))
|
|
return nil
|
|
}
|
|
|
|
// --- Token management ---
|
|
|
|
func (p *Platform) getToken(ctx context.Context) (string, error) {
|
|
p.tokenMu.Lock()
|
|
defer p.tokenMu.Unlock()
|
|
|
|
if p.token != "" && time.Now().Before(p.tokenExpire.Add(-60*time.Second)) {
|
|
return p.token, nil
|
|
}
|
|
|
|
formData := url.Values{
|
|
"grant_type": {"client_credentials"},
|
|
"client_id": {p.appID},
|
|
"client_secret": {p.appSecret},
|
|
}
|
|
|
|
// Try primary endpoint first, then fallback
|
|
endpoints := []string{p.baseURL + "/oauth2/token", p.baseURL + "/openapi/oauth2/token"}
|
|
var lastErr error
|
|
|
|
for _, endpoint := range endpoints {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(formData.Encode()))
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
lastErr = fmt.Errorf("status=%d body=%s", resp.StatusCode, string(respBody))
|
|
continue
|
|
}
|
|
|
|
var tokenResp tokenResponse
|
|
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
|
|
if tokenResp.AccessToken == "" {
|
|
lastErr = fmt.Errorf("empty access_token")
|
|
continue
|
|
}
|
|
|
|
p.token = tokenResp.AccessToken
|
|
if tokenResp.ExpiresIn > 0 {
|
|
p.tokenExpire = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
|
|
} else {
|
|
p.tokenExpire = time.Now().Add(7200 * time.Second)
|
|
}
|
|
|
|
slog.Info("wps-xiezuo: token obtained", "expires_in", tokenResp.ExpiresIn)
|
|
return p.token, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("wps-xiezuo: all token endpoints failed: %w", lastErr)
|
|
}
|
|
|
|
// --- Reaction API (typing indicator) ---
|
|
|
|
func (p *Platform) addReaction(ctx context.Context, rctx replyContext, reactionType string) error {
|
|
token, err := p.getToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body, _ := json.Marshal(reactionRequest{ReactionType: reactionType})
|
|
url := fmt.Sprintf("%s/v7/chats/%s/messages/%s/reactions/create", p.baseURL, rctx.ChatID, rctx.MessageID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("add reaction failed: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (p *Platform) deleteReaction(ctx context.Context, rctx replyContext, reactionType string) error {
|
|
token, err := p.getToken(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
body, _ := json.Marshal(reactionRequest{ReactionType: reactionType})
|
|
url := fmt.Sprintf("%s/v7/chats/%s/messages/%s/reactions/delete", p.baseURL, rctx.ChatID, rctx.MessageID)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("delete reaction failed: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Optional interface: ReplyContextReconstructor ---
|
|
|
|
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
|
|
// Formats:
|
|
// wps-xiezuo:{company_id}:{chat_id} - group or legacy P2P
|
|
// wps-xiezuo:{company_id}:{chat_id}:{sender_id} - P2P, user-scoped
|
|
parts := strings.SplitN(sessionKey, ":", 4)
|
|
if len(parts) < 3 || parts[0] != "wps-xiezuo" {
|
|
return nil, fmt.Errorf("wps-xiezuo: invalid session key %q", sessionKey)
|
|
}
|
|
rc := replyContext{
|
|
ChatID: parts[2],
|
|
CompanyID: parts[1],
|
|
}
|
|
if len(parts) == 4 {
|
|
rc.ChatType = "p2p"
|
|
rc.SenderID = parts[3]
|
|
}
|
|
return rc, nil
|
|
}
|
|
|
|
// --- Optional interface: TypingIndicator ---
|
|
|
|
func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {
|
|
rc, ok := rctx.(replyContext)
|
|
if !ok {
|
|
return func() {}
|
|
}
|
|
if rc.ChatID == "" || rc.MessageID == "" {
|
|
return func() {}
|
|
}
|
|
if err := p.addReaction(ctx, rc, "emoji_busy"); err != nil {
|
|
slog.Debug("wps-xiezuo: add typing reaction failed", "error", err)
|
|
}
|
|
return func() {}
|
|
}
|
|
|
|
// --- Optional interface: TypingIndicatorDone ---
|
|
|
|
func (p *Platform) AddDoneReaction(rctx any) {
|
|
rc, ok := rctx.(replyContext)
|
|
if !ok {
|
|
return
|
|
}
|
|
if rc.ChatID == "" || rc.MessageID == "" {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := p.deleteReaction(ctx, rc, "emoji_busy"); err != nil {
|
|
slog.Debug("wps-xiezuo: delete typing reaction failed", "error", err)
|
|
}
|
|
}
|
|
|
|
// --- Clean reply content ---
|
|
|
|
func cleanReplyContent(content string) string {
|
|
lines := strings.Split(content, "\n")
|
|
var filtered []string
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimLeft(line, " \t")
|
|
if strings.HasPrefix(trimmed, "💭") || strings.HasPrefix(trimmed, "🔧") || strings.HasPrefix(trimmed, "🧾") {
|
|
continue
|
|
}
|
|
filtered = append(filtered, line)
|
|
}
|
|
result := strings.Join(filtered, "\n")
|
|
result = strings.TrimSpace(result)
|
|
if result == "" {
|
|
return content // Return original if everything was filtered
|
|
}
|
|
return result
|
|
}
|
|
|
|
// --- Compile-time interface assertions ---
|
|
|
|
var (
|
|
_ core.Platform = (*Platform)(nil)
|
|
_ core.ReplyContextReconstructor = (*Platform)(nil)
|
|
_ core.TypingIndicator = (*Platform)(nil)
|
|
_ core.TypingIndicatorDone = (*Platform)(nil)
|
|
)
|