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.
- slidge_whatsapp/__init__.py +11 -0
- slidge_whatsapp/__main__.py +9 -0
- slidge_whatsapp/attachment.go +386 -0
- slidge_whatsapp/command.py +143 -0
- slidge_whatsapp/config.py +38 -0
- slidge_whatsapp/contact.py +75 -0
- slidge_whatsapp/event.go +856 -0
- slidge_whatsapp/gateway.go +175 -0
- slidge_whatsapp/gateway.py +97 -0
- slidge_whatsapp/generated/__init__.py +0 -0
- slidge_whatsapp/generated/_whatsapp.cpython-311-x86_64-linux-gnu.so +0 -0
- slidge_whatsapp/generated/build.py +378 -0
- slidge_whatsapp/generated/go.py +1720 -0
- slidge_whatsapp/generated/whatsapp.py +2797 -0
- slidge_whatsapp/go.mod +28 -0
- slidge_whatsapp/go.sum +55 -0
- slidge_whatsapp/group.py +240 -0
- slidge_whatsapp/session.go +783 -0
- slidge_whatsapp/session.py +663 -0
- slidge_whatsapp/util.py +12 -0
- slidge_whatsapp-0.2.0a0.dist-info/LICENSE +661 -0
- slidge_whatsapp-0.2.0a0.dist-info/METADATA +81 -0
- slidge_whatsapp-0.2.0a0.dist-info/RECORD +25 -0
- slidge_whatsapp-0.2.0a0.dist-info/WHEEL +4 -0
- slidge_whatsapp-0.2.0a0.dist-info/entry_points.txt +3 -0
|
@@ -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,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
|