mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 22:09:37 +08:00
848 lines
24 KiB
Go
848 lines
24 KiB
Go
//go:build !omit_whatsapp
|
|
|
|
package channels
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"mime"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"go.mau.fi/whatsmeow"
|
|
waProto "go.mau.fi/whatsmeow/proto/waE2E"
|
|
"go.mau.fi/whatsmeow/store/sqlstore"
|
|
"go.mau.fi/whatsmeow/types"
|
|
"go.mau.fi/whatsmeow/types/events"
|
|
waLog "go.mau.fi/whatsmeow/util/log"
|
|
"google.golang.org/protobuf/proto"
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
type WhatsAppBridgeService struct {
|
|
addr string
|
|
stateDir string
|
|
printQR bool
|
|
httpServer *http.Server
|
|
client *whatsmeow.Client
|
|
container *sqlstore.Container
|
|
rawDB *sql.DB
|
|
cancel context.CancelFunc
|
|
wsUpgrader websocket.Upgrader
|
|
wsClients map[*websocket.Conn]struct{}
|
|
statusMu sync.RWMutex
|
|
status WhatsAppBridgeStatus
|
|
wsClientsMu sync.Mutex
|
|
markReadFn func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error
|
|
localOnly bool
|
|
}
|
|
|
|
type whatsappBridgeWSMessage struct {
|
|
Type string `json:"type"`
|
|
To string `json:"to,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
Chat string `json:"chat,omitempty"`
|
|
Content string `json:"content,omitempty"`
|
|
ReplyToID string `json:"reply_to_id,omitempty"`
|
|
ReplyToSender string `json:"reply_to_sender,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
FromName string `json:"from_name,omitempty"`
|
|
Media []string `json:"media,omitempty"`
|
|
}
|
|
|
|
func NewWhatsAppBridgeService(addr, stateDir string, printQR bool) *WhatsAppBridgeService {
|
|
return &WhatsAppBridgeService{
|
|
addr: strings.TrimSpace(addr),
|
|
stateDir: strings.TrimSpace(stateDir),
|
|
printQR: printQR,
|
|
wsUpgrader: websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
},
|
|
wsClients: map[*websocket.Conn]struct{}{},
|
|
status: WhatsAppBridgeStatus{
|
|
State: "starting",
|
|
BridgeAddr: strings.TrimSpace(addr),
|
|
UpdatedAt: time.Now().Format(time.RFC3339),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) Start(ctx context.Context) error {
|
|
if err := s.startRuntime(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
s.RegisterRoutes(mux, "")
|
|
s.httpServer = &http.Server{
|
|
Addr: s.addr,
|
|
Handler: mux,
|
|
}
|
|
|
|
ln, err := net.Listen("tcp", s.addr)
|
|
if err != nil {
|
|
return fmt.Errorf("listen whatsapp bridge: %w", err)
|
|
}
|
|
|
|
if err := s.httpServer.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) StartEmbedded(ctx context.Context) error {
|
|
s.localOnly = true
|
|
return s.startRuntime(ctx)
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) startRuntime(ctx context.Context) error {
|
|
if strings.TrimSpace(s.addr) == "" {
|
|
return fmt.Errorf("bridge address is required")
|
|
}
|
|
if strings.TrimSpace(s.stateDir) == "" {
|
|
return fmt.Errorf("bridge state directory is required")
|
|
}
|
|
if err := os.MkdirAll(s.stateDir, 0o755); err != nil {
|
|
return fmt.Errorf("create whatsapp state dir: %w", err)
|
|
}
|
|
if err := s.initClient(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
s.cancel = cancel
|
|
|
|
go func() {
|
|
<-runCtx.Done()
|
|
if s.httpServer != nil {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
_ = s.httpServer.Shutdown(shutdownCtx)
|
|
}
|
|
s.closeWSClients()
|
|
if s.client != nil {
|
|
s.client.Disconnect()
|
|
}
|
|
if s.rawDB != nil {
|
|
_ = s.rawDB.Close()
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
_ = s.connectClient(runCtx)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) Stop() {
|
|
if s.cancel != nil {
|
|
s.cancel()
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) RegisterRoutes(mux *http.ServeMux, basePath string) {
|
|
if mux == nil {
|
|
return
|
|
}
|
|
basePath = normalizeBridgeBasePath(basePath)
|
|
mux.HandleFunc(basePath, s.ServeWS)
|
|
mux.HandleFunc(joinBridgeRoute(basePath, "ws"), s.ServeWS)
|
|
mux.HandleFunc(joinBridgeRoute(basePath, "status"), s.ServeStatus)
|
|
mux.HandleFunc(joinBridgeRoute(basePath, "logout"), s.ServeLogout)
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) StatusSnapshot() WhatsAppBridgeStatus {
|
|
s.statusMu.RLock()
|
|
defer s.statusMu.RUnlock()
|
|
return s.status
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) initClient(ctx context.Context) error {
|
|
dbPath := filepath.Join(s.stateDir, "whatsmeow.sqlite")
|
|
rawDB, err := sql.Open("sqlite", dbPath)
|
|
if err != nil {
|
|
return fmt.Errorf("open whatsapp sqlite store: %w", err)
|
|
}
|
|
if _, err := rawDB.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
|
|
_ = rawDB.Close()
|
|
return fmt.Errorf("enable whatsapp sqlite foreign keys: %w", err)
|
|
}
|
|
container := sqlstore.NewWithDB(rawDB, "sqlite", waLog.Noop)
|
|
if err := container.Upgrade(ctx); err != nil {
|
|
_ = rawDB.Close()
|
|
return fmt.Errorf("upgrade whatsapp sqlite store: %w", err)
|
|
}
|
|
deviceStore, err := container.GetFirstDevice(ctx)
|
|
if err != nil {
|
|
_ = rawDB.Close()
|
|
return fmt.Errorf("load whatsapp device store: %w", err)
|
|
}
|
|
client := whatsmeow.NewClient(deviceStore, waLog.Noop)
|
|
client.EnableAutoReconnect = true
|
|
client.AddEventHandler(s.handleWAEvent)
|
|
|
|
s.rawDB = rawDB
|
|
s.container = container
|
|
s.client = client
|
|
s.markReadFn = func(ctx context.Context, ids []types.MessageID, timestamp time.Time, chat, sender types.JID) error {
|
|
return client.MarkRead(ctx, ids, timestamp, chat, sender)
|
|
}
|
|
if deviceStore.ID != nil {
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.LoggedIn = true
|
|
st.UserJID = deviceStore.ID.String()
|
|
st.State = "stored_session"
|
|
st.LastEvent = "stored_session"
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) connectClient(ctx context.Context) error {
|
|
if s.client == nil {
|
|
return fmt.Errorf("whatsapp bridge client is not initialized")
|
|
}
|
|
|
|
var qrChan <-chan whatsmeow.QRChannelItem
|
|
var err error
|
|
if s.client.Store.ID == nil {
|
|
qrChan, err = s.client.GetQRChannel(ctx)
|
|
if err != nil && !errors.Is(err, whatsmeow.ErrQRStoreContainsID) {
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "error"
|
|
st.LastError = err.Error()
|
|
st.LastEvent = "qr_init_failed"
|
|
})
|
|
return err
|
|
}
|
|
if qrChan != nil {
|
|
go s.consumeQRChannel(ctx, qrChan)
|
|
}
|
|
}
|
|
|
|
if err := s.client.Connect(); err != nil {
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "error"
|
|
st.Connected = false
|
|
st.LastError = err.Error()
|
|
st.LastEvent = "connect_failed"
|
|
})
|
|
return fmt.Errorf("connect whatsapp bridge: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) consumeQRChannel(ctx context.Context, qrChan <-chan whatsmeow.QRChannelItem) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case item, ok := <-qrChan:
|
|
if !ok {
|
|
return
|
|
}
|
|
switch item.Event {
|
|
case "code":
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "qr_ready"
|
|
st.QRCode = item.Code
|
|
st.QRAvailable = item.Code != ""
|
|
st.LastEvent = "qr_ready"
|
|
})
|
|
default:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.LastEvent = item.Event
|
|
if item.Event == whatsmeow.QRChannelSuccess.Event {
|
|
st.State = "paired"
|
|
st.QRCode = ""
|
|
st.QRAvailable = false
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) handleWAEvent(evt interface{}) {
|
|
switch v := evt.(type) {
|
|
case *events.Connected:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "connected"
|
|
st.Connected = true
|
|
st.LoggedIn = s.client != nil && s.client.Store.ID != nil
|
|
st.QRCode = ""
|
|
st.QRAvailable = false
|
|
st.LastEvent = "connected"
|
|
if s.client != nil && s.client.Store.ID != nil {
|
|
st.UserJID = s.client.Store.ID.String()
|
|
}
|
|
})
|
|
case *events.Disconnected:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.Connected = false
|
|
if st.LoggedIn {
|
|
st.State = "disconnected"
|
|
} else {
|
|
st.State = "waiting_qr"
|
|
}
|
|
st.LastEvent = "disconnected"
|
|
})
|
|
case *events.PairSuccess:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "paired"
|
|
st.LoggedIn = true
|
|
st.UserJID = v.ID.String()
|
|
st.Platform = v.Platform
|
|
st.QRCode = ""
|
|
st.QRAvailable = false
|
|
st.LastEvent = "pair_success"
|
|
})
|
|
case *events.LoggedOut:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "logged_out"
|
|
st.Connected = false
|
|
st.LoggedIn = false
|
|
st.UserJID = ""
|
|
st.QRCode = ""
|
|
st.QRAvailable = false
|
|
st.LastEvent = "logged_out"
|
|
})
|
|
case *events.StreamReplaced:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "stream_replaced"
|
|
st.Connected = false
|
|
st.LastEvent = "stream_replaced"
|
|
})
|
|
case *events.ClientOutdated:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "client_outdated"
|
|
st.Connected = false
|
|
st.LastError = "whatsapp web client outdated"
|
|
st.LastEvent = "client_outdated"
|
|
})
|
|
case *events.ConnectFailure:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "connect_failed"
|
|
st.Connected = false
|
|
st.LastError = v.Reason.String()
|
|
st.LastEvent = "connect_failure"
|
|
})
|
|
case *events.TemporaryBan:
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "temporary_ban"
|
|
st.Connected = false
|
|
st.LastError = v.String()
|
|
st.LastEvent = "temporary_ban"
|
|
})
|
|
case *events.Message:
|
|
if v.Info.IsFromMe {
|
|
return
|
|
}
|
|
isGroup := v.Info.Chat.Server == types.GroupServer
|
|
mentionedSelf, replyToMe := s.matchCurrentUserContext(v.Message)
|
|
payload := whatsappBridgeWSMessage{
|
|
Type: "message",
|
|
From: v.Info.Sender.ToNonAD().String(),
|
|
Chat: v.Info.Chat.ToNonAD().String(),
|
|
Content: extractWhatsAppMessageText(v.Message),
|
|
ID: v.Info.ID,
|
|
FromName: v.Info.PushName,
|
|
}
|
|
s.broadcastWSMap(map[string]interface{}{
|
|
"type": payload.Type,
|
|
"from": payload.From,
|
|
"chat": payload.Chat,
|
|
"content": payload.Content,
|
|
"id": payload.ID,
|
|
"from_name": payload.FromName,
|
|
"is_group": isGroup,
|
|
"mentioned_self": mentionedSelf,
|
|
"reply_to_me": replyToMe,
|
|
})
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.InboundCount++
|
|
st.LastInboundAt = time.Now().Format(time.RFC3339)
|
|
st.LastInboundFrom = payload.From
|
|
st.LastInboundText = truncateString(strings.TrimSpace(payload.Content), 120)
|
|
st.LastEvent = "message_inbound"
|
|
})
|
|
s.markIncomingReadReceipt(v.Info.Chat.ToNonAD(), v.Info.Sender.ToNonAD(), v.Info.ID, v.Info.Timestamp)
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) handleWS(w http.ResponseWriter, r *http.Request) {
|
|
if !websocket.IsWebSocketUpgrade(r) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"message": "whatsapp bridge websocket endpoint",
|
|
})
|
|
return
|
|
}
|
|
conn, err := s.wsUpgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.wsClientsMu.Lock()
|
|
s.wsClients[conn] = struct{}{}
|
|
s.wsClientsMu.Unlock()
|
|
defer func() {
|
|
s.wsClientsMu.Lock()
|
|
delete(s.wsClients, conn)
|
|
s.wsClientsMu.Unlock()
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
for {
|
|
var msg whatsappBridgeWSMessage
|
|
if err := conn.ReadJSON(&msg); err != nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(msg.Type) != "message" {
|
|
continue
|
|
}
|
|
if err := s.sendOutboundMessage(r.Context(), msg.To, msg.Content, msg.Media, msg.ReplyToID, msg.ReplyToSender); err != nil {
|
|
_ = conn.WriteJSON(map[string]string{
|
|
"type": "error",
|
|
"error": err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) ServeWS(w http.ResponseWriter, r *http.Request) {
|
|
s.wrapHandler(s.handleWS)(w, r)
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) wrapHandler(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if s.localOnly && !isLocalRequest(r) {
|
|
http.Error(w, "forbidden", http.StatusForbidden)
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(s.StatusSnapshot())
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) ServeStatus(w http.ResponseWriter, r *http.Request) {
|
|
s.wrapHandler(s.handleStatus)(w, r)
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if s.client == nil {
|
|
http.Error(w, "whatsapp bridge client is not initialized", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
|
defer cancel()
|
|
if err := s.client.Logout(ctx); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.State = "logged_out"
|
|
st.Connected = false
|
|
st.LoggedIn = false
|
|
st.UserJID = ""
|
|
st.QRCode = ""
|
|
st.QRAvailable = false
|
|
st.LastEvent = "logout"
|
|
})
|
|
go func() {
|
|
_ = s.connectClient(context.Background())
|
|
}()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(s.StatusSnapshot())
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) sendTextMessage(ctx context.Context, rawTo, content, replyToID, replyToSender string) error {
|
|
if s.client == nil {
|
|
return fmt.Errorf("whatsapp client not initialized")
|
|
}
|
|
if strings.TrimSpace(content) == "" {
|
|
return fmt.Errorf("message content is required")
|
|
}
|
|
to, err := normalizeWhatsAppRecipientJID(rawTo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
text := strings.TrimSpace(content)
|
|
msg := &waProto.Message{
|
|
Conversation: &text,
|
|
}
|
|
applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender))
|
|
_, err = s.client.SendMessage(ctx, to, msg)
|
|
if err == nil {
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.OutboundCount++
|
|
st.LastOutboundAt = time.Now().Format(time.RFC3339)
|
|
st.LastOutboundTo = to.String()
|
|
st.LastOutboundText = truncateString(text, 120)
|
|
st.LastEvent = "message_outbound"
|
|
})
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) sendOutboundMessage(ctx context.Context, rawTo, content string, mediaPaths []string, replyToID, replyToSender string) error {
|
|
if len(mediaPaths) == 0 {
|
|
return s.sendTextMessage(ctx, rawTo, content, replyToID, replyToSender)
|
|
}
|
|
to, err := normalizeWhatsAppRecipientJID(rawTo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
caption := strings.TrimSpace(content)
|
|
for idx, mediaPath := range mediaPaths {
|
|
msg, err := s.buildMediaMessage(ctx, to, strings.TrimSpace(mediaPath), caption, replyToID, replyToSender)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := s.client.SendMessage(ctx, to, msg); err != nil {
|
|
return err
|
|
}
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.OutboundCount++
|
|
st.LastOutboundAt = time.Now().Format(time.RFC3339)
|
|
st.LastOutboundTo = to.String()
|
|
st.LastOutboundText = truncateString(strings.TrimSpace(content), 120)
|
|
st.LastEvent = "message_outbound"
|
|
})
|
|
if idx == 0 {
|
|
caption = ""
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) buildMediaMessage(ctx context.Context, to types.JID, mediaPath, caption, replyToID, replyToSender string) (*waProto.Message, error) {
|
|
if s.client == nil {
|
|
return nil, fmt.Errorf("whatsapp client not initialized")
|
|
}
|
|
mediaPath = strings.TrimSpace(mediaPath)
|
|
if mediaPath == "" {
|
|
return nil, fmt.Errorf("media path is required")
|
|
}
|
|
data, err := os.ReadFile(mediaPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read media file: %w", err)
|
|
}
|
|
kind, mimeType := detectWhatsAppMediaType(mediaPath, data)
|
|
uploadType := whatsmeow.MediaDocument
|
|
switch kind {
|
|
case "image":
|
|
uploadType = whatsmeow.MediaImage
|
|
case "video":
|
|
uploadType = whatsmeow.MediaVideo
|
|
case "audio":
|
|
uploadType = whatsmeow.MediaAudio
|
|
}
|
|
resp, err := s.client.Upload(ctx, data, uploadType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("upload media: %w", err)
|
|
}
|
|
fileLength := resp.FileLength
|
|
fileName := filepath.Base(mediaPath)
|
|
switch kind {
|
|
case "image":
|
|
msg := &waProto.Message{
|
|
ImageMessage: &waProto.ImageMessage{
|
|
Caption: proto.String(strings.TrimSpace(caption)),
|
|
Mimetype: proto.String(mimeType),
|
|
URL: proto.String(resp.URL),
|
|
DirectPath: proto.String(resp.DirectPath),
|
|
MediaKey: resp.MediaKey,
|
|
FileEncSHA256: resp.FileEncSHA256,
|
|
FileSHA256: resp.FileSHA256,
|
|
FileLength: proto.Uint64(fileLength),
|
|
},
|
|
}
|
|
applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender))
|
|
return msg, nil
|
|
case "video":
|
|
msg := &waProto.Message{
|
|
VideoMessage: &waProto.VideoMessage{
|
|
Caption: proto.String(strings.TrimSpace(caption)),
|
|
Mimetype: proto.String(mimeType),
|
|
URL: proto.String(resp.URL),
|
|
DirectPath: proto.String(resp.DirectPath),
|
|
MediaKey: resp.MediaKey,
|
|
FileEncSHA256: resp.FileEncSHA256,
|
|
FileSHA256: resp.FileSHA256,
|
|
FileLength: proto.Uint64(fileLength),
|
|
},
|
|
}
|
|
applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender))
|
|
return msg, nil
|
|
case "audio":
|
|
msg := &waProto.Message{
|
|
AudioMessage: &waProto.AudioMessage{
|
|
Mimetype: proto.String(mimeType),
|
|
URL: proto.String(resp.URL),
|
|
DirectPath: proto.String(resp.DirectPath),
|
|
MediaKey: resp.MediaKey,
|
|
FileEncSHA256: resp.FileEncSHA256,
|
|
FileSHA256: resp.FileSHA256,
|
|
FileLength: proto.Uint64(fileLength),
|
|
},
|
|
}
|
|
applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender))
|
|
return msg, nil
|
|
default:
|
|
msg := &waProto.Message{
|
|
DocumentMessage: &waProto.DocumentMessage{
|
|
Caption: proto.String(strings.TrimSpace(caption)),
|
|
Mimetype: proto.String(mimeType),
|
|
Title: proto.String(fileName),
|
|
FileName: proto.String(fileName),
|
|
URL: proto.String(resp.URL),
|
|
DirectPath: proto.String(resp.DirectPath),
|
|
MediaKey: resp.MediaKey,
|
|
FileEncSHA256: resp.FileEncSHA256,
|
|
FileSHA256: resp.FileSHA256,
|
|
FileLength: proto.Uint64(fileLength),
|
|
},
|
|
}
|
|
applyWhatsAppReplyContext(msg, to, strings.TrimSpace(replyToID), strings.TrimSpace(replyToSender))
|
|
return msg, nil
|
|
}
|
|
}
|
|
|
|
func detectWhatsAppMediaType(path string, data []byte) (kind string, mimeType string) {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
mimeType = mime.TypeByExtension(ext)
|
|
if mimeType == "" && len(data) > 0 {
|
|
mimeType = http.DetectContentType(data)
|
|
}
|
|
if mimeType == "" {
|
|
mimeType = "application/octet-stream"
|
|
}
|
|
switch {
|
|
case strings.HasPrefix(mimeType, "image/"):
|
|
return "image", mimeType
|
|
case strings.HasPrefix(mimeType, "video/"):
|
|
return "video", mimeType
|
|
case strings.HasPrefix(mimeType, "audio/"):
|
|
return "audio", mimeType
|
|
default:
|
|
return "document", mimeType
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) matchCurrentUserContext(msg *waProto.Message) (mentionedSelf bool, replyToMe bool) {
|
|
if s.client == nil || s.client.Store.ID == nil || msg == nil {
|
|
return false, false
|
|
}
|
|
ctx := extractWhatsAppContextInfo(msg)
|
|
if ctx == nil {
|
|
return false, false
|
|
}
|
|
own := s.client.Store.ID.ToNonAD().String()
|
|
for _, mentioned := range ctx.GetMentionedJID() {
|
|
if normalizeComparableJID(mentioned) == own {
|
|
mentionedSelf = true
|
|
break
|
|
}
|
|
}
|
|
replyParticipant := normalizeComparableJID(ctx.GetParticipant())
|
|
if replyParticipant != "" && replyParticipant == own {
|
|
replyToMe = true
|
|
}
|
|
return mentionedSelf, replyToMe
|
|
}
|
|
|
|
func extractWhatsAppContextInfo(msg *waProto.Message) *waProto.ContextInfo {
|
|
switch {
|
|
case msg == nil:
|
|
return nil
|
|
case msg.GetExtendedTextMessage() != nil:
|
|
return msg.GetExtendedTextMessage().GetContextInfo()
|
|
case msg.GetImageMessage() != nil:
|
|
return msg.GetImageMessage().GetContextInfo()
|
|
case msg.GetVideoMessage() != nil:
|
|
return msg.GetVideoMessage().GetContextInfo()
|
|
case msg.GetAudioMessage() != nil:
|
|
return msg.GetAudioMessage().GetContextInfo()
|
|
case msg.GetDocumentMessage() != nil:
|
|
return msg.GetDocumentMessage().GetContextInfo()
|
|
case msg.GetDocumentWithCaptionMessage() != nil && msg.GetDocumentWithCaptionMessage().GetMessage() != nil:
|
|
return extractWhatsAppContextInfo(msg.GetDocumentWithCaptionMessage().GetMessage())
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func normalizeComparableJID(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
jid, err := types.ParseJID(raw)
|
|
if err == nil {
|
|
return jid.ToNonAD().String()
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func applyWhatsAppReplyContext(msg *waProto.Message, chatJID types.JID, replyToID, replyToSender string) {
|
|
if msg == nil || strings.TrimSpace(replyToID) == "" {
|
|
return
|
|
}
|
|
ctx := &waProto.ContextInfo{
|
|
StanzaID: proto.String(strings.TrimSpace(replyToID)),
|
|
}
|
|
if chatJID.Server == types.GroupServer {
|
|
ctx.RemoteJID = proto.String(chatJID.ToNonAD().String())
|
|
if sender := normalizeComparableJID(replyToSender); sender != "" {
|
|
ctx.Participant = proto.String(sender)
|
|
}
|
|
}
|
|
switch {
|
|
case msg.GetExtendedTextMessage() != nil:
|
|
msg.GetExtendedTextMessage().ContextInfo = ctx
|
|
case msg.GetImageMessage() != nil:
|
|
msg.GetImageMessage().ContextInfo = ctx
|
|
case msg.GetVideoMessage() != nil:
|
|
msg.GetVideoMessage().ContextInfo = ctx
|
|
case msg.GetAudioMessage() != nil:
|
|
msg.GetAudioMessage().ContextInfo = ctx
|
|
case msg.GetDocumentMessage() != nil:
|
|
msg.GetDocumentMessage().ContextInfo = ctx
|
|
default:
|
|
msg.ExtendedTextMessage = &waProto.ExtendedTextMessage{
|
|
Text: proto.String(msg.GetConversation()),
|
|
ContextInfo: ctx,
|
|
}
|
|
msg.Conversation = nil
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) markIncomingReadReceipt(chat, sender types.JID, id types.MessageID, timestamp time.Time) {
|
|
if s == nil || s.markReadFn == nil || id == "" || chat.IsEmpty() {
|
|
return
|
|
}
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
effectiveSender := types.EmptyJID
|
|
if chat.Server == types.GroupServer {
|
|
effectiveSender = sender
|
|
}
|
|
if err := s.markReadFn(ctx, []types.MessageID{id}, timestamp, chat, effectiveSender); err != nil {
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.LastError = "mark_read_failed: " + err.Error()
|
|
st.LastEvent = "mark_read_failed"
|
|
})
|
|
return
|
|
}
|
|
s.updateStatus(func(st *WhatsAppBridgeStatus) {
|
|
st.ReadReceiptCount++
|
|
st.LastReadAt = time.Now().Format(time.RFC3339)
|
|
st.LastEvent = "mark_read"
|
|
})
|
|
}()
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) updateStatus(mut func(*WhatsAppBridgeStatus)) {
|
|
s.statusMu.Lock()
|
|
defer s.statusMu.Unlock()
|
|
mut(&s.status)
|
|
s.status.UpdatedAt = time.Now().Format(time.RFC3339)
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) broadcastWS(payload whatsappBridgeWSMessage) {
|
|
s.wsClientsMu.Lock()
|
|
defer s.wsClientsMu.Unlock()
|
|
for conn := range s.wsClients {
|
|
_ = conn.WriteJSON(payload)
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) broadcastWSMap(payload map[string]interface{}) {
|
|
s.wsClientsMu.Lock()
|
|
defer s.wsClientsMu.Unlock()
|
|
for conn := range s.wsClients {
|
|
_ = conn.WriteJSON(payload)
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) closeWSClients() {
|
|
s.wsClientsMu.Lock()
|
|
defer s.wsClientsMu.Unlock()
|
|
for conn := range s.wsClients {
|
|
_ = conn.Close()
|
|
delete(s.wsClients, conn)
|
|
}
|
|
}
|
|
|
|
func (s *WhatsAppBridgeService) ServeLogout(w http.ResponseWriter, r *http.Request) {
|
|
s.wrapHandler(s.handleLogout)(w, r)
|
|
}
|
|
|
|
func normalizeWhatsAppRecipientJID(raw string) (types.JID, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return types.EmptyJID, fmt.Errorf("recipient is required")
|
|
}
|
|
if strings.Contains(raw, "@") {
|
|
jid, err := types.ParseJID(raw)
|
|
if err != nil {
|
|
return types.EmptyJID, fmt.Errorf("parse recipient jid: %w", err)
|
|
}
|
|
return jid.ToNonAD(), nil
|
|
}
|
|
if strings.Contains(raw, "-") {
|
|
return types.NewJID(raw, types.GroupServer), nil
|
|
}
|
|
return types.NewJID(raw, types.DefaultUserServer), nil
|
|
}
|
|
|
|
func extractWhatsAppMessageText(msg *waProto.Message) string {
|
|
if msg == nil {
|
|
return ""
|
|
}
|
|
switch {
|
|
case strings.TrimSpace(msg.GetConversation()) != "":
|
|
return msg.GetConversation()
|
|
case msg.GetExtendedTextMessage() != nil && strings.TrimSpace(msg.GetExtendedTextMessage().GetText()) != "":
|
|
return msg.GetExtendedTextMessage().GetText()
|
|
case msg.GetImageMessage() != nil && strings.TrimSpace(msg.GetImageMessage().GetCaption()) != "":
|
|
return msg.GetImageMessage().GetCaption()
|
|
case msg.GetVideoMessage() != nil && strings.TrimSpace(msg.GetVideoMessage().GetCaption()) != "":
|
|
return msg.GetVideoMessage().GetCaption()
|
|
case msg.GetDocumentMessage() != nil && strings.TrimSpace(msg.GetDocumentMessage().GetCaption()) != "":
|
|
return msg.GetDocumentMessage().GetCaption()
|
|
case msg.GetAudioMessage() != nil:
|
|
return "[audio]"
|
|
case msg.GetStickerMessage() != nil:
|
|
return "[sticker]"
|
|
case msg.GetImageMessage() != nil:
|
|
return "[image]"
|
|
case msg.GetVideoMessage() != nil:
|
|
return "[video]"
|
|
case msg.GetDocumentMessage() != nil:
|
|
return "[document]"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|