503 lines
16 KiB
Go
503 lines
16 KiB
Go
package whatsapp
|
|
|
|
import (
|
|
// Standard library.
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"runtime"
|
|
"time"
|
|
|
|
// Third-party libraries.
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"go.mau.fi/whatsmeow"
|
|
"go.mau.fi/whatsmeow/appstate"
|
|
"go.mau.fi/whatsmeow/binary/proto"
|
|
"go.mau.fi/whatsmeow/store"
|
|
"go.mau.fi/whatsmeow/types"
|
|
"go.mau.fi/whatsmeow/types/events"
|
|
)
|
|
|
|
const (
|
|
// The default host part for user JIDs on WhatsApp.
|
|
DefaultUserServer = types.DefaultUserServer
|
|
|
|
// The default host part for group JIDs on WhatsApp.
|
|
DefaultGroupServer = types.GroupServer
|
|
|
|
// The number of times keep-alive checks can fail before attempting to re-connect the session.
|
|
keepAliveFailureThreshold = 3
|
|
|
|
// The minimum and maximum wait interval between connection retries after keep-alive check failure.
|
|
keepAliveMinRetryInterval = 5 * time.Second
|
|
keepAliveMaxRetryInterval = 5 * time.Minute
|
|
)
|
|
|
|
// HandleEventFunc represents a handler for incoming events sent to the Python Session, accepting an
|
|
// event type and payload. Note that this is distinct to the [Session.handleEvent] function, which
|
|
// may emit events into the Python Session event handler but which otherwise does not process across
|
|
// Python/Go boundaries.
|
|
type HandleEventFunc func(EventKind, *EventPayload)
|
|
|
|
// A Session represents a connection (active or not) between a linked device and WhatsApp. Active
|
|
// sessions need to be established by logging in, after which incoming events will be forwarded to
|
|
// the adapter event handler, and outgoing events will be forwarded to WhatsApp.
|
|
type Session struct {
|
|
device LinkedDevice // The linked device this session corresponds to.
|
|
eventHandler HandleEventFunc // The event handler for the overarching Session.
|
|
client *whatsmeow.Client // The concrete client connection to WhatsApp for this session.
|
|
gateway *Gateway // The Gateway this Session is attached to.
|
|
}
|
|
|
|
// Login attempts to authenticate the given [Session], either by re-using the [LinkedDevice] attached
|
|
// or by initiating a pairing session for a new linked device. Callers are expected to have set an
|
|
// event handler in order to receive any incoming events from the underlying WhatsApp session.
|
|
func (s *Session) Login() error {
|
|
var err error
|
|
var store *store.Device
|
|
|
|
// Try to fetch existing device from given device JID.
|
|
if s.device.ID != "" {
|
|
store, err = s.gateway.container.GetDevice(s.device.JID())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if store == nil {
|
|
store = s.gateway.container.NewDevice()
|
|
}
|
|
|
|
s.client = whatsmeow.NewClient(store, s.gateway.logger)
|
|
s.client.AddEventHandler(s.handleEvent)
|
|
|
|
// Simply connect our client if already registered.
|
|
if s.client.Store.ID != nil {
|
|
return s.client.Connect()
|
|
}
|
|
|
|
// Attempt out-of-band registration of client via QR code.
|
|
qrChan, _ := s.client.GetQRChannel(context.Background())
|
|
if err = s.client.Connect(); err != nil {
|
|
return err
|
|
}
|
|
|
|
go func() {
|
|
for e := range qrChan {
|
|
if !s.client.IsConnected() {
|
|
return
|
|
}
|
|
switch e.Event {
|
|
case "code":
|
|
s.propagateEvent(EventQRCode, &EventPayload{QRCode: e.Code})
|
|
}
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Logout disconnects and removes the current linked device locally and initiates a logout remotely.
|
|
func (s *Session) Logout() error {
|
|
if s.client == nil || s.client.Store.ID == nil {
|
|
return nil
|
|
}
|
|
|
|
err := s.client.Logout()
|
|
s.client = nil
|
|
|
|
return err
|
|
}
|
|
|
|
// Disconnects detaches the current connection to WhatsApp without removing any linked device state.
|
|
func (s *Session) Disconnect() error {
|
|
if s.client != nil {
|
|
s.client.Disconnect()
|
|
s.client = nil
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SendMessage processes the given Message and sends a WhatsApp message for the kind and contact JID
|
|
// specified within. In general, different message kinds require different fields to be set; see the
|
|
// documentation for the [Message] type for more information.
|
|
func (s *Session) SendMessage(message Message) error {
|
|
if s.client == nil || s.client.Store.ID == nil {
|
|
return fmt.Errorf("Cannot send message for unauthenticated session")
|
|
}
|
|
|
|
jid, err := types.ParseJID(message.JID)
|
|
if err != nil {
|
|
return fmt.Errorf("Could not parse sender JID for message: %s", err)
|
|
}
|
|
|
|
var payload *proto.Message
|
|
var extra whatsmeow.SendRequestExtra
|
|
|
|
switch message.Kind {
|
|
case MessageAttachment:
|
|
// Handle message with attachment, if any.
|
|
if len(message.Attachments) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Attempt to download attachment data if URL is set.
|
|
if url := message.Attachments[0].URL; url != "" {
|
|
if buf, err := getFromURL(s.gateway.httpClient, url); err != nil {
|
|
return fmt.Errorf("Failed downloading attachment: %s", err)
|
|
} else {
|
|
message.Attachments[0].Data = buf
|
|
}
|
|
}
|
|
|
|
// Ignore attachments with no data set or downloaded.
|
|
if len(message.Attachments[0].Data) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Upload attachment into WhatsApp before sending message.
|
|
if payload, err = uploadAttachment(s.client, message.Attachments[0]); err != nil {
|
|
return fmt.Errorf("Failed uploading attachment: %s", err)
|
|
}
|
|
extra.ID = message.ID
|
|
case MessageRevoke:
|
|
// Don't send message, but revoke existing message by ID.
|
|
payload = s.client.BuildRevoke(s.device.JID().ToNonAD(), types.EmptyJID, message.ID)
|
|
case MessageReaction:
|
|
// Send message as emoji reaction to a given message.
|
|
payload = &proto.Message{
|
|
ReactionMessage: &proto.ReactionMessage{
|
|
Key: &proto.MessageKey{
|
|
RemoteJid: &message.JID,
|
|
FromMe: &message.IsCarbon,
|
|
Id: &message.ID,
|
|
Participant: &message.OriginJID,
|
|
},
|
|
Text: &message.Body,
|
|
SenderTimestampMs: ptrTo(time.Now().UnixMilli()),
|
|
},
|
|
}
|
|
default:
|
|
// Compose extended message when made as a reply to a different message, otherwise compose
|
|
// plain-text message for body given for all other message kinds.
|
|
if message.ReplyID != "" {
|
|
// Fall back to our own JID if no origin JID has been specified, in which case we assume
|
|
// we're replying to our own messages.
|
|
if message.OriginJID == "" {
|
|
message.OriginJID = s.device.JID().ToNonAD().String()
|
|
}
|
|
payload = &proto.Message{
|
|
ExtendedTextMessage: &proto.ExtendedTextMessage{
|
|
Text: &message.Body,
|
|
ContextInfo: &proto.ContextInfo{
|
|
StanzaId: &message.ReplyID,
|
|
QuotedMessage: &proto.Message{Conversation: ptrTo(message.ReplyBody)},
|
|
Participant: &message.OriginJID,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
// Add URL preview, if any was given in message.
|
|
if message.Preview.URL != "" {
|
|
if payload == nil {
|
|
payload = &proto.Message{
|
|
ExtendedTextMessage: &proto.ExtendedTextMessage{Text: &message.Body},
|
|
}
|
|
}
|
|
payload.ExtendedTextMessage.MatchedText = &message.Preview.URL
|
|
payload.ExtendedTextMessage.Title = &message.Preview.Title
|
|
if url := message.Preview.ImageURL; url != "" {
|
|
if buf, err := getFromURL(s.gateway.httpClient, url); err == nil {
|
|
payload.ExtendedTextMessage.JpegThumbnail = buf
|
|
}
|
|
} else if len(message.Preview.ImageData) > 0 {
|
|
payload.ExtendedTextMessage.JpegThumbnail = message.Preview.ImageData
|
|
}
|
|
}
|
|
if payload == nil {
|
|
payload = &proto.Message{Conversation: &message.Body}
|
|
}
|
|
extra.ID = message.ID
|
|
}
|
|
|
|
s.gateway.logger.Debugf("Sending message to JID '%s': %+v", jid, payload)
|
|
_, err = s.client.SendMessage(context.Background(), jid, payload, extra)
|
|
return err
|
|
}
|
|
|
|
// SendChatState sends the given chat state notification (e.g. composing message) to WhatsApp for the
|
|
// contact specified within.
|
|
func (s *Session) SendChatState(state ChatState) error {
|
|
if s.client == nil || s.client.Store.ID == nil {
|
|
return fmt.Errorf("Cannot send chat state for unauthenticated session")
|
|
}
|
|
|
|
jid, err := types.ParseJID(state.JID)
|
|
if err != nil {
|
|
return fmt.Errorf("Could not parse sender JID for chat state: %s", err)
|
|
}
|
|
|
|
var presence types.ChatPresence
|
|
switch state.Kind {
|
|
case ChatStateComposing:
|
|
presence = types.ChatPresenceComposing
|
|
case ChatStatePaused:
|
|
presence = types.ChatPresencePaused
|
|
}
|
|
|
|
return s.client.SendChatPresence(jid, presence, "")
|
|
}
|
|
|
|
// SendReceipt sends a read receipt to WhatsApp for the message IDs specified within.
|
|
func (s *Session) SendReceipt(receipt Receipt) error {
|
|
if s.client == nil || s.client.Store.ID == nil {
|
|
return fmt.Errorf("Cannot send receipt for unauthenticated session")
|
|
}
|
|
|
|
var jid, senderJID types.JID
|
|
var err error
|
|
|
|
if receipt.GroupJID != "" {
|
|
if senderJID, err = types.ParseJID(receipt.JID); err != nil {
|
|
return fmt.Errorf("Could not parse sender JID for receipt: %s", err)
|
|
} else if jid, err = types.ParseJID(receipt.GroupJID); err != nil {
|
|
return fmt.Errorf("Could not parse group JID for receipt: %s", err)
|
|
}
|
|
} else {
|
|
if jid, err = types.ParseJID(receipt.JID); err != nil {
|
|
return fmt.Errorf("Could not parse sender JID for receipt: %s", err)
|
|
}
|
|
}
|
|
|
|
ids := append([]types.MessageID{}, receipt.MessageIDs...)
|
|
return s.client.MarkRead(ids, time.Unix(receipt.Timestamp, 0), jid, senderJID)
|
|
}
|
|
|
|
// GetContacts subscribes to the WhatsApp roster currently stored in the Session's internal state.
|
|
// If `refresh` is `true`, FetchRoster will pull application state from the remote service and
|
|
// synchronize any contacts found with the adapter.
|
|
func (s *Session) GetContacts(refresh bool) ([]Contact, error) {
|
|
if s.client == nil || s.client.Store.ID == nil {
|
|
return nil, fmt.Errorf("Cannot get contacts for unauthenticated session")
|
|
}
|
|
|
|
// Synchronize remote application state with local state if requested.
|
|
if refresh {
|
|
err := s.client.FetchAppState(appstate.WAPatchCriticalUnblockLow, false, false)
|
|
if err != nil {
|
|
s.gateway.logger.Warnf("Could not get app state from server: %s", err)
|
|
}
|
|
}
|
|
|
|
// Synchronize local contact state with overarching gateway for all local contacts.
|
|
data, err := s.client.Store.Contacts.GetAllContacts()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed getting local contacts: %s", err)
|
|
}
|
|
|
|
var contacts []Contact
|
|
for jid, info := range data {
|
|
if err = s.client.SubscribePresence(jid); err != nil {
|
|
s.gateway.logger.Warnf("Failed to subscribe to presence for %s", jid)
|
|
}
|
|
|
|
_, c := newContactEvent(s.client, jid, info)
|
|
contacts = append(contacts, c.Contact)
|
|
}
|
|
|
|
return contacts, nil
|
|
}
|
|
|
|
// GetGroups returns a list of all group-chats currently joined in WhatsApp, along with additional
|
|
// information on present participants.
|
|
func (s *Session) GetGroups() ([]Group, error) {
|
|
if s.client == nil || s.client.Store.ID == nil {
|
|
return nil, fmt.Errorf("Cannot get groups for unauthenticated session")
|
|
}
|
|
|
|
data, err := s.client.GetJoinedGroups()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("Failed getting groups: %s", err)
|
|
}
|
|
|
|
var groups []Group
|
|
for _, info := range data {
|
|
groups = append(groups, newGroup(s.client, info))
|
|
}
|
|
|
|
return groups, nil
|
|
}
|
|
|
|
// GetAvatar fetches a profile picture for the Contact or Group JID given. If a non-empty `avatarID`
|
|
// is also given, GetAvatar will return an empty [Avatar] instance with no error if the remote state
|
|
// for the given ID has not changed.
|
|
func (s *Session) GetAvatar(resourceID, avatarID string) (Avatar, error) {
|
|
jid, err := types.ParseJID(resourceID)
|
|
if err != nil {
|
|
return Avatar{}, fmt.Errorf("Could not parse JID for avatar: %s", err)
|
|
}
|
|
|
|
p, err := s.client.GetProfilePictureInfo(jid, &whatsmeow.GetProfilePictureParams{ExistingID: avatarID})
|
|
if err != nil {
|
|
return Avatar{}, fmt.Errorf("Could not get avatar: %s", err)
|
|
} else if p != nil {
|
|
return Avatar{ID: p.ID, URL: p.URL}, nil
|
|
}
|
|
|
|
return Avatar{}, nil
|
|
}
|
|
|
|
// SetEventHandler assigns the given handler function for propagating internal events into the Python
|
|
// gateway. Note that the event handler function is not entirely safe to use directly, and all calls
|
|
// should instead be made via the [propagateEvent] function.
|
|
func (s *Session) SetEventHandler(h HandleEventFunc) {
|
|
s.eventHandler = h
|
|
}
|
|
|
|
// PropagateEvent handles the given event kind and payload with the adapter event handler defined in
|
|
// SetEventHandler. If no event handler is set, this function will return early with no error.
|
|
func (s *Session) propagateEvent(kind EventKind, payload *EventPayload) {
|
|
if s.eventHandler == nil {
|
|
s.gateway.logger.Errorf("Event handler not set when propagating event %d with payload %v", kind, payload)
|
|
return
|
|
} else if kind == EventUnknown {
|
|
return
|
|
}
|
|
|
|
// Send empty payload instead of a nil pointer, as Python has trouble handling the latter.
|
|
if payload == nil {
|
|
payload = &EventPayload{}
|
|
}
|
|
|
|
// Don't allow other Goroutines from using this thread, as this might lead to concurrent use of
|
|
// the GIL, which can lead to crashes.
|
|
runtime.LockOSThread()
|
|
defer runtime.UnlockOSThread()
|
|
|
|
s.eventHandler(kind, payload)
|
|
}
|
|
|
|
// HandleEvent processes the given incoming WhatsApp event, checking its concrete type and
|
|
// propagating it to the adapter event handler. Unknown or unhandled events are ignored, and any
|
|
// errors that occur during processing are logged.
|
|
func (s *Session) handleEvent(evt interface{}) {
|
|
s.gateway.logger.Debugf("Handling event '%T': %+v", evt, evt)
|
|
|
|
switch evt := evt.(type) {
|
|
case *events.AppStateSyncComplete:
|
|
if len(s.client.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
|
|
s.propagateEvent(EventConnected, &EventPayload{ConnectedJID: s.device.JID().ToNonAD().String()})
|
|
if err := s.client.SendPresence(types.PresenceAvailable); err != nil {
|
|
s.gateway.logger.Warnf("Failed to send available presence: %s", err)
|
|
}
|
|
}
|
|
case *events.Connected, *events.PushNameSetting:
|
|
if len(s.client.Store.PushName) == 0 {
|
|
return
|
|
}
|
|
s.propagateEvent(EventConnected, &EventPayload{ConnectedJID: s.device.JID().ToNonAD().String()})
|
|
if err := s.client.SendPresence(types.PresenceAvailable); err != nil {
|
|
s.gateway.logger.Warnf("Failed to send available presence: %s", err)
|
|
}
|
|
case *events.HistorySync:
|
|
switch evt.Data.GetSyncType() {
|
|
case proto.HistorySync_PUSH_NAME:
|
|
for _, n := range evt.Data.Pushnames {
|
|
jid, err := types.ParseJID(n.GetId())
|
|
if err != nil {
|
|
continue
|
|
}
|
|
s.propagateEvent(newContactEvent(s.client, jid, types.ContactInfo{FullName: n.GetPushname()}))
|
|
if err = s.client.SubscribePresence(jid); err != nil {
|
|
s.gateway.logger.Warnf("Failed to subscribe to presence for %s", jid)
|
|
}
|
|
}
|
|
}
|
|
case *events.Message:
|
|
s.propagateEvent(newMessageEvent(s.client, evt))
|
|
case *events.Receipt:
|
|
s.propagateEvent(newReceiptEvent(evt))
|
|
case *events.Presence:
|
|
s.propagateEvent(newPresenceEvent(evt))
|
|
case *events.PushName:
|
|
s.propagateEvent(newContactEvent(s.client, evt.JID, types.ContactInfo{FullName: evt.NewPushName}))
|
|
case *events.JoinedGroup:
|
|
s.propagateEvent(EventGroup, &EventPayload{Group: newGroup(s.client, &evt.GroupInfo)})
|
|
case *events.GroupInfo:
|
|
s.propagateEvent(newGroupEvent(evt))
|
|
case *events.ChatPresence:
|
|
s.propagateEvent(newChatStateEvent(evt))
|
|
case *events.CallTerminate:
|
|
if evt.Reason == "timeout" {
|
|
s.propagateEvent(newCallEvent(CallMissed, evt.BasicCallMeta))
|
|
}
|
|
case *events.LoggedOut:
|
|
s.client.Disconnect()
|
|
if err := s.client.Store.Delete(); err != nil {
|
|
s.gateway.logger.Warnf("Unable to delete local device state on logout: %s", err)
|
|
}
|
|
s.client = nil
|
|
s.propagateEvent(EventLoggedOut, nil)
|
|
case *events.PairSuccess:
|
|
if s.client.Store.ID == nil {
|
|
s.gateway.logger.Errorf("Pairing succeeded, but device ID is missing")
|
|
return
|
|
}
|
|
deviceID := s.client.Store.ID.String()
|
|
s.propagateEvent(EventPair, &EventPayload{PairDeviceID: deviceID})
|
|
if err := s.gateway.CleanupSession(LinkedDevice{ID: deviceID}); err != nil {
|
|
s.gateway.logger.Warnf("Failed to clean up devices after pair: %s", err)
|
|
}
|
|
case *events.KeepAliveTimeout:
|
|
if evt.ErrorCount > keepAliveFailureThreshold {
|
|
s.gateway.logger.Debugf("Forcing reconnection after keep-alive timeouts...")
|
|
go func() {
|
|
s.client.Disconnect()
|
|
|
|
var interval = keepAliveMinRetryInterval
|
|
for {
|
|
err := s.client.Connect()
|
|
if err == nil || err == whatsmeow.ErrAlreadyConnected {
|
|
break
|
|
}
|
|
|
|
s.gateway.logger.Errorf("Error reconnecting after keep-alive timeouts, retrying in %s: %s", interval, err)
|
|
time.Sleep(interval)
|
|
|
|
if interval > keepAliveMaxRetryInterval {
|
|
interval = keepAliveMaxRetryInterval
|
|
} else if interval < keepAliveMaxRetryInterval {
|
|
interval *= 2
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
}
|
|
|
|
// GetFromURL is a convienience function for fetching the raw response body from the URL given, for
|
|
// the provided HTTP client.
|
|
func getFromURL(client *http.Client, url string) ([]byte, error) {
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
defer resp.Body.Close()
|
|
|
|
buf, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf, nil
|
|
}
|
|
|
|
// PtrTo returns a pointer to the given value, and is used for convenience when converting between
|
|
// concrete and pointer values without assigning to a variable.
|
|
func ptrTo[T any](t T) *T {
|
|
return &t
|
|
}
|