slidge-whatsapp 0.2.2__cp313-cp313-manylinux_2_36_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- slidge_whatsapp/__init__.py +17 -0
- slidge_whatsapp/__main__.py +3 -0
- slidge_whatsapp/command.py +143 -0
- slidge_whatsapp/config.py +32 -0
- slidge_whatsapp/contact.py +77 -0
- slidge_whatsapp/event.go +1175 -0
- slidge_whatsapp/gateway.go +181 -0
- slidge_whatsapp/gateway.py +82 -0
- slidge_whatsapp/generated/__init__.py +0 -0
- slidge_whatsapp/generated/_whatsapp.cpython-313-x86_64-linux-gnu.h +606 -0
- slidge_whatsapp/generated/_whatsapp.cpython-313-x86_64-linux-gnu.so +0 -0
- slidge_whatsapp/generated/build.py +395 -0
- slidge_whatsapp/generated/go.py +1632 -0
- slidge_whatsapp/generated/whatsapp.c +6887 -0
- slidge_whatsapp/generated/whatsapp.go +3572 -0
- slidge_whatsapp/generated/whatsapp.py +2911 -0
- slidge_whatsapp/generated/whatsapp_go.h +606 -0
- slidge_whatsapp/go.mod +29 -0
- slidge_whatsapp/go.sum +62 -0
- slidge_whatsapp/group.py +256 -0
- slidge_whatsapp/media/ffmpeg.go +72 -0
- slidge_whatsapp/media/media.go +542 -0
- slidge_whatsapp/media/mupdf.go +47 -0
- slidge_whatsapp/media/stub.go +19 -0
- slidge_whatsapp/session.go +855 -0
- slidge_whatsapp/session.py +745 -0
- slidge_whatsapp-0.2.2.dist-info/LICENSE +661 -0
- slidge_whatsapp-0.2.2.dist-info/METADATA +744 -0
- slidge_whatsapp-0.2.2.dist-info/RECORD +31 -0
- slidge_whatsapp-0.2.2.dist-info/WHEEL +4 -0
- slidge_whatsapp-0.2.2.dist-info/entry_points.txt +3 -0
slidge_whatsapp/event.go
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
package whatsapp
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
// Standard library.
|
|
5
|
+
"context"
|
|
6
|
+
"fmt"
|
|
7
|
+
"mime"
|
|
8
|
+
"strings"
|
|
9
|
+
|
|
10
|
+
// Internal packages.
|
|
11
|
+
"codeberg.org/slidge/slidge-whatsapp/slidge_whatsapp/media"
|
|
12
|
+
|
|
13
|
+
// Third-party libraries.
|
|
14
|
+
"go.mau.fi/whatsmeow"
|
|
15
|
+
"go.mau.fi/whatsmeow/proto/waE2E"
|
|
16
|
+
"go.mau.fi/whatsmeow/proto/waWeb"
|
|
17
|
+
"go.mau.fi/whatsmeow/types"
|
|
18
|
+
"go.mau.fi/whatsmeow/types/events"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// EventKind represents all event types recognized by the Python session adapter, as emitted by the
|
|
22
|
+
// Go session adapter.
|
|
23
|
+
type EventKind int
|
|
24
|
+
|
|
25
|
+
// The event types handled by the overarching session adapter handler.
|
|
26
|
+
const (
|
|
27
|
+
EventUnknown EventKind = iota
|
|
28
|
+
EventQRCode
|
|
29
|
+
EventPair
|
|
30
|
+
EventConnect
|
|
31
|
+
EventLoggedOut
|
|
32
|
+
EventContact
|
|
33
|
+
EventPresence
|
|
34
|
+
EventMessage
|
|
35
|
+
EventChatState
|
|
36
|
+
EventReceipt
|
|
37
|
+
EventGroup
|
|
38
|
+
EventCall
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// EventPayload represents the collected payloads for all event types handled by the overarching
|
|
42
|
+
// session adapter handler. Only specific fields will be populated in events emitted by internal
|
|
43
|
+
// handlers, see documentation for specific types for more information.
|
|
44
|
+
type EventPayload struct {
|
|
45
|
+
QRCode string
|
|
46
|
+
PairDeviceID string
|
|
47
|
+
Connect Connect
|
|
48
|
+
Contact Contact
|
|
49
|
+
Presence Presence
|
|
50
|
+
Message Message
|
|
51
|
+
ChatState ChatState
|
|
52
|
+
Receipt Receipt
|
|
53
|
+
Group Group
|
|
54
|
+
Call Call
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// HandleEventFunc represents a handler for incoming events sent to the Python adapter, accepting an
|
|
58
|
+
// event type and payload.
|
|
59
|
+
type HandleEventFunc func(EventKind, *EventPayload)
|
|
60
|
+
|
|
61
|
+
// Connect represents event data related to a connection to WhatsApp being established, or failing
|
|
62
|
+
// to do so (based on the [Connect.Error] result).
|
|
63
|
+
type Connect struct {
|
|
64
|
+
JID string // The device JID given for this connection.
|
|
65
|
+
Error string // The connection error, if any.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A Avatar represents a small image set for a Contact or Group.
|
|
69
|
+
type Avatar struct {
|
|
70
|
+
ID string // The unique ID for this avatar, used for persistent caching.
|
|
71
|
+
URL string // The HTTP URL over which this avatar might be retrieved. Can change for the same ID.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A Contact represents any entity that be communicated with directly in WhatsApp. This typically
|
|
75
|
+
// represents people, but may represent a business or bot as well, but not a group-chat.
|
|
76
|
+
type Contact struct {
|
|
77
|
+
JID string // The WhatsApp JID for this contact.
|
|
78
|
+
Name string // The user-set, human-readable name for this contact.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// NewContactEvent returns event data meant for [Session.propagateEvent] for the contact information
|
|
82
|
+
// given. Unknown or invalid contact information will return an [EventUnknown] event with nil data.
|
|
83
|
+
func newContactEvent(jid types.JID, info types.ContactInfo) (EventKind, *EventPayload) {
|
|
84
|
+
var contact = Contact{
|
|
85
|
+
JID: jid.ToNonAD().String(),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for _, n := range []string{info.FullName, info.FirstName, info.BusinessName, info.PushName} {
|
|
89
|
+
if n != "" {
|
|
90
|
+
contact.Name = n
|
|
91
|
+
break
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Don't attempt to synchronize contacts with no user-readable name.
|
|
96
|
+
if contact.Name == "" {
|
|
97
|
+
return EventUnknown, nil
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return EventContact, &EventPayload{Contact: contact}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// PresenceKind represents the different kinds of activity states possible in WhatsApp.
|
|
104
|
+
type PresenceKind int
|
|
105
|
+
|
|
106
|
+
// The presences handled by the overarching session event handler.
|
|
107
|
+
const (
|
|
108
|
+
PresenceUnknown PresenceKind = iota
|
|
109
|
+
PresenceAvailable
|
|
110
|
+
PresenceUnavailable
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
// Precence represents a contact's general state of activity, and is periodically updated as
|
|
114
|
+
// contacts start or stop paying attention to their client of choice.
|
|
115
|
+
type Presence struct {
|
|
116
|
+
JID string
|
|
117
|
+
Kind PresenceKind
|
|
118
|
+
LastSeen int64
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// NewPresenceEvent returns event data meant for [Session.propagateEvent] for the primitive presence
|
|
122
|
+
// event given.
|
|
123
|
+
func newPresenceEvent(evt *events.Presence) (EventKind, *EventPayload) {
|
|
124
|
+
var presence = Presence{
|
|
125
|
+
JID: evt.From.ToNonAD().String(),
|
|
126
|
+
Kind: PresenceAvailable,
|
|
127
|
+
LastSeen: evt.LastSeen.Unix(),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if evt.Unavailable {
|
|
131
|
+
presence.Kind = PresenceUnavailable
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return EventPresence, &EventPayload{Presence: presence}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// MessageKind represents all concrete message types (plain-text messages, edit messages, reactions)
|
|
138
|
+
// recognized by the Python session adapter.
|
|
139
|
+
type MessageKind int
|
|
140
|
+
|
|
141
|
+
// The message types handled by the overarching session event handler.
|
|
142
|
+
const (
|
|
143
|
+
MessagePlain MessageKind = iota
|
|
144
|
+
MessageEdit
|
|
145
|
+
MessageRevoke
|
|
146
|
+
MessageReaction
|
|
147
|
+
MessageAttachment
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// A Message represents one of many kinds of bidirectional communication payloads, for example, a
|
|
151
|
+
// text message, a file (image, video) attachment, an emoji reaction, etc. Messages of different
|
|
152
|
+
// kinds are denoted as such, and re-use fields where the semantics overlap.
|
|
153
|
+
type Message struct {
|
|
154
|
+
Kind MessageKind // The concrete message kind being sent or received.
|
|
155
|
+
ID string // The unique message ID, used for referring to a specific Message instance.
|
|
156
|
+
JID string // The JID this message concerns, semantics can change based on IsCarbon.
|
|
157
|
+
GroupJID string // The JID of the group-chat this message was sent in, if any.
|
|
158
|
+
OriginJID string // For reactions and replies in groups, the JID of the original user.
|
|
159
|
+
Body string // The plain-text message body. For attachment messages, this can be a caption.
|
|
160
|
+
Timestamp int64 // The Unix timestamp denoting when this message was created.
|
|
161
|
+
IsCarbon bool // Whether or not this message concerns the gateway user themselves.
|
|
162
|
+
IsForwarded bool // Whether or not the message was forwarded from another source.
|
|
163
|
+
ReplyID string // The unique message ID this message is in reply to, if any.
|
|
164
|
+
ReplyBody string // The full body of the message this message is in reply to, if any.
|
|
165
|
+
Attachments []Attachment // The list of file (image, video, etc.) attachments contained in this message.
|
|
166
|
+
Preview Preview // A short description for the URL provided in the message body, if any.
|
|
167
|
+
Location Location // The location metadata for messages, if any.
|
|
168
|
+
MentionJIDs []string // A list of JIDs mentioned in this message, if any.
|
|
169
|
+
Receipts []Receipt // The receipt statuses for the message, typically provided alongside historical messages.
|
|
170
|
+
Reactions []Message // Reactions attached to message, typically provided alongside historical messages.
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// A Attachment represents additional binary data (e.g. images, videos, documents) provided alongside
|
|
174
|
+
// a message, for display or storage on the recepient client.
|
|
175
|
+
type Attachment struct {
|
|
176
|
+
MIME string // The MIME type for attachment.
|
|
177
|
+
Filename string // The recommended file name for this attachment. May be an auto-generated name.
|
|
178
|
+
Caption string // The user-provided caption, provided alongside this attachment.
|
|
179
|
+
Data []byte // Data for the attachment.
|
|
180
|
+
|
|
181
|
+
// Internal fields.
|
|
182
|
+
spec *media.Spec // Metadata specific to audio/video files, used in processing.
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// GetSpec returns metadata for this attachment, as derived from the underlying attachment data.
|
|
186
|
+
func (a *Attachment) GetSpec(ctx context.Context) (*media.Spec, error) {
|
|
187
|
+
if a.spec != nil {
|
|
188
|
+
return a.spec, nil
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
spec, err := media.GetSpec(ctx, a.Data)
|
|
192
|
+
if err != nil {
|
|
193
|
+
return nil, err
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
a.spec = spec
|
|
197
|
+
return a.spec, nil
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// PreviewKind represents different ways of previewingadditional data inline with messages.
|
|
201
|
+
type PreviewKind int
|
|
202
|
+
|
|
203
|
+
const (
|
|
204
|
+
PreviewPlain PreviewKind = iota
|
|
205
|
+
PreviewVideo
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
// A Preview represents a short description for a URL provided in a message body, as usually derived
|
|
209
|
+
// from the content of the page pointed at.
|
|
210
|
+
type Preview struct {
|
|
211
|
+
Kind PreviewKind // The kind of preview to show, defaults to plain URL preview.
|
|
212
|
+
URL string // The original (or canonical) URL this preview was generated for.
|
|
213
|
+
Title string // The short title for the URL preview.
|
|
214
|
+
Description string // The (optional) long-form description for the URL preview.
|
|
215
|
+
Thumbnail []byte // The (optional) thumbnail image data.
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A Location represents additional metadata given to location messages.
|
|
219
|
+
type Location struct {
|
|
220
|
+
Latitude float64
|
|
221
|
+
Longitude float64
|
|
222
|
+
Accuracy int
|
|
223
|
+
IsLive bool
|
|
224
|
+
|
|
225
|
+
// Optional fields given for named locations.
|
|
226
|
+
Name string
|
|
227
|
+
Address string
|
|
228
|
+
URL string
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// NewMessageEvent returns event data meant for [Session.propagateEvent] for the primive message
|
|
232
|
+
// event given. Unknown or invalid messages will return an [EventUnknown] event with nil data.
|
|
233
|
+
func newMessageEvent(client *whatsmeow.Client, evt *events.Message) (EventKind, *EventPayload) {
|
|
234
|
+
// Set basic data for message, to be potentially amended depending on the concrete version of
|
|
235
|
+
// the underlying message.
|
|
236
|
+
var message = Message{
|
|
237
|
+
Kind: MessagePlain,
|
|
238
|
+
ID: evt.Info.ID,
|
|
239
|
+
JID: evt.Info.Sender.ToNonAD().String(),
|
|
240
|
+
Body: evt.Message.GetConversation(),
|
|
241
|
+
Timestamp: evt.Info.Timestamp.Unix(),
|
|
242
|
+
IsCarbon: evt.Info.IsFromMe,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if evt.Info.Chat.Server == types.BroadcastServer {
|
|
246
|
+
// Handle non-carbon, non-status broadcast messages as plain messages; support for other
|
|
247
|
+
// types is lacking in the XMPP world.
|
|
248
|
+
if evt.Info.Chat.User == types.StatusBroadcastJID.User || message.IsCarbon {
|
|
249
|
+
return EventUnknown, nil
|
|
250
|
+
}
|
|
251
|
+
} else if evt.Info.IsGroup {
|
|
252
|
+
message.GroupJID = evt.Info.Chat.ToNonAD().String()
|
|
253
|
+
} else if message.IsCarbon {
|
|
254
|
+
message.JID = evt.Info.Chat.ToNonAD().String()
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Handle handle protocol messages (such as message deletion or editing).
|
|
258
|
+
if p := evt.Message.GetProtocolMessage(); p != nil {
|
|
259
|
+
switch p.GetType() {
|
|
260
|
+
case waE2E.ProtocolMessage_MESSAGE_EDIT:
|
|
261
|
+
if m := p.GetEditedMessage(); m != nil {
|
|
262
|
+
message.Kind = MessageEdit
|
|
263
|
+
message.ID = p.Key.GetID()
|
|
264
|
+
message.Body = m.GetConversation()
|
|
265
|
+
} else {
|
|
266
|
+
return EventUnknown, nil
|
|
267
|
+
}
|
|
268
|
+
case waE2E.ProtocolMessage_REVOKE:
|
|
269
|
+
message.Kind = MessageRevoke
|
|
270
|
+
message.ID = p.Key.GetID()
|
|
271
|
+
message.OriginJID = p.Key.GetParticipant()
|
|
272
|
+
return EventMessage, &EventPayload{Message: message}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Handle emoji reaction to existing message.
|
|
277
|
+
if r := evt.Message.GetReactionMessage(); r != nil {
|
|
278
|
+
message.Kind = MessageReaction
|
|
279
|
+
message.ID = r.Key.GetID()
|
|
280
|
+
message.Body = r.GetText()
|
|
281
|
+
return EventMessage, &EventPayload{Message: message}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Handle location (static and live) message.
|
|
285
|
+
if l := evt.Message.GetLocationMessage(); l != nil {
|
|
286
|
+
message.Location = Location{
|
|
287
|
+
Latitude: l.GetDegreesLatitude(),
|
|
288
|
+
Longitude: l.GetDegreesLongitude(),
|
|
289
|
+
Accuracy: int(l.GetAccuracyInMeters()),
|
|
290
|
+
IsLive: l.GetIsLive(),
|
|
291
|
+
Name: l.GetName(),
|
|
292
|
+
Address: l.GetAddress(),
|
|
293
|
+
URL: l.GetURL(),
|
|
294
|
+
}
|
|
295
|
+
return EventMessage, &EventPayload{Message: message}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if l := evt.Message.GetLiveLocationMessage(); l != nil {
|
|
299
|
+
message.Body = l.GetCaption()
|
|
300
|
+
message.Location = Location{
|
|
301
|
+
Latitude: l.GetDegreesLatitude(),
|
|
302
|
+
Longitude: l.GetDegreesLongitude(),
|
|
303
|
+
Accuracy: int(l.GetAccuracyInMeters()),
|
|
304
|
+
IsLive: true,
|
|
305
|
+
}
|
|
306
|
+
return EventMessage, &EventPayload{Message: message}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Handle message attachments, if any.
|
|
310
|
+
if attach, context, err := getMessageAttachments(client, evt.Message); err != nil {
|
|
311
|
+
client.Log.Errorf("Failed getting message attachments: %s", err)
|
|
312
|
+
return EventUnknown, nil
|
|
313
|
+
} else if len(attach) > 0 {
|
|
314
|
+
message.Attachments = append(message.Attachments, attach...)
|
|
315
|
+
message.Kind = MessageAttachment
|
|
316
|
+
if context != nil {
|
|
317
|
+
message = getMessageWithContext(message, context)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Get extended information from message, if available. Extended messages typically represent
|
|
322
|
+
// messages with additional context, such as replies, forwards, etc.
|
|
323
|
+
if e := evt.Message.GetExtendedTextMessage(); e != nil {
|
|
324
|
+
if message.Body == "" {
|
|
325
|
+
message.Body = e.GetText()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
message = getMessageWithContext(message, e.GetContextInfo())
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Ignore obviously invalid messages.
|
|
332
|
+
if message.Kind == MessagePlain && message.Body == "" {
|
|
333
|
+
return EventUnknown, nil
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return EventMessage, &EventPayload{Message: message}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// GetMessageWithContext processes the given [Message] and applies any context metadata might be
|
|
340
|
+
// useful; examples of context include messages being quoted. If no context is found, the original
|
|
341
|
+
// message is returned unchanged.
|
|
342
|
+
func getMessageWithContext(message Message, info *waE2E.ContextInfo) Message {
|
|
343
|
+
if info == nil {
|
|
344
|
+
return message
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
message.ReplyID = info.GetStanzaID()
|
|
348
|
+
message.OriginJID = info.GetParticipant()
|
|
349
|
+
message.IsForwarded = info.GetIsForwarded()
|
|
350
|
+
|
|
351
|
+
if q := info.GetQuotedMessage(); q != nil {
|
|
352
|
+
if qe := q.GetExtendedTextMessage(); qe != nil {
|
|
353
|
+
message.ReplyBody = qe.GetText()
|
|
354
|
+
} else {
|
|
355
|
+
message.ReplyBody = q.GetConversation()
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return message
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// GetMessageAttachments fetches and decrypts attachments (images, audio, video, or documents) sent
|
|
363
|
+
// via WhatsApp. Any failures in retrieving any attachment will return an error immediately.
|
|
364
|
+
func getMessageAttachments(client *whatsmeow.Client, message *waE2E.Message) ([]Attachment, *waE2E.ContextInfo, error) {
|
|
365
|
+
var result []Attachment
|
|
366
|
+
var info *waE2E.ContextInfo
|
|
367
|
+
var convertSpec *media.Spec
|
|
368
|
+
var kinds = []whatsmeow.DownloadableMessage{
|
|
369
|
+
message.GetImageMessage(),
|
|
370
|
+
message.GetAudioMessage(),
|
|
371
|
+
message.GetVideoMessage(),
|
|
372
|
+
message.GetDocumentMessage(),
|
|
373
|
+
message.GetStickerMessage(),
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for _, msg := range kinds {
|
|
377
|
+
// Handle data for specific attachment type.
|
|
378
|
+
var a Attachment
|
|
379
|
+
switch msg := msg.(type) {
|
|
380
|
+
case *waE2E.ImageMessage:
|
|
381
|
+
a.MIME, a.Caption = msg.GetMimetype(), msg.GetCaption()
|
|
382
|
+
case *waE2E.AudioMessage:
|
|
383
|
+
// Convert Opus-encoded voice messages to AAC-encoded audio, which has better support.
|
|
384
|
+
a.MIME = msg.GetMimetype()
|
|
385
|
+
if msg.GetPTT() {
|
|
386
|
+
convertSpec = &media.Spec{MIME: media.TypeM4A}
|
|
387
|
+
}
|
|
388
|
+
case *waE2E.VideoMessage:
|
|
389
|
+
a.MIME, a.Caption = msg.GetMimetype(), msg.GetCaption()
|
|
390
|
+
case *waE2E.DocumentMessage:
|
|
391
|
+
a.MIME, a.Caption, a.Filename = msg.GetMimetype(), msg.GetCaption(), msg.GetFileName()
|
|
392
|
+
case *waE2E.StickerMessage:
|
|
393
|
+
a.MIME = msg.GetMimetype()
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Ignore attachments with empty or unknown MIME types.
|
|
397
|
+
if a.MIME == "" {
|
|
398
|
+
continue
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Attempt to download and decrypt raw attachment data, if any.
|
|
402
|
+
data, err := client.Download(msg)
|
|
403
|
+
if err != nil {
|
|
404
|
+
return nil, nil, err
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
a.Data = data
|
|
408
|
+
|
|
409
|
+
// Convert incoming data if a specification has been given, ignoring any errors that occur.
|
|
410
|
+
if convertSpec != nil {
|
|
411
|
+
data, err = media.Convert(context.Background(), a.Data, convertSpec)
|
|
412
|
+
if err == nil {
|
|
413
|
+
a.Data, a.MIME = data, string(convertSpec.MIME)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Set filename from SHA256 checksum and MIME type, if none is already set.
|
|
418
|
+
if a.Filename == "" {
|
|
419
|
+
a.Filename = fmt.Sprintf("%x%s", msg.GetFileSHA256(), extensionByType(a.MIME))
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
result = append(result, a)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Handle any contact vCard as attachment.
|
|
426
|
+
if c := message.GetContactMessage(); c != nil {
|
|
427
|
+
result = append(result, Attachment{
|
|
428
|
+
MIME: "text/vcard",
|
|
429
|
+
Filename: c.GetDisplayName() + ".vcf",
|
|
430
|
+
Data: []byte(c.GetVcard()),
|
|
431
|
+
})
|
|
432
|
+
info = c.GetContextInfo()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return result, info, nil
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const (
|
|
439
|
+
// The MIME type used by voice messages on WhatsApp.
|
|
440
|
+
voiceMessageMIME = string(media.TypeOgg) + "; codecs=opus"
|
|
441
|
+
// The MIME type used by animated images on WhatsApp.
|
|
442
|
+
animatedImageMIME = "image/gif"
|
|
443
|
+
|
|
444
|
+
// The maximum image attachment size we'll attempt to process in any way, in bytes.
|
|
445
|
+
maxConvertImageSize = 1024 * 1024 * 10 // 10MiB
|
|
446
|
+
// The maximum audio/video attachment size we'll attempt to process in any way, in bytes.
|
|
447
|
+
maxConvertAudioVideoSize = 1024 * 1024 * 20 // 20MiB
|
|
448
|
+
|
|
449
|
+
// The maximum number of samples to return in media waveforms.
|
|
450
|
+
maxWaveformSamples = 64
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
var (
|
|
454
|
+
// Default target specification for voice messages.
|
|
455
|
+
voiceMessageSpec = media.Spec{
|
|
456
|
+
MIME: media.MIMEType(voiceMessageMIME),
|
|
457
|
+
AudioBitRate: 64,
|
|
458
|
+
AudioChannels: 1,
|
|
459
|
+
AudioSampleRate: 48000,
|
|
460
|
+
StripMetadata: true,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Default target specification for generic audio messages.
|
|
464
|
+
audioMessageSpec = media.Spec{
|
|
465
|
+
MIME: media.TypeM4A,
|
|
466
|
+
AudioBitRate: 160,
|
|
467
|
+
AudioSampleRate: 44100,
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Default target specification for video messages with inline preview.
|
|
471
|
+
videoMessageSpec = media.Spec{
|
|
472
|
+
MIME: media.TypeMP4,
|
|
473
|
+
AudioBitRate: 160,
|
|
474
|
+
AudioSampleRate: 44100,
|
|
475
|
+
VideoFilter: "pad=ceil(iw/2)*2:ceil(ih/2)*2",
|
|
476
|
+
VideoFrameRate: 25,
|
|
477
|
+
VideoPixelFormat: "yuv420p",
|
|
478
|
+
StripMetadata: true,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Default target specification for image messages with inline preview.
|
|
482
|
+
imageMessageSpec = media.Spec{
|
|
483
|
+
MIME: media.TypeJPEG,
|
|
484
|
+
ImageQuality: 85,
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Default target specifications for default and preview-size thumbnails.
|
|
488
|
+
defaultThumbnailSpec = media.Spec{
|
|
489
|
+
MIME: media.TypeJPEG,
|
|
490
|
+
ImageWidth: 100,
|
|
491
|
+
StripMetadata: true,
|
|
492
|
+
}
|
|
493
|
+
previewThumbnailSpec = media.Spec{
|
|
494
|
+
MIME: media.TypeJPEG,
|
|
495
|
+
ImageWidth: 250,
|
|
496
|
+
StripMetadata: true,
|
|
497
|
+
}
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
// ConvertAttachment attempts to process a given attachment from a less-supported type to a
|
|
501
|
+
// canonically supported one; for example, from `image/png` to `image/jpeg`.
|
|
502
|
+
//
|
|
503
|
+
// Decisions about which MIME types to convert to are based on the concrete MIME type inferred from
|
|
504
|
+
// the file itself, and care is taken to conform to WhatsApp semantics for the given input MIME
|
|
505
|
+
// type.
|
|
506
|
+
//
|
|
507
|
+
// If the input MIME type is unknown, or conversion is impossible, the given attachment is not
|
|
508
|
+
// changed.
|
|
509
|
+
func convertAttachment(attach *Attachment) error {
|
|
510
|
+
var detectedMIME media.MIMEType
|
|
511
|
+
if t := media.DetectMIMEType(attach.Data); t != media.TypeUnknown {
|
|
512
|
+
detectedMIME = t
|
|
513
|
+
if attach.MIME == "" || attach.MIME == "application/octet-stream" {
|
|
514
|
+
attach.MIME = string(detectedMIME)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
var spec media.Spec
|
|
519
|
+
var ctx = context.Background()
|
|
520
|
+
|
|
521
|
+
switch detectedMIME {
|
|
522
|
+
case media.TypePNG, media.TypeWebP:
|
|
523
|
+
// Convert common image formats to JPEG for inline preview.
|
|
524
|
+
if len(attach.Data) > maxConvertImageSize {
|
|
525
|
+
return fmt.Errorf("attachment size %d exceeds maximum of %d", len(attach.Data), maxConvertImageSize)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
spec = imageMessageSpec
|
|
529
|
+
case media.TypeGIF:
|
|
530
|
+
// Convert GIFs to JPEG or MP4, if animated, as required by WhatsApp.
|
|
531
|
+
if len(attach.Data) > maxConvertImageSize {
|
|
532
|
+
return fmt.Errorf("attachment size %d exceeds maximum of %d", len(attach.Data), maxConvertImageSize)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
spec = imageMessageSpec
|
|
536
|
+
if s, err := attach.GetSpec(ctx); err == nil && s.ImageFrameRate > 0 {
|
|
537
|
+
spec = videoMessageSpec
|
|
538
|
+
spec.ImageFrameRate = s.ImageFrameRate
|
|
539
|
+
}
|
|
540
|
+
case media.TypeM4A:
|
|
541
|
+
if len(attach.Data) > maxConvertAudioVideoSize {
|
|
542
|
+
return fmt.Errorf("attachment size %d exceeds maximum of %d", len(attach.Data), maxConvertAudioVideoSize)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
spec = voiceMessageSpec
|
|
546
|
+
|
|
547
|
+
if s, err := attach.GetSpec(ctx); err == nil {
|
|
548
|
+
if s.AudioCodec == "alac" {
|
|
549
|
+
// Don't attempt to process lossless files at all, as it's assumed that the sender
|
|
550
|
+
// wants to retain these characteristics. Since WhatsApp will try (and likely fail)
|
|
551
|
+
// to process this as an audio message anyways, set a unique MIME type.
|
|
552
|
+
attach.MIME = "application/octet-stream"
|
|
553
|
+
return nil
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
case media.TypeOgg:
|
|
557
|
+
if len(attach.Data) > maxConvertAudioVideoSize {
|
|
558
|
+
return fmt.Errorf("attachment size %d exceeds maximum of %d", len(attach.Data), maxConvertAudioVideoSize)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
spec = audioMessageSpec
|
|
562
|
+
if s, err := attach.GetSpec(ctx); err == nil {
|
|
563
|
+
if s.AudioCodec == "opus" {
|
|
564
|
+
// Assume that Opus-encoded Ogg files are meant to be voice messages, and re-encode
|
|
565
|
+
// them as such for WhatsApp.
|
|
566
|
+
spec = voiceMessageSpec
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
case media.TypeMP4, media.TypeWebM:
|
|
570
|
+
if len(attach.Data) > maxConvertAudioVideoSize {
|
|
571
|
+
return fmt.Errorf("attachment size %d exceeds maximum of %d", len(attach.Data), maxConvertAudioVideoSize)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
spec = videoMessageSpec
|
|
575
|
+
|
|
576
|
+
if s, err := attach.GetSpec(ctx); err == nil {
|
|
577
|
+
// Try to see if there's a video stream for ostensibly video-related MIME types, as
|
|
578
|
+
// these are some times misdetected as such.
|
|
579
|
+
if s.VideoWidth == 0 && s.VideoHeight == 0 && s.AudioSampleRate > 0 && s.Duration > 0 {
|
|
580
|
+
spec = voiceMessageSpec
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
default:
|
|
584
|
+
// Detected source MIME not in list we're willing to convert, move on without error.
|
|
585
|
+
return nil
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Convert attachment between file-types, if source MIME matches the known list of convertable types.
|
|
589
|
+
data, err := media.Convert(ctx, attach.Data, &spec)
|
|
590
|
+
if err != nil {
|
|
591
|
+
return fmt.Errorf("failed converting attachment: %w", err)
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
attach.Data, attach.MIME = data, string(spec.MIME)
|
|
595
|
+
return nil
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// KnownMediaTypes represents MIME type to WhatsApp media types known to be handled by WhatsApp in a
|
|
599
|
+
// special way (that is, not as generic file uploads).
|
|
600
|
+
var knownMediaTypes = map[string]whatsmeow.MediaType{
|
|
601
|
+
"image/jpeg": whatsmeow.MediaImage,
|
|
602
|
+
"audio/mpeg": whatsmeow.MediaAudio,
|
|
603
|
+
"audio/mp4": whatsmeow.MediaAudio,
|
|
604
|
+
"audio/aac": whatsmeow.MediaAudio,
|
|
605
|
+
"audio/ogg": whatsmeow.MediaAudio,
|
|
606
|
+
"video/mp4": whatsmeow.MediaVideo,
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// UploadAttachment attempts to push the given attachment data to WhatsApp according to the MIME
|
|
610
|
+
// type specified within. Attachments are handled as generic file uploads unless they're of a
|
|
611
|
+
// specific format; in addition, certain MIME types may be automatically converted to a
|
|
612
|
+
// well-supported type via FFmpeg (if available).
|
|
613
|
+
func uploadAttachment(client *whatsmeow.Client, attach *Attachment) (*waE2E.Message, error) {
|
|
614
|
+
var ctx = context.Background()
|
|
615
|
+
var originalMIME = attach.MIME
|
|
616
|
+
|
|
617
|
+
if err := convertAttachment(attach); err != nil {
|
|
618
|
+
client.Log.Warnf("failed to auto-convert attachment: %s", err)
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
mediaType := knownMediaTypes[getBaseMediaType(attach.MIME)]
|
|
622
|
+
if mediaType == "" {
|
|
623
|
+
mediaType = whatsmeow.MediaDocument
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if len(attach.Data) == 0 {
|
|
627
|
+
return nil, fmt.Errorf("attachment file contains no data")
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
upload, err := client.Upload(ctx, attach.Data, mediaType)
|
|
631
|
+
if err != nil {
|
|
632
|
+
return nil, err
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
var message *waE2E.Message
|
|
636
|
+
switch mediaType {
|
|
637
|
+
case whatsmeow.MediaImage:
|
|
638
|
+
message = &waE2E.Message{
|
|
639
|
+
ImageMessage: &waE2E.ImageMessage{
|
|
640
|
+
URL: &upload.URL,
|
|
641
|
+
DirectPath: &upload.DirectPath,
|
|
642
|
+
MediaKey: upload.MediaKey,
|
|
643
|
+
Mimetype: &attach.MIME,
|
|
644
|
+
FileEncSHA256: upload.FileEncSHA256,
|
|
645
|
+
FileSHA256: upload.FileSHA256,
|
|
646
|
+
FileLength: ptrTo(uint64(len(attach.Data))),
|
|
647
|
+
},
|
|
648
|
+
}
|
|
649
|
+
t, err := media.Convert(ctx, attach.Data, &defaultThumbnailSpec)
|
|
650
|
+
if err != nil {
|
|
651
|
+
client.Log.Warnf("failed generating attachment thumbnail: %s", err)
|
|
652
|
+
} else {
|
|
653
|
+
message.ImageMessage.JPEGThumbnail = t
|
|
654
|
+
}
|
|
655
|
+
case whatsmeow.MediaAudio:
|
|
656
|
+
spec, err := attach.GetSpec(ctx)
|
|
657
|
+
if err != nil {
|
|
658
|
+
client.Log.Warnf("failed fetching attachment metadata: %s", err)
|
|
659
|
+
spec = &media.Spec{}
|
|
660
|
+
}
|
|
661
|
+
message = &waE2E.Message{
|
|
662
|
+
AudioMessage: &waE2E.AudioMessage{
|
|
663
|
+
URL: &upload.URL,
|
|
664
|
+
DirectPath: &upload.DirectPath,
|
|
665
|
+
MediaKey: upload.MediaKey,
|
|
666
|
+
Mimetype: &attach.MIME,
|
|
667
|
+
FileEncSHA256: upload.FileEncSHA256,
|
|
668
|
+
FileSHA256: upload.FileSHA256,
|
|
669
|
+
FileLength: ptrTo(uint64(len(attach.Data))),
|
|
670
|
+
Seconds: ptrTo(uint32(spec.Duration.Seconds())),
|
|
671
|
+
},
|
|
672
|
+
}
|
|
673
|
+
if attach.MIME == voiceMessageMIME {
|
|
674
|
+
message.AudioMessage.PTT = ptrTo(true)
|
|
675
|
+
if spec != nil {
|
|
676
|
+
w, err := media.GetWaveform(ctx, attach.Data, spec, maxWaveformSamples)
|
|
677
|
+
if err != nil {
|
|
678
|
+
client.Log.Warnf("failed generating attachment waveform: %s", err)
|
|
679
|
+
} else {
|
|
680
|
+
message.AudioMessage.Waveform = w
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
case whatsmeow.MediaVideo:
|
|
685
|
+
spec, err := attach.GetSpec(ctx)
|
|
686
|
+
if err != nil {
|
|
687
|
+
client.Log.Warnf("failed fetching attachment metadata: %s", err)
|
|
688
|
+
spec = &media.Spec{}
|
|
689
|
+
}
|
|
690
|
+
message = &waE2E.Message{
|
|
691
|
+
VideoMessage: &waE2E.VideoMessage{
|
|
692
|
+
URL: &upload.URL,
|
|
693
|
+
DirectPath: &upload.DirectPath,
|
|
694
|
+
MediaKey: upload.MediaKey,
|
|
695
|
+
Mimetype: &attach.MIME,
|
|
696
|
+
FileEncSHA256: upload.FileEncSHA256,
|
|
697
|
+
FileSHA256: upload.FileSHA256,
|
|
698
|
+
FileLength: ptrTo(uint64(len(attach.Data))),
|
|
699
|
+
Seconds: ptrTo(uint32(spec.Duration.Seconds())),
|
|
700
|
+
Width: ptrTo(uint32(spec.VideoWidth)),
|
|
701
|
+
Height: ptrTo(uint32(spec.VideoHeight)),
|
|
702
|
+
},
|
|
703
|
+
}
|
|
704
|
+
t, err := media.Convert(ctx, attach.Data, &defaultThumbnailSpec)
|
|
705
|
+
if err != nil {
|
|
706
|
+
client.Log.Warnf("failed generating attachment thumbnail: %s", err)
|
|
707
|
+
} else {
|
|
708
|
+
message.VideoMessage.JPEGThumbnail = t
|
|
709
|
+
}
|
|
710
|
+
if originalMIME == animatedImageMIME {
|
|
711
|
+
message.VideoMessage.GifPlayback = ptrTo(true)
|
|
712
|
+
}
|
|
713
|
+
case whatsmeow.MediaDocument:
|
|
714
|
+
message = &waE2E.Message{
|
|
715
|
+
DocumentMessage: &waE2E.DocumentMessage{
|
|
716
|
+
URL: &upload.URL,
|
|
717
|
+
DirectPath: &upload.DirectPath,
|
|
718
|
+
MediaKey: upload.MediaKey,
|
|
719
|
+
Mimetype: &attach.MIME,
|
|
720
|
+
FileEncSHA256: upload.FileEncSHA256,
|
|
721
|
+
FileSHA256: upload.FileSHA256,
|
|
722
|
+
FileLength: ptrTo(uint64(len(attach.Data))),
|
|
723
|
+
FileName: &attach.Filename,
|
|
724
|
+
},
|
|
725
|
+
}
|
|
726
|
+
switch media.MIMEType(attach.MIME) {
|
|
727
|
+
case media.TypePDF:
|
|
728
|
+
if spec, err := attach.GetSpec(ctx); err != nil {
|
|
729
|
+
client.Log.Warnf("failed fetching attachment metadata: %s", err)
|
|
730
|
+
} else {
|
|
731
|
+
message.DocumentMessage.PageCount = ptrTo(uint32(spec.DocumentPage))
|
|
732
|
+
}
|
|
733
|
+
t, err := media.Convert(ctx, attach.Data, &previewThumbnailSpec)
|
|
734
|
+
if err != nil {
|
|
735
|
+
client.Log.Warnf("failed generating attachment thumbnail: %s", err)
|
|
736
|
+
} else {
|
|
737
|
+
message.DocumentMessage.JPEGThumbnail = t
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
return message, nil
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// KnownExtensions represents MIME type to file-extension mappings for basic, known media types.
|
|
746
|
+
var knownExtensions = map[string]string{
|
|
747
|
+
"image/jpeg": ".jpg",
|
|
748
|
+
"audio/ogg": ".oga",
|
|
749
|
+
"audio/mp4": ".m4a",
|
|
750
|
+
"video/mp4": ".mp4",
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ExtensionByType returns the file extension for the given MIME type, or a generic extension if the
|
|
754
|
+
// MIME type is unknown.
|
|
755
|
+
func extensionByType(typ string) string {
|
|
756
|
+
// Handle common, known MIME types first.
|
|
757
|
+
if ext := knownExtensions[typ]; ext != "" {
|
|
758
|
+
return ext
|
|
759
|
+
}
|
|
760
|
+
if ext, _ := mime.ExtensionsByType(typ); len(ext) > 0 {
|
|
761
|
+
return ext[0]
|
|
762
|
+
}
|
|
763
|
+
return ".bin"
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// GetBaseMediaType returns the media type without any additional parameters.
|
|
767
|
+
func getBaseMediaType(typ string) string {
|
|
768
|
+
return strings.SplitN(typ, ";", 2)[0]
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// NewEventFromHistory returns event data meant for [Session.propagateEvent] for the primive history
|
|
772
|
+
// message given. Currently, only events related to group-chats will be handled, due to uncertain
|
|
773
|
+
// support for history back-fills on 1:1 chats.
|
|
774
|
+
//
|
|
775
|
+
// Otherwise, the implementation largely follows that of [newMessageEvent], however the base types
|
|
776
|
+
// used by these two functions differ in many small ways which prevent unifying the approach.
|
|
777
|
+
//
|
|
778
|
+
// Typically, this will return [EventMessage] events with appropriate [Message] payloads; unknown or
|
|
779
|
+
// invalid messages will return an [EventUnknown] event with nil data.
|
|
780
|
+
func newEventFromHistory(client *whatsmeow.Client, info *waWeb.WebMessageInfo) (EventKind, *EventPayload) {
|
|
781
|
+
// Handle message as group message is remote JID is a group JID in the absence of any other,
|
|
782
|
+
// specific signal, or don't handle at all if no group JID is found.
|
|
783
|
+
var jid = info.GetKey().GetRemoteJID()
|
|
784
|
+
if j, _ := types.ParseJID(jid); j.Server != types.GroupServer {
|
|
785
|
+
return EventUnknown, nil
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Set basic data for message, to be potentially amended depending on the concrete version of
|
|
789
|
+
// the underlying message.
|
|
790
|
+
var message = Message{
|
|
791
|
+
Kind: MessagePlain,
|
|
792
|
+
ID: info.GetKey().GetID(),
|
|
793
|
+
GroupJID: info.GetKey().GetRemoteJID(),
|
|
794
|
+
Body: info.GetMessage().GetConversation(),
|
|
795
|
+
Timestamp: int64(info.GetMessageTimestamp()),
|
|
796
|
+
IsCarbon: info.GetKey().GetFromMe(),
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if info.Participant != nil {
|
|
800
|
+
message.JID = info.GetParticipant()
|
|
801
|
+
} else if info.GetKey().GetFromMe() {
|
|
802
|
+
message.JID = client.Store.ID.ToNonAD().String()
|
|
803
|
+
} else {
|
|
804
|
+
// It's likely we cannot handle this message correctly if we don't know the concrete
|
|
805
|
+
// sender, so just ignore it completely.
|
|
806
|
+
return EventUnknown, nil
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Handle handle protocol messages (such as message deletion or editing), while ignoring known
|
|
810
|
+
// unhandled types.
|
|
811
|
+
switch info.GetMessageStubType() {
|
|
812
|
+
case waWeb.WebMessageInfo_CIPHERTEXT:
|
|
813
|
+
return EventUnknown, nil
|
|
814
|
+
case waWeb.WebMessageInfo_CALL_MISSED_VOICE, waWeb.WebMessageInfo_CALL_MISSED_VIDEO:
|
|
815
|
+
return EventCall, &EventPayload{Call: Call{
|
|
816
|
+
State: CallMissed,
|
|
817
|
+
JID: info.GetKey().GetRemoteJID(),
|
|
818
|
+
Timestamp: int64(info.GetMessageTimestamp()),
|
|
819
|
+
}}
|
|
820
|
+
case waWeb.WebMessageInfo_REVOKE:
|
|
821
|
+
if p := info.GetMessageStubParameters(); len(p) > 0 {
|
|
822
|
+
message.Kind = MessageRevoke
|
|
823
|
+
message.ID = p[0]
|
|
824
|
+
return EventMessage, &EventPayload{Message: message}
|
|
825
|
+
} else {
|
|
826
|
+
return EventUnknown, nil
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Handle emoji reaction to existing message.
|
|
831
|
+
for _, r := range info.GetReactions() {
|
|
832
|
+
if r.GetText() != "" {
|
|
833
|
+
message.Reactions = append(message.Reactions, Message{
|
|
834
|
+
Kind: MessageReaction,
|
|
835
|
+
ID: r.GetKey().GetID(),
|
|
836
|
+
JID: r.GetKey().GetRemoteJID(),
|
|
837
|
+
Body: r.GetText(),
|
|
838
|
+
Timestamp: r.GetSenderTimestampMS() / 1000,
|
|
839
|
+
IsCarbon: r.GetKey().GetFromMe(),
|
|
840
|
+
})
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// Handle message attachments, if any.
|
|
845
|
+
if attach, context, err := getMessageAttachments(client, info.GetMessage()); err != nil {
|
|
846
|
+
client.Log.Errorf("Failed getting message attachments: %s", err)
|
|
847
|
+
return EventUnknown, nil
|
|
848
|
+
} else if len(attach) > 0 {
|
|
849
|
+
message.Attachments = append(message.Attachments, attach...)
|
|
850
|
+
message.Kind = MessageAttachment
|
|
851
|
+
if context != nil {
|
|
852
|
+
message = getMessageWithContext(message, context)
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Handle pre-set receipt status, if any.
|
|
857
|
+
for _, r := range info.GetUserReceipt() {
|
|
858
|
+
// Ignore self-receipts for the moment, as these cannot be handled correctly by the adapter.
|
|
859
|
+
if client.Store.ID.ToNonAD().String() == r.GetUserJID() {
|
|
860
|
+
continue
|
|
861
|
+
}
|
|
862
|
+
var receipt = Receipt{MessageIDs: []string{message.ID}, JID: r.GetUserJID(), GroupJID: message.GroupJID}
|
|
863
|
+
switch info.GetStatus() {
|
|
864
|
+
case waWeb.WebMessageInfo_DELIVERY_ACK:
|
|
865
|
+
receipt.Kind = ReceiptDelivered
|
|
866
|
+
receipt.Timestamp = r.GetReceiptTimestamp()
|
|
867
|
+
case waWeb.WebMessageInfo_READ:
|
|
868
|
+
receipt.Kind = ReceiptRead
|
|
869
|
+
receipt.Timestamp = r.GetReadTimestamp()
|
|
870
|
+
}
|
|
871
|
+
message.Receipts = append(message.Receipts, receipt)
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Get extended information from message, if available. Extended messages typically represent
|
|
875
|
+
// messages with additional context, such as replies, forwards, etc.
|
|
876
|
+
if e := info.GetMessage().GetExtendedTextMessage(); e != nil {
|
|
877
|
+
if message.Body == "" {
|
|
878
|
+
message.Body = e.GetText()
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
message = getMessageWithContext(message, e.GetContextInfo())
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// Ignore obviously invalid messages.
|
|
885
|
+
if message.Kind == MessagePlain && message.Body == "" {
|
|
886
|
+
return EventUnknown, nil
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
return EventMessage, &EventPayload{Message: message}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ChatStateKind represents the different kinds of chat-states possible in WhatsApp.
|
|
893
|
+
type ChatStateKind int
|
|
894
|
+
|
|
895
|
+
// The chat states handled by the overarching session event handler.
|
|
896
|
+
const (
|
|
897
|
+
ChatStateUnknown ChatStateKind = iota
|
|
898
|
+
ChatStateComposing
|
|
899
|
+
ChatStatePaused
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
// A ChatState represents the activity of a contact within a certain discussion, for instance,
|
|
903
|
+
// whether the contact is currently composing a message. This is separate to the concept of a
|
|
904
|
+
// Presence, which is the contact's general state across all discussions.
|
|
905
|
+
type ChatState struct {
|
|
906
|
+
Kind ChatStateKind
|
|
907
|
+
JID string
|
|
908
|
+
GroupJID string
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// NewChatStateEvent returns event data meant for [Session.propagateEvent] for the primitive
|
|
912
|
+
// chat-state event given.
|
|
913
|
+
func newChatStateEvent(evt *events.ChatPresence) (EventKind, *EventPayload) {
|
|
914
|
+
var state = ChatState{JID: evt.MessageSource.Sender.ToNonAD().String()}
|
|
915
|
+
if evt.MessageSource.IsGroup {
|
|
916
|
+
state.GroupJID = evt.MessageSource.Chat.ToNonAD().String()
|
|
917
|
+
}
|
|
918
|
+
switch evt.State {
|
|
919
|
+
case types.ChatPresenceComposing:
|
|
920
|
+
state.Kind = ChatStateComposing
|
|
921
|
+
case types.ChatPresencePaused:
|
|
922
|
+
state.Kind = ChatStatePaused
|
|
923
|
+
}
|
|
924
|
+
return EventChatState, &EventPayload{ChatState: state}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ReceiptKind represents the different types of delivery receipts possible in WhatsApp.
|
|
928
|
+
type ReceiptKind int
|
|
929
|
+
|
|
930
|
+
// The delivery receipts handled by the overarching session event handler.
|
|
931
|
+
const (
|
|
932
|
+
ReceiptUnknown ReceiptKind = iota
|
|
933
|
+
ReceiptDelivered
|
|
934
|
+
ReceiptRead
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
// A Receipt represents a notice of delivery or presentation for [Message] instances sent or
|
|
938
|
+
// received. Receipts can be delivered for many messages at once, but are generally all delivered
|
|
939
|
+
// under one specific state at a time.
|
|
940
|
+
type Receipt struct {
|
|
941
|
+
Kind ReceiptKind // The distinct kind of receipt presented.
|
|
942
|
+
MessageIDs []string // The list of message IDs to mark for receipt.
|
|
943
|
+
JID string
|
|
944
|
+
GroupJID string
|
|
945
|
+
Timestamp int64
|
|
946
|
+
IsCarbon bool
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// NewReceiptEvent returns event data meant for [Session.propagateEvent] for the primive receipt
|
|
950
|
+
// event given. Unknown or invalid receipts will return an [EventUnknown] event with nil data.
|
|
951
|
+
func newReceiptEvent(evt *events.Receipt) (EventKind, *EventPayload) {
|
|
952
|
+
var receipt = Receipt{
|
|
953
|
+
MessageIDs: append([]string{}, evt.MessageIDs...),
|
|
954
|
+
JID: evt.MessageSource.Sender.ToNonAD().String(),
|
|
955
|
+
Timestamp: evt.Timestamp.Unix(),
|
|
956
|
+
IsCarbon: evt.MessageSource.IsFromMe,
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
if len(receipt.MessageIDs) == 0 {
|
|
960
|
+
return EventUnknown, nil
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if evt.MessageSource.Chat.Server == types.BroadcastServer {
|
|
964
|
+
receipt.JID = evt.MessageSource.BroadcastListOwner.ToNonAD().String()
|
|
965
|
+
} else if evt.MessageSource.IsGroup {
|
|
966
|
+
receipt.GroupJID = evt.MessageSource.Chat.ToNonAD().String()
|
|
967
|
+
} else if receipt.IsCarbon {
|
|
968
|
+
receipt.JID = evt.MessageSource.Chat.ToNonAD().String()
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
switch evt.Type {
|
|
972
|
+
case types.ReceiptTypeDelivered:
|
|
973
|
+
receipt.Kind = ReceiptDelivered
|
|
974
|
+
case types.ReceiptTypeRead:
|
|
975
|
+
receipt.Kind = ReceiptRead
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return EventReceipt, &EventPayload{Receipt: receipt}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// GroupAffiliation represents the set of privilidges given to a specific participant in a group.
|
|
982
|
+
type GroupAffiliation int
|
|
983
|
+
|
|
984
|
+
const (
|
|
985
|
+
GroupAffiliationNone GroupAffiliation = iota // None, or normal member group affiliation.
|
|
986
|
+
GroupAffiliationAdmin // Can perform some management operations.
|
|
987
|
+
GroupAffiliationOwner // Can manage group fully, including destroying the group.
|
|
988
|
+
)
|
|
989
|
+
|
|
990
|
+
// A Group represents a named, many-to-many chat space which may be joined or left at will. All
|
|
991
|
+
// fields apart from the group JID are considered to be optional, and may not be set in cases where
|
|
992
|
+
// group information is being updated against previous assumed state. Groups in WhatsApp are
|
|
993
|
+
// generally invited to out-of-band with respect to overarching adaptor; see the documentation for
|
|
994
|
+
// [Session.GetGroups] for more information.
|
|
995
|
+
type Group struct {
|
|
996
|
+
JID string // The WhatsApp JID for this group.
|
|
997
|
+
Name string // The user-defined, human-readable name for this group.
|
|
998
|
+
Subject GroupSubject // The longer-form, user-defined description for this group.
|
|
999
|
+
Nickname string // Our own nickname in this group-chat.
|
|
1000
|
+
Participants []GroupParticipant // The list of participant contacts for this group, including ourselves.
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// A GroupSubject represents the user-defined group description and attached metadata thereof, for a
|
|
1004
|
+
// given [Group].
|
|
1005
|
+
type GroupSubject struct {
|
|
1006
|
+
Subject string // The user-defined group description.
|
|
1007
|
+
SetAt int64 // The exact time this group description was set at, as a timestamp.
|
|
1008
|
+
SetByJID string // The JID of the user that set the subject.
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
// GroupParticipantAction represents the distinct set of actions that can be taken when encountering
|
|
1012
|
+
// a group participant, typically to add or remove.
|
|
1013
|
+
type GroupParticipantAction int
|
|
1014
|
+
|
|
1015
|
+
const (
|
|
1016
|
+
GroupParticipantActionAdd GroupParticipantAction = iota // Default action; add participant to list.
|
|
1017
|
+
GroupParticipantActionRemove // Remove participant from list, if existing.
|
|
1018
|
+
GroupParticipantActionPromote // Make group member into administrator.
|
|
1019
|
+
GroupParticipantActionDemote // Make group administrator into member.
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
// ToParticipantChange converts our public [GroupParticipantAction] to the internal [ParticipantChange]
|
|
1023
|
+
// representation.
|
|
1024
|
+
func (a GroupParticipantAction) toParticipantChange() whatsmeow.ParticipantChange {
|
|
1025
|
+
switch a {
|
|
1026
|
+
case GroupParticipantActionRemove:
|
|
1027
|
+
return whatsmeow.ParticipantChangeRemove
|
|
1028
|
+
case GroupParticipantActionPromote:
|
|
1029
|
+
return whatsmeow.ParticipantChangePromote
|
|
1030
|
+
case GroupParticipantActionDemote:
|
|
1031
|
+
return whatsmeow.ParticipantChangeDemote
|
|
1032
|
+
default:
|
|
1033
|
+
return whatsmeow.ParticipantChangeAdd
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// A GroupParticipant represents a contact who is currently joined in a given group. Participants in
|
|
1038
|
+
// WhatsApp can always be derived back to their individual [Contact]; there are no anonymous groups
|
|
1039
|
+
// in WhatsApp.
|
|
1040
|
+
type GroupParticipant struct {
|
|
1041
|
+
JID string // The WhatsApp JID for this participant.
|
|
1042
|
+
Affiliation GroupAffiliation // The set of priviledges given to this specific participant.
|
|
1043
|
+
Action GroupParticipantAction // The specific action to take for this participant; typically to add.
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// NewGroupParticipant returns a [GroupParticipant], filling fields from the internal participant
|
|
1047
|
+
// type. This is a no-op if [types.GroupParticipant.Error] is non-zero, and other fields may only
|
|
1048
|
+
// be set optionally.
|
|
1049
|
+
func newGroupParticipant(p types.GroupParticipant) GroupParticipant {
|
|
1050
|
+
if p.Error > 0 {
|
|
1051
|
+
return GroupParticipant{}
|
|
1052
|
+
}
|
|
1053
|
+
var affiliation = GroupAffiliationNone
|
|
1054
|
+
if p.IsSuperAdmin {
|
|
1055
|
+
affiliation = GroupAffiliationOwner
|
|
1056
|
+
} else if p.IsAdmin {
|
|
1057
|
+
affiliation = GroupAffiliationAdmin
|
|
1058
|
+
}
|
|
1059
|
+
return GroupParticipant{
|
|
1060
|
+
JID: p.JID.ToNonAD().String(),
|
|
1061
|
+
Affiliation: affiliation,
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// NewGroupEvent returns event data meant for [Session.propagateEvent] for the primive group event
|
|
1066
|
+
// given. Group data returned by this function can be partial, and callers should take care to only
|
|
1067
|
+
// handle non-empty values.
|
|
1068
|
+
func newGroupEvent(evt *events.GroupInfo) (EventKind, *EventPayload) {
|
|
1069
|
+
var group = Group{JID: evt.JID.ToNonAD().String()}
|
|
1070
|
+
if evt.Name != nil {
|
|
1071
|
+
group.Name = evt.Name.Name
|
|
1072
|
+
}
|
|
1073
|
+
if evt.Topic != nil {
|
|
1074
|
+
group.Subject = GroupSubject{
|
|
1075
|
+
Subject: evt.Topic.Topic,
|
|
1076
|
+
SetAt: evt.Topic.TopicSetAt.Unix(),
|
|
1077
|
+
SetByJID: evt.Topic.TopicSetBy.ToNonAD().String(),
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
for _, p := range evt.Join {
|
|
1081
|
+
group.Participants = append(group.Participants, GroupParticipant{
|
|
1082
|
+
JID: p.ToNonAD().String(),
|
|
1083
|
+
Action: GroupParticipantActionAdd,
|
|
1084
|
+
})
|
|
1085
|
+
}
|
|
1086
|
+
for _, p := range evt.Leave {
|
|
1087
|
+
group.Participants = append(group.Participants, GroupParticipant{
|
|
1088
|
+
JID: p.ToNonAD().String(),
|
|
1089
|
+
Action: GroupParticipantActionRemove,
|
|
1090
|
+
})
|
|
1091
|
+
}
|
|
1092
|
+
for _, p := range evt.Promote {
|
|
1093
|
+
group.Participants = append(group.Participants, GroupParticipant{
|
|
1094
|
+
JID: p.ToNonAD().String(),
|
|
1095
|
+
Action: GroupParticipantActionPromote,
|
|
1096
|
+
Affiliation: GroupAffiliationAdmin,
|
|
1097
|
+
})
|
|
1098
|
+
}
|
|
1099
|
+
for _, p := range evt.Demote {
|
|
1100
|
+
group.Participants = append(group.Participants, GroupParticipant{
|
|
1101
|
+
JID: p.ToNonAD().String(),
|
|
1102
|
+
Action: GroupParticipantActionDemote,
|
|
1103
|
+
Affiliation: GroupAffiliationNone,
|
|
1104
|
+
})
|
|
1105
|
+
}
|
|
1106
|
+
return EventGroup, &EventPayload{Group: group}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// NewGroup returns a concrete [Group] for the primitive data given. This function will generally
|
|
1110
|
+
// populate fields with as much data as is available from the remote, and is therefore should not
|
|
1111
|
+
// be called when partial data is to be returned.
|
|
1112
|
+
func newGroup(client *whatsmeow.Client, info *types.GroupInfo) Group {
|
|
1113
|
+
var participants []GroupParticipant
|
|
1114
|
+
for i := range info.Participants {
|
|
1115
|
+
p := newGroupParticipant(info.Participants[i])
|
|
1116
|
+
if p.JID == "" {
|
|
1117
|
+
continue
|
|
1118
|
+
}
|
|
1119
|
+
participants = append(participants, p)
|
|
1120
|
+
}
|
|
1121
|
+
return Group{
|
|
1122
|
+
JID: info.JID.ToNonAD().String(),
|
|
1123
|
+
Name: info.GroupName.Name,
|
|
1124
|
+
Subject: GroupSubject{
|
|
1125
|
+
Subject: info.Topic,
|
|
1126
|
+
SetAt: info.TopicSetAt.Unix(),
|
|
1127
|
+
SetByJID: info.TopicSetBy.ToNonAD().String(),
|
|
1128
|
+
},
|
|
1129
|
+
Nickname: client.Store.PushName,
|
|
1130
|
+
Participants: participants,
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
// CallState represents the state of the call to synchronize with.
|
|
1135
|
+
type CallState int
|
|
1136
|
+
|
|
1137
|
+
// The call states handled by the overarching session event handler.
|
|
1138
|
+
const (
|
|
1139
|
+
CallUnknown CallState = iota
|
|
1140
|
+
CallIncoming
|
|
1141
|
+
CallMissed
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
// CallStateFromReason converts the given (internal) reason string to a public [CallState]. Calls
|
|
1145
|
+
// given invalid or unknown reasons will return the [CallUnknown] state.
|
|
1146
|
+
func callStateFromReason(reason string) CallState {
|
|
1147
|
+
switch reason {
|
|
1148
|
+
case "", "timeout":
|
|
1149
|
+
return CallMissed
|
|
1150
|
+
default:
|
|
1151
|
+
return CallUnknown
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// A Call represents an incoming or outgoing voice/video call made over WhatsApp. Full support for
|
|
1156
|
+
// calls is currently not implemented, and this structure contains the bare minimum data required
|
|
1157
|
+
// for notifying on missed calls.
|
|
1158
|
+
type Call struct {
|
|
1159
|
+
State CallState
|
|
1160
|
+
JID string
|
|
1161
|
+
Timestamp int64
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// NewCallEvent returns event data meant for [Session.propagateEvent] for the call metadata given.
|
|
1165
|
+
func newCallEvent(state CallState, meta types.BasicCallMeta) (EventKind, *EventPayload) {
|
|
1166
|
+
if state == CallUnknown || meta.From.IsEmpty() {
|
|
1167
|
+
return EventUnknown, nil
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
return EventCall, &EventPayload{Call: Call{
|
|
1171
|
+
State: state,
|
|
1172
|
+
JID: meta.From.ToNonAD().String(),
|
|
1173
|
+
Timestamp: meta.Timestamp.Unix(),
|
|
1174
|
+
}}
|
|
1175
|
+
}
|