slidge-whatsapp 0.2.0a0__cp311-cp311-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.

Potentially problematic release.


This version of slidge-whatsapp might be problematic. Click here for more details.

@@ -0,0 +1,11 @@
1
+ """
2
+ WhatsApp gateway using the multi-device API.
3
+ """
4
+
5
+ from slidge.util.util import get_version # noqa: F401
6
+
7
+ from . import command, config, contact, group, session
8
+ from .gateway import Gateway
9
+
10
+ __version__ = "0.2.0alpha0"
11
+ __all__ = "Gateway", "session", "command", "contact", "config", "group"
@@ -0,0 +1,9 @@
1
+ from slidge import entrypoint
2
+
3
+
4
+ def main():
5
+ entrypoint("slidge_whatsapp")
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,386 @@
1
+ package whatsapp
2
+
3
+ import (
4
+ // Standard library.
5
+ "bufio"
6
+ "bytes"
7
+ "errors"
8
+ "fmt"
9
+ "image"
10
+ "image/jpeg"
11
+ _ "image/png"
12
+ "math"
13
+ "os"
14
+ "os/exec"
15
+ "path"
16
+ "strconv"
17
+ "strings"
18
+ "time"
19
+
20
+ // Third-party packages.
21
+ "github.com/h2non/filetype"
22
+ _ "golang.org/x/image/webp"
23
+ )
24
+
25
+ // The full path and default arguments for FFmpeg, used for converting media to supported types.
26
+ var (
27
+ ffmpegCommand, _ = exec.LookPath("ffmpeg")
28
+ ffmpegDefaultArgs = []string{"-v", "error", "-y"}
29
+ )
30
+
31
+ // The full path and default arguments for FFprobe, as provided by FFmpeg, used for getting media
32
+ // metadata (e.g. duration, waveforms, etc.)
33
+ var (
34
+ ffprobeCommand, _ = exec.LookPath("ffprobe")
35
+ ffprobeDefaultArgs = []string{"-v", "error", "-of", "csv=nokey=0:print_section=0"}
36
+ )
37
+
38
+ const (
39
+ // The MIME type used by voice messages on WhatsApp.
40
+ voiceMessageMIME = "audio/ogg; codecs=opus"
41
+ // the MIME type used by animated images on WhatsApp.
42
+ animatedImageMIME = "image/gif"
43
+ )
44
+
45
+ // A ConvertAttachmentFunc is a function that can convert any attachment to another format, given a
46
+ // set of arguments.
47
+ type convertAttachmentFunc func(*Attachment, ...string) error
48
+
49
+ // ConvertAttachmentOptions contains options used in converting media between formats via FFmpeg.
50
+ type convertAttachmentOptions struct {
51
+ mime string // The destination MIME type for the converted media.
52
+ call convertAttachmentFunc // The function to use for converting media.
53
+ args []string // The arguments to pass to the conversion function.
54
+ }
55
+
56
+ // Attachment conversion specifications.
57
+ var (
58
+ // The MIME type and conversion arguments used by image messages on WhatsApp.
59
+ imageMessageOptions = convertAttachmentOptions{
60
+ mime: "image/jpeg",
61
+ call: convertImage,
62
+ }
63
+ // The MIME type and conversion arguments used by voice messages on WhatsApp.
64
+ voiceMessageOptions = convertAttachmentOptions{
65
+ mime: voiceMessageMIME,
66
+ call: convertAudioVideo,
67
+ args: []string{
68
+ "-f", "ogg", "-c:a", "libopus", // Convert to Ogg with Opus.
69
+ "-ac", "1", // Convert to mono.
70
+ "-ar", "48000", // Use specific sample-rate of 48000hz.
71
+ "-b:a", "64k", // Use relatively reasonable bit-rate of 64kBit/s.
72
+ "-map_metadata", "-1", // Remove all metadata from output.
73
+ },
74
+ }
75
+ // The MIME type and conversion arguments used by video messages on WhatsApp.
76
+ videoMessageOptions = convertAttachmentOptions{
77
+ mime: "video/mp4",
78
+ call: convertAudioVideo,
79
+ args: []string{
80
+ "-f", "mp4", "-c:v", "libx264", // Convert to mp4 with h264.
81
+ "-pix_fmt", "yuv420p", // Use YUV 4:2:0 chroma subsampling.
82
+ "-profile:v", "baseline", // Use Baseline profile for better compatibility.
83
+ "-level", "3.0", // Ensure compatibility with older devices.
84
+ "-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2", // Pad dimensions to ensure height is a factor of 2.
85
+ "-r", "25", "-g", "50", // Use 25fps, with an index frame every 50 frames.
86
+ "-c:a", "aac", "-b:a", "160k", "-r:a", "44100", // Re-encode audio to AAC, if any.
87
+ "-movflags", "+faststart", // Use Faststart for quicker rendering.
88
+ "-y", // Overwrite existing output file, where this exists.
89
+ },
90
+ }
91
+ )
92
+
93
+ // ConvertAttachmentTypes represents a list of media types to convert based on source MIME type.
94
+ var convertAttachmentTypes = map[string]convertAttachmentOptions{
95
+ "image/png": imageMessageOptions,
96
+ "image/webp": imageMessageOptions,
97
+ "audio/mp4": voiceMessageOptions,
98
+ "audio/aac": voiceMessageOptions,
99
+ "audio/ogg; codecs=opus": voiceMessageOptions,
100
+ "video/mp4": videoMessageOptions,
101
+ "video/webm": videoMessageOptions,
102
+ "image/gif": {
103
+ mime: videoMessageOptions.mime,
104
+ call: videoMessageOptions.call,
105
+ args: append([]string{
106
+ "-r", "10", // Assume 10fps GIF speed.
107
+ }, videoMessageOptions.args...),
108
+ },
109
+ }
110
+
111
+ // ConvertAttachment attempts to process a given attachment from a less-supported type to a
112
+ // canonically supported one; for example, from `image/png` to `image/jpeg`. Decisions about which
113
+ // MIME types to convert to are based on the concrete MIME type inferred from the file itself, and
114
+ // care is taken to conform to WhatsApp semantics for the given input MIME type. If the input MIME
115
+ // type is unknown, or conversion is impossible, the original attachment is returned unchanged.
116
+ func convertAttachment(attach *Attachment) error {
117
+ var detectedMIME string
118
+ if t, _ := filetype.MatchFile(attach.Path); t != filetype.Unknown {
119
+ detectedMIME = t.MIME.Value
120
+ if attach.MIME == "" || attach.MIME == "application/octet-stream" {
121
+ attach.MIME = detectedMIME
122
+ }
123
+ }
124
+
125
+ switch detectedMIME {
126
+ case "audio/m4a":
127
+ // MP4 audio files are matched as `audio/m4a` which is not a valid MIME, correct this to
128
+ // `audio/mp4`, which is what WhatsApp requires as well.
129
+ detectedMIME = "audio/mp4"
130
+ fallthrough
131
+ case "audio/mp4", "audio/ogg":
132
+ if err := populateAttachmentMetadata(attach); err == nil {
133
+ switch attach.meta.codec {
134
+ // Don't attempt to process lossless files at all, as it's assumed that the sender
135
+ // wants to retain these characteristics. Since WhatsApp will try (and likely fail)
136
+ // to process this as an audio message anyways, set a unique MIME type.
137
+ case "alac":
138
+ attach.MIME = "application/octet-stream"
139
+ return nil
140
+ case "opus":
141
+ detectedMIME += "; codecs=" + attach.meta.codec
142
+ }
143
+ }
144
+ case "video/mp4":
145
+ // Try to see if there's a video stream for ostensibly video-related MIME types, as these are
146
+ // some times misdetected as such.
147
+ if err := populateAttachmentMetadata(attach); err == nil {
148
+ if attach.meta.width == 0 && attach.meta.height == 0 && attach.meta.sampleRate > 0 && attach.meta.duration > 0 {
149
+ detectedMIME = "audio/mp4"
150
+ }
151
+ }
152
+ }
153
+
154
+ // Convert attachment between file-types, if source MIME matches the known list of convertable types.
155
+ if o, ok := convertAttachmentTypes[detectedMIME]; ok {
156
+ if err := o.call(attach, o.args...); err != nil {
157
+ return fmt.Errorf("conversion from %s to %s failed: %s", attach.MIME, o.mime, err)
158
+ } else {
159
+ attach.MIME = o.mime
160
+ }
161
+ }
162
+
163
+ return nil
164
+ }
165
+
166
+ const (
167
+ // The maximum image attachment size we'll attempt to process in any way, in bytes.
168
+ maxImageSize = 1024 * 1024 * 10 // 10MiB
169
+ // The maximum audio/video attachment size we'll attempt to process in any way, in bytes.
170
+ maxAudioVideoSize = 1024 * 1024 * 20 // 20MiB
171
+ )
172
+
173
+ // ConvertImage processes the given Attachment, assumed to be an image of a supported format, and
174
+ // converting to a JPEG-encoded image in-place. No arguments are processed currently.
175
+ func convertImage(attach *Attachment, args ...string) error {
176
+ if stat, err := os.Stat(attach.Path); err != nil {
177
+ return err
178
+ } else if s := stat.Size(); s > maxImageSize {
179
+ return fmt.Errorf("attachment size %d exceeds maximum of %d", s, maxImageSize)
180
+ }
181
+
182
+ f, err := os.OpenFile(attach.Path, os.O_RDWR, 0)
183
+ if err != nil {
184
+ return err
185
+ }
186
+
187
+ img, _, err := image.Decode(f)
188
+ if err != nil {
189
+ f.Close()
190
+ return err
191
+ }
192
+
193
+ f.Close()
194
+ if f, err = os.Create(attach.Path); err != nil {
195
+ return err
196
+ }
197
+
198
+ if err = jpeg.Encode(f, img, nil); err != nil {
199
+ return err
200
+ }
201
+
202
+ return nil
203
+ }
204
+
205
+ // ConvertAudioVideo processes the given Attachment, assumed to be an audio or video file of a
206
+ // supported format, according to the arguments given.
207
+ func convertAudioVideo(attach *Attachment, args ...string) error {
208
+ if ffmpegCommand == "" {
209
+ return fmt.Errorf("FFmpeg command not found")
210
+ } else if stat, err := os.Stat(attach.Path); err != nil {
211
+ return err
212
+ } else if s := stat.Size(); s > maxAudioVideoSize {
213
+ return fmt.Errorf("attachment size %d exceeds maximum of %d", s, maxAudioVideoSize)
214
+ }
215
+
216
+ tmp, err := os.CreateTemp(path.Dir(attach.Path), path.Base(attach.Path)+".*")
217
+ if err != nil {
218
+ return fmt.Errorf("failed creating temporary file: %w", err)
219
+ }
220
+
221
+ args = append(ffmpegDefaultArgs, append([]string{"-i", attach.Path}, append(args, tmp.Name())...)...)
222
+ cmd := exec.Command(ffmpegCommand, args...)
223
+ tmp.Close()
224
+
225
+ if _, err := cmd.Output(); err != nil {
226
+ if e := new(exec.ExitError); errors.As(err, &e) {
227
+ return fmt.Errorf("%s: %s", e.Error(), bytes.TrimSpace(e.Stderr))
228
+ }
229
+ return err
230
+ }
231
+
232
+ if err := os.Rename(tmp.Name(), attach.Path); err != nil {
233
+ return fmt.Errorf("failed cleaning up temporary file: %w", err)
234
+ }
235
+
236
+ return nil
237
+ }
238
+
239
+ // GetAttachmentThumbnail returns a static thumbnail in JPEG format from the given Attachment, assumed
240
+ // to point to a video file. If no thumbnail could be generated for any reason, this returns nil.
241
+ func getAttachmentThumbnail(attach *Attachment) ([]byte, error) {
242
+ var tmp string
243
+ if data, err := os.ReadFile(attach.Path); err != nil {
244
+ return nil, fmt.Errorf("failed reading attachment %s: %w", attach.Path, err)
245
+ } else if tmp, err = createTempFile(data); err != nil {
246
+ return nil, err
247
+ }
248
+
249
+ defer os.Remove(tmp)
250
+ var buf []byte
251
+
252
+ args := []string{"-f", "mjpeg", "-vf", "scale=500:-1", "-qscale:v", "5", "-frames:v", "1"}
253
+ if err := convertAudioVideo(&Attachment{Path: tmp}, args...); err != nil {
254
+ return nil, err
255
+ } else if buf, err = os.ReadFile(tmp); err != nil {
256
+ return nil, fmt.Errorf("failed reading converted file: %w", err)
257
+ }
258
+
259
+ return buf, nil
260
+ }
261
+
262
+ // AttachmentMetadata represents secondary information for a given audio/video buffer. This information
263
+ // is usually gathered on a best-effort basis, and thus may be missing even for otherwise valid
264
+ // media buffers.
265
+ type attachmentMetadata struct {
266
+ codec string // The codec used for the primary stream in this attachment.
267
+ width int // The calculated width of the given video buffer; 0 if there's no video stream.
268
+ height int // The calculated height of the given video buffer; 0 if there's no video stream.
269
+ sampleRate int // The calculated sample rate of the given audio buffer; usually not set for video streams.
270
+ duration time.Duration // The duration of the given audio/video stream.
271
+ }
272
+
273
+ // PopulateAttachmentMetadata calculates and populates secondary information for the given
274
+ // audio/video attachment, if any. Metadata is gathered on a best-effort basis, and may be missing;
275
+ // see the documentation for [attachmentMetadata] for more information.
276
+ func populateAttachmentMetadata(attach *Attachment) error {
277
+ if ffprobeCommand == "" {
278
+ return fmt.Errorf("FFprobe command not found")
279
+ } else if stat, err := os.Stat(attach.Path); err != nil {
280
+ return err
281
+ } else if s := stat.Size(); s > maxAudioVideoSize {
282
+ return fmt.Errorf("attachment size %d exceeds maximum of %d", s, maxAudioVideoSize)
283
+ }
284
+
285
+ args := append(ffprobeDefaultArgs, []string{
286
+ "-i", attach.Path,
287
+ "-show_entries", "stream=codec_name,width,height,sample_rate,duration",
288
+ }...)
289
+
290
+ cmd := exec.Command(ffprobeCommand, args...)
291
+
292
+ stdout, err := cmd.StdoutPipe()
293
+ if err != nil {
294
+ return fmt.Errorf("failed to set up standard output: %s", err)
295
+ } else if err = cmd.Start(); err != nil {
296
+ return fmt.Errorf("failed to initialize command: %s", err)
297
+ }
298
+
299
+ var meta attachmentMetadata
300
+ scanner := bufio.NewScanner(stdout)
301
+
302
+ for scanner.Scan() {
303
+ for _, f := range strings.Split(scanner.Text(), ",") {
304
+ k, v, _ := strings.Cut(strings.TrimSpace(f), "=")
305
+ switch k {
306
+ case "codec_name":
307
+ meta.codec = v
308
+ case "duration":
309
+ if v, err := strconv.ParseFloat(v, 64); err == nil {
310
+ meta.duration = time.Duration(v * float64(time.Second))
311
+ }
312
+ case "width":
313
+ if v, err := strconv.Atoi(v); err == nil {
314
+ meta.width = v
315
+ }
316
+ case "height":
317
+ if v, err := strconv.Atoi(v); err == nil {
318
+ meta.height = v
319
+ }
320
+ case "sample_rate":
321
+ if v, err := strconv.Atoi(v); err == nil {
322
+ meta.sampleRate = v
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ if err = cmd.Wait(); err != nil {
329
+ return fmt.Errorf("failed to wait for command to complete: %s", err)
330
+ } else if err = scanner.Err(); err != nil {
331
+ return fmt.Errorf("failed scanning command output: %s", err)
332
+ }
333
+
334
+ attach.meta = meta
335
+ return nil
336
+ }
337
+
338
+ const (
339
+ // The maximum number of samples to return in media waveforms.
340
+ maxWaveformSamples = 64
341
+ )
342
+
343
+ // GetAttachmentWaveform returns the computed waveform for the attachment given, as a series of 64
344
+ // numbers ranging from 0 to 100. Any errors in computing the waveform will have this function
345
+ // return a nil result.
346
+ func getAttachmentWaveform(attach *Attachment) ([]byte, error) {
347
+ if ffprobeCommand == "" {
348
+ return nil, fmt.Errorf("FFprobe command not found")
349
+ } else if stat, err := os.Stat(attach.Path); err != nil {
350
+ return nil, err
351
+ } else if s := stat.Size(); s > maxAudioVideoSize {
352
+ return nil, fmt.Errorf("attachment size %d exceeds maximum of %d", s, maxAudioVideoSize)
353
+ } else if attach.meta.sampleRate == 0 || attach.meta.duration == 0 {
354
+ return nil, fmt.Errorf("empty sample-rate or duration")
355
+ }
356
+
357
+ var samples = make([]byte, 0, maxWaveformSamples)
358
+ var numSamples = int(float64(attach.meta.sampleRate)*attach.meta.duration.Seconds()) / maxWaveformSamples
359
+
360
+ // Determine number of waveform to take based on duration and sample-rate of original file.
361
+ // Get waveform with 64 samples, and scale these from a range of 0 to 100.
362
+ args := append(ffprobeDefaultArgs, []string{
363
+ "-f", "lavfi",
364
+ "-i", "amovie=" + attach.Path + ",asetnsamples=" + strconv.Itoa(numSamples) + ",astats=metadata=1:reset=1",
365
+ "-show_entries", "frame_tags=lavfi.astats.Overall.Peak_level",
366
+ }...)
367
+
368
+ var buf bytes.Buffer
369
+ cmd := exec.Command(ffprobeCommand, args...)
370
+ cmd.Stdout = &buf
371
+
372
+ if err := cmd.Run(); err != nil {
373
+ return nil, fmt.Errorf("failed to run command: %w", err)
374
+ }
375
+
376
+ scanner := bufio.NewScanner(&buf)
377
+ for scanner.Scan() {
378
+ _, v, _ := bytes.Cut(scanner.Bytes(), []byte{'='})
379
+ db, err := strconv.ParseFloat(string(bytes.Trim(v, "\n\r")), 64)
380
+ if err == nil {
381
+ samples = append(samples, byte(math.Pow(10, (db/50))*100))
382
+ }
383
+ }
384
+
385
+ return samples[:maxWaveformSamples], nil
386
+ }
@@ -0,0 +1,143 @@
1
+ from typing import TYPE_CHECKING, Optional
2
+
3
+ from slidge.command import Command, CommandAccess, Form, FormField
4
+ from slidge.util import is_valid_phone_number
5
+ from slixmpp import JID
6
+ from slixmpp.exceptions import XMPPError
7
+
8
+ from .generated import whatsapp
9
+
10
+ if TYPE_CHECKING:
11
+ from .session import Session
12
+
13
+
14
+ class Logout(Command):
15
+ NAME = "🔓 Disconnect from WhatsApp"
16
+ HELP = (
17
+ "Disconnects active WhatsApp session without removing any linked device credentials. "
18
+ "To re-connect, use the 're-login' command."
19
+ )
20
+ NODE = "wa_logout"
21
+ CHAT_COMMAND = "logout"
22
+ ACCESS = CommandAccess.USER_LOGGED
23
+
24
+ async def run(
25
+ self,
26
+ session: Optional["Session"], # type:ignore
27
+ ifrom: JID,
28
+ *args,
29
+ ) -> str:
30
+ assert session is not None
31
+ try:
32
+ session.shutdown()
33
+ except Exception as e:
34
+ session.send_gateway_status(f"Logout failed: {e}", show="dnd")
35
+ raise XMPPError(
36
+ "internal-server-error",
37
+ etype="wait",
38
+ text=f"Could not logout WhatsApp session: {e}",
39
+ )
40
+ session.send_gateway_status("Logged out", show="away")
41
+ return "Logged out successfully"
42
+
43
+
44
+ class PairPhone(Command):
45
+ NAME = "📱 Complete registration via phone number"
46
+ HELP = (
47
+ "As an alternative to QR code verification, this allows you to complete registration "
48
+ "by inputing a one-time code into the official WhatsApp client; this requires that you "
49
+ "provide the phone number used for the main device, in international format "
50
+ "(e.g. +447700900000). See more information here: https://faq.whatsapp.com/1324084875126592"
51
+ )
52
+ NODE = "wa_pair_phone"
53
+ CHAT_COMMAND = "pair-phone"
54
+ ACCESS = CommandAccess.USER_NON_LOGGED
55
+
56
+ async def run(
57
+ self,
58
+ session: Optional["Session"], # type:ignore
59
+ ifrom: JID,
60
+ *args,
61
+ ) -> Form:
62
+ return Form(
63
+ title="Pair to WhatsApp via phone number",
64
+ instructions="Enter your phone number in international format (e.g. +447700900000)",
65
+ fields=[FormField(var="phone", label="Phone number", required=True)],
66
+ handler=self.finish, # type:ignore
67
+ )
68
+
69
+ @staticmethod
70
+ async def finish(form_values: dict, session: "Session", _ifrom: JID):
71
+ p = form_values.get("phone")
72
+ if not is_valid_phone_number(p):
73
+ raise ValueError("Not a valid phone number", p)
74
+ code = session.whatsapp.PairPhone(p)
75
+ return f"Please open the official WhatsApp client and input the following code: {code}"
76
+
77
+
78
+ class ChangePresence(Command):
79
+ NAME = "📴 Set WhatsApp web presence"
80
+ HELP = (
81
+ "If you want to receive notifications in the WhatsApp official client,"
82
+ "you need to set your presence to unavailable. As a side effect, you "
83
+ "won't receive receipts and presences from your contacts."
84
+ )
85
+ NODE = "wa_presence"
86
+ CHAT_COMMAND = "presence"
87
+ ACCESS = CommandAccess.USER_LOGGED
88
+
89
+ async def run(
90
+ self,
91
+ session: Optional["Session"], # type:ignore
92
+ ifrom: JID,
93
+ *args,
94
+ ) -> Form:
95
+ return Form(
96
+ title="Set WhatsApp web presence",
97
+ instructions="Choose what type of presence you want to set",
98
+ fields=[
99
+ FormField(
100
+ var="presence",
101
+ value="available",
102
+ type="list-single",
103
+ options=[
104
+ {"label": "Available", "value": "available"},
105
+ {"label": "Unavailable", "value": "unavailable"},
106
+ ],
107
+ )
108
+ ],
109
+ handler=self.finish, # type:ignore
110
+ )
111
+
112
+ @staticmethod
113
+ async def finish(form_values: dict, session: "Session", _ifrom: JID):
114
+ p = form_values.get("presence")
115
+ if p == "available":
116
+ session.whatsapp.SendPresence(whatsapp.PresenceAvailable, "")
117
+ elif p == "unavailable":
118
+ session.whatsapp.SendPresence(whatsapp.PresenceUnavailable, "")
119
+ else:
120
+ raise ValueError("Not a valid presence kind.", p)
121
+ return f"Presence succesfully set to {p}"
122
+
123
+
124
+ class SubscribeToPresences(Command):
125
+ NAME = "🔔 Subscribe to contacts' presences"
126
+ HELP = (
127
+ "Subscribes to and refreshes contacts' presences; typically this is "
128
+ "done automatically, but re-subscribing might be useful in case contact "
129
+ "presences are stuck or otherwise not updating."
130
+ )
131
+ NODE = "wa_subscribe"
132
+ CHAT_COMMAND = "subscribe"
133
+ ACCESS = CommandAccess.USER_LOGGED
134
+
135
+ async def run(
136
+ self,
137
+ session: Optional["Session"], # type:ignore
138
+ ifrom: JID,
139
+ *args,
140
+ ) -> str:
141
+ assert session is not None
142
+ session.whatsapp.GetContacts(False)
143
+ return "Looks like no exception was raised. Success, I guess?"
@@ -0,0 +1,38 @@
1
+ """
2
+ Config contains plugin-specific configuration for WhatsApp, and is loaded automatically by the
3
+ core configuration framework.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from slidge import global_config
10
+
11
+ # workaround because global_config.HOME_DIR is not defined unless
12
+ # called by slidge's main(), which is a problem for tests, docs and the
13
+ # dedicated slidge-whatsapp setuptools entrypoint
14
+ try:
15
+ DB_PATH: Optional[Path] = global_config.HOME_DIR / "whatsapp" / "whatsapp.db"
16
+ except AttributeError:
17
+ DB_PATH: Optional[Path] = None # type:ignore
18
+
19
+ DB_PATH__DOC = (
20
+ "The path to the database used for the WhatsApp plugin. Default to "
21
+ "${SLIDGE_HOME_DIR}/whatsapp/whatsapp.db"
22
+ )
23
+
24
+ ALWAYS_SYNC_ROSTER = False
25
+ ALWAYS_SYNC_ROSTER__DOC = (
26
+ "Whether or not to perform a full sync of the WhatsApp roster on startup."
27
+ )
28
+
29
+ SKIP_VERIFY_TLS = False
30
+ SKIP_VERIFY_TLS__DOC = (
31
+ "Whether or not HTTPS connections made by this plugin should verify TLS"
32
+ " certificates."
33
+ )
34
+
35
+ ENABLE_LINK_PREVIEWS = True
36
+ ENABLE_LINK_PREVIEWS__DOC = (
37
+ "Whether or not previews for links (URLs) should be generated on outgoing messages"
38
+ )
@@ -0,0 +1,75 @@
1
+ from datetime import datetime, timezone
2
+ from typing import TYPE_CHECKING
3
+
4
+ from slidge import LegacyContact, LegacyRoster
5
+ from slixmpp.exceptions import XMPPError
6
+
7
+ from . import config
8
+ from .generated import whatsapp
9
+
10
+ if TYPE_CHECKING:
11
+ from .session import Session
12
+
13
+
14
+ class Contact(LegacyContact[str]):
15
+ CORRECTION = True
16
+ REACTIONS_SINGLE_EMOJI = True
17
+
18
+ async def update_presence(
19
+ self, presence: whatsapp.PresenceKind, last_seen_timestamp: int
20
+ ):
21
+ last_seen = (
22
+ datetime.fromtimestamp(last_seen_timestamp, tz=timezone.utc)
23
+ if last_seen_timestamp > 0
24
+ else None
25
+ )
26
+ if presence == whatsapp.PresenceUnavailable:
27
+ self.away(last_seen=last_seen)
28
+ else:
29
+ self.online(last_seen=last_seen)
30
+
31
+
32
+ class Roster(LegacyRoster[str, Contact]):
33
+ session: "Session"
34
+
35
+ async def fill(self):
36
+ """
37
+ Retrieve contacts from remote WhatsApp service, subscribing to their presence and adding to
38
+ local roster.
39
+ """
40
+ contacts = self.session.whatsapp.GetContacts(refresh=config.ALWAYS_SYNC_ROSTER)
41
+ for contact in contacts:
42
+ c = await self.add_whatsapp_contact(contact)
43
+ if c is not None:
44
+ yield c
45
+
46
+ async def add_whatsapp_contact(self, data: whatsapp.Contact) -> Contact | None:
47
+ """
48
+ Adds a WhatsApp contact to local roster, filling all required and optional information.
49
+ """
50
+ if data.JID == self.user_legacy_id:
51
+ # with the current implementation, we don't allow that
52
+ return None
53
+ contact = await self.by_legacy_id(data.JID)
54
+ contact.name = data.Name
55
+ contact.is_friend = True
56
+ try:
57
+ avatar = self.session.whatsapp.GetAvatar(data.JID, contact.avatar or "")
58
+ if avatar.URL:
59
+ await contact.set_avatar(avatar.URL, avatar.ID)
60
+ except RuntimeError as err:
61
+ self.session.log.error(
62
+ "Failed getting avatar for contact %s: %s", data.JID, err
63
+ )
64
+ contact.set_vcard(full_name=contact.name, phone=str(contact.jid.local))
65
+ return contact
66
+
67
+ async def legacy_id_to_jid_username(self, legacy_id: str) -> str:
68
+ return "+" + legacy_id[: legacy_id.find("@")]
69
+
70
+ async def jid_username_to_legacy_id(self, jid_username: str) -> str:
71
+ if jid_username.startswith("#"):
72
+ raise XMPPError("item-not-found", "Invalid contact ID: group ID given")
73
+ if not jid_username.startswith("+"):
74
+ raise XMPPError("item-not-found", "Invalid contact ID, expected '+' prefix")
75
+ return jid_username.removeprefix("+") + "@" + whatsapp.DefaultUserServer