This commit implements basic support for WhatsApp via the multi-device
API, as supported by the `tulir/whatsmeow` Go library. Integration
between Python and Go has been achieved using GoPy, but the artifacts
produced are not (yet) commited to the repository; the Docker development
environment has been configured to build (and re-build) these as needed,
but local development remains an open question.
Most features currently supported within Slidge are also supported by
the WhatsApp plugin, with the notable omission of message correction,
which is currently unsupported by WhatsApp itself (but see here[0] for
more). Feature parity will continue to evolve as Slidge evolves.
Even so, there are several caveats or missing pieces to this work, e.g.:
- Slidge will only allow a JID to link against a single device, as
it's unclear what happens when multiple linked devices share a
contact; how do we choose which account to send from?
- Full roster synchronization will only happen on pair, and subsequent
connections (say, when Slidge restarts) will only synchronize
presence, unless configured otherwise. The user-facing effect here
is that contacts may appear to lose their avatars or nicknames,
depending on whether these are cached by the XMPP clients themselves.
The reasoning is that full roster synchronization can be an expensive
operation (in terms of calls to remote endpoints), and larger Slidge
instances are in danger of hitting rate-limits if they synchronize all
contacts for all registered users at once.
- Media messages (i.e. image, audio, or video) sent to WhatsApp are
only represented as such if they conform to specific requirements:
JPEG for images, OGG with Opus for audio, MP4 for video. Unfortunately,
XMPP clients do not always send data under these formats (for instance,
Conversations will send audio recordings as M4A files), which leads to
degraded UX on the receiving side.
Solving this will require integrating a processing pipeline for these
media files, most likely, something based on FFMPEG, which will only
really work for smaller Slidge instances.
- Search and other ad-hoc and chat commands are currently not implemented,
but support for these will be added in subsequent commits.
- Local development is unavoidably more complicated with the introduction
of Go and GoPy dependencies, and documentation here is also lacking.
Debian packaging is an open question as well.
The Docker environment is set up to build the WhatsApp plugin container
separately to other plugin containers, though.
Nevertheless, the plugin is in a good enough state, and has been well-tested
throughout development, to be in a mergeable state.
Fixes: #24
[0]: https://wabetainfo.com/whatsapp-is-working-on-editing-text-messages/
---
This patch removes some un-needed imports, fixes formatting in documentation,
and sends the patch email with the right project annotation.
.gitignore | 3 +-
Dockerfile | 32 +-
README.md | 17 +-
confs/prosody.cfg.lua | 8 +
docker-compose.yml | 18 ++
docs/source/user/plugins/whatsapp.rst | 22 ++
slidge/core/contact.py | 2 +-
slidge/plugins/whatsapp/__init__.py | 435 ++++++++++++++++++++++++++
slidge/plugins/whatsapp/event.go | 427 +++++++++++++++++++++++++
slidge/plugins/whatsapp/gateway.go | 183 +++++++++++
slidge/plugins/whatsapp/go.mod | 17 +
slidge/plugins/whatsapp/go.sum | 22 ++
slidge/plugins/whatsapp/session.go | 331 ++++++++++++++++++++
watcher.py | 33 ++
14 files changed, 1535 insertions(+), 15 deletions(-)
create mode 100644 docs/source/user/plugins/whatsapp.rst
create mode 100644 slidge/plugins/whatsapp/__init__.py
create mode 100644 slidge/plugins/whatsapp/event.go
create mode 100644 slidge/plugins/whatsapp/gateway.go
create mode 100644 slidge/plugins/whatsapp/go.mod
create mode 100644 slidge/plugins/whatsapp/go.sum
create mode 100644 slidge/plugins/whatsapp/session.go
create mode 100644 watcher.py
diff --git a/.gitignore b/.gitignore
index a96b8d1..74f2cf9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,5 @@ persistent/
.idea
.mypy_cache
.old
-requirements* \ No newline at end of file
+requirements*
+slidge/plugins/whatsapp/generated
diff --git a/Dockerfile b/Dockerfile
index 4b664a9..46531be 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -42,6 +42,20 @@ RUN ls -la /tmp/tdlib/lib
FROM scratch AS tdlib
COPY --from=builder-tdlib /tmp/tdlib/lib /
+FROM docker.io/library/python:3.9-slim AS builder-whatsapp
+ENV PATH /root/go/bin:$PATH
+
+RUN echo "deb http://deb.debian.org/debian bullseye-backports main" > /etc/apt/sources.list.d/backports.list && \
+ apt update -y && apt install -yt bullseye-backports golang
+
+RUN go install github.com/go-python/gopy@latest && \
+ go install golang.org/x/tools/cmd/goimports@latest
+
+WORKDIR /build
+COPY slidge/plugins/whatsapp /build
+
+RUN python -m pip install pybindgen
+RUN cd /build && gopy build -output=generated -no-make=true .
FROM docker.io/library/python:3.9-slim AS builder
@@ -65,7 +79,7 @@ COPY poetry.lock pyproject.toml ./
RUN poetry export > r-base.txt
RUN --mount=type=cache,id=slidge-pip-cache,target=/root/.cache/pip \
pip install -r r-base.txt
-ARG PLUGIN="facebook signal telegram skype mattermost steam discord"
+ARG PLUGIN="facebook signal telegram skype mattermost steam discord whatsapp"
RUN poetry export --extras "$PLUGIN" > r.txt || true
RUN --mount=type=cache,id=slidge-pip-cache,target=/root/.cache/pip \
pip install -r r.txt || true
@@ -110,14 +124,23 @@ ENV SLIDGE_LEGACY_MODULE=slidge.plugins.$PLUGIN
COPY --from=builder /venv /venv
COPY ./slidge /venv/lib/python3.9/site-packages/slidge
+COPY --from=builder-whatsapp /build/generated /venv/lib/python3.9/site-packages/slidge/plugins/whatsapp/generated
ENTRYPOINT ["python", "-m", "slidge"]
FROM slidge AS slidge-dev
ARG TARGETPLATFORM
+ENV PATH /root/go/bin:$PATH
+
+RUN apt update -y && apt install -y libc++1 wget
+
+RUN echo "deb http://deb.debian.org/debian bullseye-backports main" > /etc/apt/sources.list.d/backports.list && \
+ apt update -y && apt install -yt bullseye-backports golang
RUN apt update && apt install libc++1 wget -y
+RUN go install github.com/go-python/gopy@latest && \
+ go install golang.org/x/tools/cmd/goimports@latest
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
cd /venv/lib/python3.9/site-packages/aiotdlib/tdlib/ && \
@@ -126,13 +149,12 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
fi
RUN --mount=type=cache,id=slidge-slidge-dev,target=/root/.cache/pip \
- pip install watchdog[watchmedo]
+ pip install watchdog[watchmedo] pybindgen
COPY --from=prosody /etc/prosody/certs/localhost.crt /usr/local/share/ca-certificates/
RUN update-ca-certificates
COPY ./assets /venv/lib/python3.9/site-packages/assets
+COPY watcher.py /watcher.py
-ENTRYPOINT ["watchmedo", "auto-restart", \
- "--directory=/venv/lib/python3.9/site-packages/slidge", "--pattern=*.py", "-R", "--", \
- "python", "-m", "slidge"]
+ENTRYPOINT ["/venv/bin/python", "/watcher.py", "/venv/lib/python3.9/site-packages/slidge/"]
diff --git a/README.md b/README.md
index bd8af3c..baf6dc6 100644
--- a/README.md
+++ b/README.md
@@ -22,15 +22,16 @@ It's a work in progress, but it should make
It comes with a few plugins included, implementing at least basic direct messaging and often more "advanced"
instant messaging features:
-| | Presences[¹] | Typing[²] | Marks[³] | Upload[⁴] | Edit[⁵] | React[⁶] | Retract[⁷] | Reply[⁸] |
+| | Presences[¹] | Typing[²] | Marks[³] | Upload[⁴] | Edit[⁵] | React[⁶] | Retract[⁷] | Reply[⁸] |
|------------|--------------|-----------|----------|-----------|---------|----------|------------|----------|
-| Signal | N/A | ✅ | ✅ | ✅ | N/A | ✅ | ✅ | ✅ |
-| Telegram | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Discord | ❌ | ✅ | N/A | ✅ | ✅ | ~ | ✅ | ✅ |
-| Steam | ✅ | ✅ | N/A | ❌ | N/A | ~ | N/A | N/A |
-| Mattermost | ✅ | ✅ | ~ | ✅ | ✅ | ✅ | ✅ | ❌ |
-| Facebook | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
-| Skype | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ |
+| Signal | N/A | ✅ | ✅ | ✅ | N/A | ✅ | ✅ | ✅ |
+| Telegram | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Discord | ❌ | ✅ | N/A | ✅ | ✅ | ~ | ✅ | ✅ |
+| Steam | ✅ | ✅ | N/A | ❌ | N/A | ~ | N/A | N/A |
+| Mattermost | ✅ | ✅ | ~ | ✅ | ✅ | ✅ | ✅ | ❌ |
+| Facebook | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
+| Skype | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ✅ | ❌ |
+| WhatsApp | ✅ | ✅ | ✅ | ✅ | N/A | ✅ | ✅ | ✅ |
[¹]: https://xmpp.org/rfcs/rfc6121.html#presence
diff --git a/confs/prosody.cfg.lua b/confs/prosody.cfg.lua
index a4e1f4d..f9fc081 100644
--- a/confs/prosody.cfg.lua
+++ b/confs/prosody.cfg.lua
@@ -88,6 +88,10 @@ VirtualHost "localhost"
roster = "both";
message = "outgoing";
},
+ ["whatsapp.localhost"] = {
+ roster = "both";
+ message = "outgoing";
+ },
}
Component "muc.localhost" "muc"
@@ -129,6 +133,10 @@ Component "discord.localhost"
component_secret = "secret"
modules_enabled = {"privilege"}
+Component "whatsapp.localhost"
+ component_secret = "secret"
+ modules_enabled = {"privilege"}
+
Component "upload.localhost" "http_file_share"
http_host = "localhost"
diff --git a/docker-compose.yml b/docker-compose.yml
index cb15169..f55a3b3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -159,6 +159,24 @@ services:
- prosody
network_mode: "service:prosody"
+ slidge-whatsapp:
+ build:
+ context: .
+ target: slidge-dev
+ command:
+ - --config=/slidge-example.ini
+ - --legacy-module=slidge.plugins.whatsapp
+ - --jid=whatsapp.localhost
+ environment:
+ SLIDGE__WHATSAPP_SKIP_VERIFY_TLS: "true"
+ volumes:
+ - ./slidge:/venv/lib/python3.9/site-packages/slidge
+ - ./persistent:/var/lib/slidge
+ - ./confs/slidge-example.ini:/slidge-example.ini
+ depends_on:
+ - prosody
+ network_mode: "service:prosody"
+
signald:
image: signald/signald:0.23.0
volumes:
diff --git a/docs/source/user/plugins/whatsapp.rst b/docs/source/user/plugins/whatsapp.rst
new file mode 100644
index 0000000..af1c4c7
--- /dev/null
+++ b/docs/source/user/plugins/whatsapp.rst
@@ -0,0 +1,22 @@
+WhatsApp
+--------
+
+.. note::
+ Slidge uses WhatsApp's `Linked Devices <https://faq.whatsapp.com/378279804439436/>`_ feature,
+ which may require perioding re-linking against the official client. However, you can still not
+ use or even uninstall the official client between re-linking.
+
+Roster
+******
+
+Contact JIDs are of the form ``+<phone-number>@slidge-whatsapp.example.com``, where
+``<phone-number>`` is the contact's phone number in international format (e.g. ``+442087599036``.
+Contacts will be added to the roster as they engage with your account, and may not all appear at
+once as they exist in the official client.
+
+Presences
+*********
+
+Your contacts' presence will appear as either "online" when the contact is currently using the
+WhatsApp client, or "away" otherwise; their last interaction time will also be noted if you've
+chosen to share this in the privacy settings of the official client.
diff --git a/slidge/core/contact.py b/slidge/core/contact.py
index c7e4c97..c96be91 100644
--- a/slidge/core/contact.py
+++ b/slidge/core/contact.py
@@ -666,7 +666,7 @@ class LegacyContact(Generic[SessionType], metaclass=SubclassableOnce):
This synchronizes the outgoing message history on the XMPP side, using
:xep:`0356` to impersonate the XMPP user and send a message from the user to
- the contact. Thw XMPP server should in turn send carbons (:xep:`0280`) to online
+ the contact. The XMPP server should in turn send carbons (:xep:`0280`) to online
XMPP clients +/- write the message in server-side archives (:xep:`0313`),
depending on the user's and the server's archiving policy.
diff --git a/slidge/plugins/whatsapp/__init__.py b/slidge/plugins/whatsapp/__init__.py
new file mode 100644
index 0000000..0ac6684
--- /dev/null
+++ b/slidge/plugins/whatsapp/__init__.py
@@ -0,0 +1,435 @@
+"""
+WhatsApp gateway using the multi-device API.
+"""
+
+import logging
+
+from datetime import datetime
+from functools import wraps
+from os.path import basename
+from pathlib import Path
+from io import BytesIO
+from mimetypes import guess_type
+from typing import Any, Optional
+
+from slidge import *
+from slidge.plugins.whatsapp.generated import whatsapp, go
+
+
+REGISTRATION_INSTRUCTIONS = (
+ "Continue and scan the resulting QR codes on your main device to complete registration. "
+ "More information at https://slidge.readthedocs.io/en/latest/user/plugins/whatsapp.html"
+)
+
+WELCOME_MESSAGE = (
+ "Thank you for registering! Please scan the following QR code on your main device to complete "
+ "registration, or type 'help' to list other available commands."
+)
+
+MESSAGE_PAIR_SUCCESS = (
+ "Pairing successful! You might need to repeat this process in the future if the Linked Device is "
+ "re-registered from your main device."
+)
+
+MESSAGE_LOGGED_OUT = "You have been logged out, please re-scan the QR code on your main device to log in."
+
+
+class Config:
+ """
+ Config contains plugin-specific configuration for WhatsApp, and is loaded automatically by the
+ core configuration framework.
+ """
+
+ DB_PATH = global_config.HOME_DIR / "whatsapp" / "whatsapp.db"
+ DB_PATH__DOC = "The path to the database used for the WhatsApp plugin."
+
+ ALWAYS_SYNC_ROSTER = True
+ ALWAYS_SYNC_ROSTER__DOC = (
+ "Whether or not to perform a full sync of the WhatsApp roster on startup."
+ )
+
+ SKIP_VERIFY_TLS = False
+ SKIP_VERIFY_TLS__DOC = "Whether or not HTTPS connections made by this plugin should verify TLS certificates."
+
+
+class Gateway(BaseGateway):
+ COMPONENT_NAME = "WhatsApp (slidge)"
+ COMPONENT_TYPE = "whatsapp"
+ COMPONENT_AVATAR = "https://www.whatsapp.com/apple-touch-icon..png"
+ REGISTRATION_INSTRUCTIONS = REGISTRATION_INSTRUCTIONS
+ WELCOME_MESSAGE = WELCOME_MESSAGE
+ REGISTRATION_FIELDS = []
+ ROSTER_GROUP = "WhatsApp"
+
+ def __init__(self):
+ super().__init__()
+ self.use_origin_id = True
+ Path(Config.DB_PATH.parent).mkdir(exist_ok=True)
+ self.whatsapp = whatsapp.NewGateway()
+ self.whatsapp.SetLogHandler(handle_log)
+ self.whatsapp.DBPath = str(Config.DB_PATH)
+ self.whatsapp.SkipVerifyTLS = Config.SKIP_VERIFY_TLS
+ self.whatsapp.Name = "Slidge on " + str(global_config.JID)
+ self.whatsapp.Init()
+
+ def shutdown(self):
+ for user in user_store.get_all():
+ session = self.session_cls.from_jid(user.jid)
+ for c in session.contacts:
+ c.offline()
+ self.loop.create_task(session.disconnect())
+ self.send_presence(ptype="unavailable", pto=user.jid)
+
+ async def unregister(self, user: GatewayUser):
+ self.whatsapp.DestroySession(
+ whatsapp.LinkedDevice(ID=user.registration_form.get("device_id", ""))
+ )
+
+
+class Contact(LegacyContact):
+ # WhatsApp only allows message editing in Beta versions of their app, and support is uncertain.
+ CORRECTION = False
+
+ def update_presence(self, away: bool, last_seen_timestamp: int):
+ last_seen = (
+ datetime.fromtimestamp(last_seen_timestamp)
+ if last_seen_timestamp > 0
+ else None
+ )
+ if away:
+ self.away(last_seen=last_seen)
+ else:
+ self.online(last_seen=last_seen)
+
+
+class Roster(LegacyRoster):
+ @staticmethod
+ def legacy_id_to_jid_username(legacy_id: str) -> str:
+ return "+" + legacy_id[: legacy_id.find("@")]
+
+ @staticmethod
+ async def jid_username_to_legacy_id(jid_username: str) -> str:
+ return jid_username.removeprefix("+") + "@" + whatsapp.DefaultUserServer
+
+
+class Session(BaseSession[Contact, Roster, Gateway]):
+ def __init__(self, user: GatewayUser):
+ super().__init__(user)
+ self.whatsapp = self.xmpp.whatsapp.Session(
+ whatsapp.LinkedDevice(ID=self.user.registration_form.get("device_id", ""))
+ )
+ self._handle_event = make_sync(self.handle_event, self.xmpp.loop)
+ self.whatsapp.SetEventHandler(self._handle_event)
+
+ async def login(self):
+ """
+ Initiate login process and connect session to WhatsApp. Depending on existing state, login
+ might either return having initiated the Linked Device registration process in the background,
+ or will re-connect to a previously existing Linked Device session.
+ """
+ self.whatsapp.Login()
+
+ async def logout(self):
+ """
+ Logout from the active WhatsApp session. This will also force a remote log-out, and thus
+ require pairing on next login. For simply disconnecting the active session, look at the
+ :meth:`.Session.disconnect` function.
+ """
+ self.whatsapp.Logout()
+
+ async def disconnect(self):
+ """
+ Disconnect the active WhatsApp session. This will not remove any local or remote state, and
+ will thus allow previously authenticated sessions to re-authenticate without needing to pair.
+ """
+ self.whatsapp.Disconnect()
+
+ async def handle_event(self, event, ptr):
+ """
+ Handle incoming event, as propagated by the WhatsApp adapter. Typically, events carry all
+ state required for processing by the Gateway itself, and will do minimal processing themselves.
+ """
+ data = whatsapp.EventPayload(handle=ptr)
+ if event == whatsapp.EventQRCode:
+ self.send_gateway_status("QR Scan Needed", show="dnd")
+ await self.send_qr(data.QRCode)
+ elif event == whatsapp.EventPairSuccess:
+ self.send_gateway_message(MESSAGE_PAIR_SUCCESS)
+ self.user.registration_form["device_id"] = data.PairDeviceID
+ user_store.add(self.user.jid, self.user.registration_form)
+ self.whatsapp.FetchRoster(refresh=True)
+ elif event == whatsapp.EventConnected:
+ self.send_gateway_status("Logged in")
+ self.whatsapp.FetchRoster(refresh=Config.ALWAYS_SYNC_ROSTER)
+ elif event == whatsapp.EventLoggedOut:
+ self.send_gateway_message(MESSAGE_LOGGED_OUT)
+ self.send_gateway_status("Logged out", show="away")
+ await self.login()
+ elif event == whatsapp.EventContactSync:
+ contact = self.contacts.by_legacy_id(data.Contact.JID)
+ contact.name = data.Contact.Name
+ if data.Contact.AvatarURL != "":
+ contact.avatar = data.Contact.AvatarURL
+ await contact.add_to_roster()
+ elif event == whatsapp.EventPresence:
+ self.contacts.by_legacy_id(data.Presence.JID).update_presence(
+ data.Presence.Away, data.Presence.LastSeen
+ )
+ elif event == whatsapp.EventChatState:
+ contact = self.contacts.by_legacy_id(data.ChatState.JID)
+ if data.ChatState.Kind == whatsapp.ChatStateComposing:
+ contact.composing()
+ elif data.ChatState.Kind == whatsapp.ChatStatePaused:
+ contact.paused()
+ elif event == whatsapp.EventReceipt:
+ await self.handle_receipt(data.Receipt)
+ elif event == whatsapp.EventMessage:
+ await self.handle_message(data.Message)
+
+ async def handle_receipt(self, receipt: whatsapp.Receipt):
+ """
+ Handle incoming delivered/read receipt, as propagated by the WhatsApp adapter.
+ """
+ contact = self.contacts.by_legacy_id(receipt.JID)
+ for message_id in receipt.MessageIDs:
+ if receipt.IsCarbon:
+ message_timestamp = datetime.fromtimestamp(receipt.Timestamp)
+ contact.carbon_read(legacy_msg_id=message_id, when=message_timestamp)
+ elif receipt.Kind == whatsapp.ReceiptDelivered:
+ contact.received(message_id)
+ elif receipt.Kind == whatsapp.ReceiptRead:
+ contact.displayed(message_id)
+
+ async def handle_message(self, message: whatsapp.Message):
+ """
+ Handle incoming message, as propagated by the WhatsApp adapter. Messages can be one of many
+ types, including plain-text messages, media messages, reactions, etc., and may also include
+ other aspects such as references to other messages for the purposes of quoting or correction.
+ """
+ contact = self.contacts.by_legacy_id(message.JID)
+ message_reply_id = message.ReplyID if message.ReplyID != "" else None
+ message_reply_body = message.ReplyBody if message.ReplyBody != "" else None
+ message_timestamp = (
+ datetime.fromtimestamp(message.Timestamp) if message.Timestamp > 0 else None
+ )
+ if message.IsCarbon:
+ if message.Kind == whatsapp.MessagePlain:
+ contact.carbon(
+ body=message.Body,
+ legacy_id=message.ID,
+ when=message_timestamp,
+ reply_to_msg_id=message_reply_id,
+ reply_to_fallback_text=message_reply_body,
+ )
+ elif message.Kind == whatsapp.MessageAttachment:
+ for ptr in message.Attachments:
+ attachment = whatsapp.Attachment(handle=ptr)
+ attachment_caption = (
+ attachment.Caption if attachment.Caption != "" else None
+ )
+ await contact.carbon_upload(
+ filename=attachment.Filename,
+ content_type=attachment.MIME,
+ input_file=BytesIO(initial_bytes=bytes(attachment.Data)),
+ legacy_id=message.ID,
+ reply_to_msg_id=message_reply_id,
+ when=message_timestamp,
+ )
+ elif message.Kind == whatsapp.MessageRevoke:
+ contact.carbon_retract(legacy_msg_id=message.ID, when=message_timestamp)
+ elif message.Kind == whatsapp.MessageReaction:
+ contact.carbon_react(
+ legacy_msg_id=message.ID,
+ reactions=message.Body,
+ when=message_timestamp,
+ )
+ elif message.Kind == whatsapp.MessagePlain:
+ contact.send_text(
+ body=message.Body,
+ legacy_msg_id=message.ID,
+ when=message_timestamp,
+ reply_to_msg_id=message_reply_id,
+ reply_to_fallback_text=message_reply_body,
+ )
+ elif message.Kind == whatsapp.MessageAttachment:
+ for ptr in message.Attachments:
+ attachment = whatsapp.Attachment(handle=ptr)
+ attachment_caption = (
+ attachment.Caption if attachment.Caption != "" else None
+ )
+ await contact.send_file(
+ filename=attachment.Filename,
+ content_type=attachment.MIME,
+ input_file=BytesIO(initial_bytes=bytes(attachment.Data)),
+ legacy_msg_id=message.ID,
+ reply_to_msg_id=message_reply_id,
+ when=message_timestamp,
+ caption=attachment_caption,
+ )
+ elif message.Kind == whatsapp.MessageRevoke:
+ contact.retract(message.ID)
+ elif message.Kind == whatsapp.MessageReaction:
+ contact.react(legacy_msg_id=message.ID, emojis=message.Body)
+
+ async def send_text(
+ self,
+ t: str,
+ c: LegacyContact,
+ *,
+ reply_to_msg_id: Optional[str] = None,
+ reply_to_fallback_text: Optional[str] = None,
+ ):
+ """
+ Send outgoing plain-text message to given WhatsApp contact.
+ """
+ message_id = whatsapp.GenerateMessageID()
+ message = whatsapp.Message(ID=message_id, JID=c.legacy_id, Body=t)
+ if reply_to_msg_id is not None:
+ message.ReplyID = reply_to_msg_id
+ if reply_to_fallback_text is not None:
+ message.ReplyBody = strip_quote_prefix(reply_to_fallback_text)
+ message.Body = message.Body.lstrip()
+ self.whatsapp.SendMessage(message)
+ return message_id
+
+ async def send_file(
+ self,
+ u: str,
+ c: LegacyContact,
+ *,
+ reply_to_msg_id: Optional[str] = None,
+ ):
+ """
+ Send outgoing media message (i.e. audio, image, document) to given WhatsApp contact.
+ """
+ message_id = whatsapp.GenerateMessageID()
+ message_attachment = whatsapp.Attachment(
+ MIME=guess_type(u)[0], Filename=basename(u), URL=u
+ )
+ self.whatsapp.SendMessage(
+ whatsapp.Message(
+ Kind=whatsapp.MessageAttachment,
+ ID=message_id,
+ JID=c.legacy_id,
+ ReplyID=reply_to_msg_id if reply_to_msg_id is not None else "",
+ Attachments=whatsapp.Slice_whatsapp_Attachment([message_attachment]),
+ )
+ )
+ return message_id
+
+ async def active(self, c: LegacyContact):
+ """
+ WhatsApp has no equivalent to the "active" chat state, so calls to this function are no-ops.
+ """
+ pass
+
+ async def inactive(self, c: LegacyContact):
+ """
+ WhatsApp has no equivalent to the "inactive" chat state, so calls to this function are no-ops.
+ """
+ pass
+
+ async def composing(self, c: LegacyContact):
+ """
+ Send "composing" chat state to given WhatsApp contact, signifying that a message is currently
+ being composed.
+ """
+ self.whatsapp.SendChatState(
+ whatsapp.ChatState(JID=c.legacy_id, Kind=whatsapp.ChatStateComposing)
+ )
+
+ async def paused(self, c: LegacyContact):
+ """
+ Send "paused" chat state to given WhatsApp contact, signifying that an (unsent) message is no
+ longer being composed.
+ """
+ self.whatsapp.SendChatState(
+ whatsapp.ChatState(JID=c.legacy_id, Kind=whatsapp.ChatStatePaused)
+ )
+
+ async def displayed(self, legacy_msg_id: Any, c: LegacyContact):
+ """
+ Send "read" receipt, signifying that the WhatsApp message sent has been displayed on the XMPP
+ client.
+ """
+ self.whatsapp.SendReceipt(
+ whatsapp.Receipt(
+ MessageIDs=go.Slice_string([legacy_msg_id]), JID=c.legacy_id
+ )
+ )
+
+ async def react(self, legacy_msg_id: Any, emojis: list[str], c: LegacyContact):
+ """
+ Send or remove emoji reaction to existing WhatsApp message. Noted that WhatsApp places
+ restrictions on the number of emoji reactions a user can place on any given message; these
+ restrictions are currently not observed by this function.
+ """
+ for emoji in emojis if len(emojis) > 0 else [""]:
+ self.whatsapp.SendMessage(
+ whatsapp.Message(
+ Kind=whatsapp.MessageReaction,
+ ID=legacy_msg_id,
+ JID=c.legacy_id,
+ Body=emoji,
+ IsCarbon=legacy_msg_id in self.sent,
+ )
+ )
+
+ async def retract(self, legacy_msg_id: Any, c: LegacyContact):
+ """
+ Request deletion (aka retraction) for a given WhatsApp message.
+ """
+ self.whatsapp.SendMessage(
+ whatsapp.Message(
+ Kind=whatsapp.MessageRevoke, ID=legacy_msg_id, JID=c.legacy_id
+ )
+ )
+
+ async def correct(self, text: str, legacy_msg_id: Any, c: LegacyContact):
+ self.send_gateway_message(
+ "Warning: WhatsApp does not support message editing at this point in time."
+ )
+
+ async def search(self, form_values: dict[str, str]):
+ self.send_gateway_message("Searching on WhatsApp has not been implemented yet.")
+
+
+def make_sync(func, loop):
+ """
+ Wrap async function in synchronous operation, running against the given loop in thread-safe mode.
+ """
+ import asyncio
+
+ @wraps(func)
+ def wrapper(*args, **kwargs):
+ result = func(*args, **kwargs)
+ if asyncio.iscoroutine(result):
+ future = asyncio.run_coroutine_threadsafe(result, loop)
+ return future.result()
+ return result
+
+ return wrapper
+
+
+def strip_quote_prefix(text: str):
+ """
+ Return multi-line text without leading quote marks (i.e. the ">" character).
+ """
+ return "\n".join(x.lstrip(">").strip() for x in text.split("\n")).strip()
+
+
+def handle_log(level, msg: str):
+ """
+ Log given message of specified level in system-wide logger.
+ """
+ if level == whatsapp.LevelError:
+ log.error(msg)
+ elif level == whatsapp.LevelWarning:
+ log.warning(msg)
+ elif level == whatsapp.LevelDebug:
+ log.debug(msg)
+ else:
+ log.info(msg)
+
+
+log = logging.getLogger(__name__)
diff --git a/slidge/plugins/whatsapp/event.go b/slidge/plugins/whatsapp/event.go
new file mode 100644
index 0000000..46dcd83
--- /dev/null
+++ b/slidge/plugins/whatsapp/event.go
@@ -0,0 +1,427 @@
+package whatsapp
+
+import (
+ // Standard library.
+ "context"
+ "fmt"
+ "mime"
+
+ // Third-party libraries.
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/binary/proto"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+// EventKind represents all event types recognized by the Python session adapter, as emitted by the
+// Go session adapter.
+type EventKind int
+
+// The event types handled by the overarching session adapter handler.
+const (
+ EventUnknown EventKind = iota
+ EventQRCode
+ EventPairSuccess
+ EventConnected
+ EventLoggedOut
+ EventContactSync
+ EventPresence
+ EventMessage
+ EventChatState
+ EventReceipt
+)
+
+// EventPayload represents the collected payloads for all event types handled by the overarching
+// session adapter handler. Only specific fields will be populated in events emitted by internal
+// handlers, see documentation for specific types for more information.
+type EventPayload struct {
+ QRCode string
+ PairDeviceID string
+ Contact Contact
+ Presence Presence
+ Message Message
+ ChatState ChatState
+ Receipt Receipt
+}
+
+// A Contact represents any entity that be communicated with directly in WhatsApp. This typically
+// represents people, but may represent a business or bot as well, but not a group-chat.
+type Contact struct {
+ JID string
+ Name string
+ AvatarURL string
+}
+
+// NewContactSyncEvent returns event data meant for [Session.propagateEvent] for the contact information
+// given. Unknown or invalid contact information will return an [EventUnknown] event with nil data.
+func newContactSyncEvent(c *whatsmeow.Client, jid types.JID, info types.ContactInfo) (EventKind, *EventPayload) {
+ var contact = Contact{
+ JID: jid.ToNonAD().String(),
+ }
+
+ for _, n := range []string{info.FullName, info.FirstName, info.BusinessName, info.PushName} {
+ if n != "" {
+ contact.Name = n
+ break
+ }
+ }
+
+ // Don't attempt to synchronize contacts with no user-readable name.
+ if contact.Name == "" {
+ return EventUnknown, nil
+ }
+
+ if p, _ := c.GetProfilePictureInfo(jid, false, ""); p != nil {
+ contact.AvatarURL = p.URL
+ }
+
+ return EventContactSync, &EventPayload{Contact: contact}
+}
+
+// Precence represents a contact's general state of activity, and is periodically updated as
+// contacts start or stop paying attention to their client of choice.
+type Presence struct {
+ JID string
+ Away bool
+ LastSeen int64
+}
+
+// NewPresenceEvent returns event data meant for [Session.propagateEvent] for the primitive presence
+// event given.
+func newPresenceEvent(evt *events.Presence) (EventKind, *EventPayload) {
+ return EventPresence, &EventPayload{Presence: Presence{
+ JID: evt.From.ToNonAD().String(),
+ Away: evt.Unavailable,
+ LastSeen: evt.LastSeen.Unix(),
+ }}
+}
+
+// MessageKind represents all concrete message types (plain-text messages, edit messages, reactions)
+// recognized by the Python session adapter.
+type MessageKind int
+
+// The message types handled by the overarching session event handler.
+const (
+ MessagePlain MessageKind = 1 + iota
+ MessageRevoke
+ MessageReaction
+ MessageAttachment
+)
+
+// A Message represents one of many kinds of bidirectional communication payloads, for example, a
+// text message, a file (image, video) attachment, an emoji reaction, etc. Messages of different
+// kinds are denoted as such, and re-use fields where the semantics overlap.
+type Message struct {
+ Kind MessageKind // The concrete message kind being sent or received.
+ ID string // The unique message ID, used for referring to a specific Message instance.
+ JID string // The JID this message concerns, semantics can change based on IsCarbon.
+ Body string // The plain-text message body. For attachment messages, this can be a caption.
+ Timestamp int64 // The Unix timestamp denoting when this message was created.
+ IsCarbon bool // Whether or not this message concerns the gateway user themselves.
+ ReplyID string // The unique message ID this message is in reply to, if any.
+ ReplyBody string // The full body of the message this message is in reply to, if any.
+ Attachments []Attachment // The list of file (image, video, etc.) attachments contained in this message.
+}
+
+// A Attachment represents additional binary data (e.g. images, videos, documents) provided alongside
+// a message, for display or storage on the recepient client.
+type Attachment struct {
+ MIME string // The MIME type for attachment.
+ Filename string // The recommended file name for this attachment. May be an auto-generated name.
+ Caption string // The user-provided caption, provided alongside this attachment.
+ Data []byte // The raw binary data for this attachment. Mutually exclusive with [.URL].
+ URL string // The URL to download attachment data from. Mutually exclusive with [.Data].
+}
+
+// GenerateMessageID returns a valid, pseudo-random message ID for use in outgoing messages. This
+// function will panic if there is no entropy available for random ID generation.
+func GenerateMessageID() string {
+ return whatsmeow.GenerateMessageID()
+}
+
+// NewMessageEvent returns event data meant for [Session.propagateEvent] for the primive message
+// event given. Unknown or invalid messages will return an [EventUnknown] event with nil data.
+func newMessageEvent(client *whatsmeow.Client, evt *events.Message) (EventKind, *EventPayload) {
+ // Ignore incoming messages sent or received over group-chats until proper support is implemented.
+ if evt.Info.IsGroup {
+ return EventUnknown, nil
+ }
+
+ // Set basic data for message, to be potentially amended depending on the concrete version of
+ // the underlying message.
+ var message = Message{
+ Kind: MessagePlain,
+ ID: evt.Info.ID,
+ Body: evt.Message.GetConversation(),
+ Timestamp: evt.Info.Timestamp.Unix(),
+ IsCarbon: evt.Info.IsFromMe,
+ }
+
+ // Set message JID based on whether a message is originating from ourselves or someone else.
+ if message.IsCarbon {
+ message.JID = evt.Info.MessageSource.Chat.ToNonAD().String()
+ } else {
+ message.JID = evt.Info.MessageSource.Sender.ToNonAD().String()
+ }
+
+ // Handle handle protocol messages (such as message deletion or editing).
+ if p := evt.Message.GetProtocolMessage(); p != nil {
+ switch p.GetType() {
+ case proto.ProtocolMessage_REVOKE:
+ message.Kind = MessageRevoke
+ message.ID = p.Key.GetId()
+ return EventMessage, &EventPayload{Message: message}
+ }
+ }
+
+ // Handle emoji reaction to existing message.
+ if r := evt.Message.GetReactionMessage(); r != nil {
+ message.Kind = MessageReaction
+ message.ID = r.Key.GetId()
+ message.Body = r.GetText()
+ return EventMessage, &EventPayload{Message: message}
+ }
+
+ // Handle message attachments, if any.
+ if attach, err := getMessageAttachments(client, evt.Message); err != nil {
+ client.Log.Errorf("Failed getting message attachments: %s", err)
+ return EventUnknown, nil
+ } else if len(attach) > 0 {
+ message.Attachments = append(message.Attachments, attach...)
+ message.Kind = MessageAttachment
+ }
+
+ // Get extended information from message, if available. Extended messages typically represent
+ // messages with additional context, such as replies, forwards, etc.
+ if e := evt.Message.GetExtendedTextMessage(); e != nil {
+ if message.Body == "" {
+ message.Body = e.GetText()
+ }
+ if c := e.GetContextInfo(); c != nil {
+ message.ReplyID = c.GetStanzaId()
+ if q := c.GetQuotedMessage(); q != nil {
+ message.ReplyBody = q.GetConversation()
+ }
+ }
+ }
+
+ // Ignore obviously invalid messages.
+ if message.Kind == MessagePlain && message.Body == "" {
+ return EventUnknown, nil
+ }
+
+ return EventMessage, &EventPayload{Message: message}
+}
+
+// GetMessageAttachments fetches and decrypts attachments (images, audio, video, or documents) sent
+// via WhatsApp. Any failures in retrieving any attachment will return an error immediately.
+func getMessageAttachments(client *whatsmeow.Client, message *proto.Message) ([]Attachment, error) {
+ var result []Attachment
+ var kinds = []whatsmeow.DownloadableMessage{
+ message.GetImageMessage(),
+ message.GetAudioMessage(),
+ message.GetVideoMessage(),
+ message.GetDocumentMessage(),
+ }
+
+ for _, msg := range kinds {
+ // Handle data for specific attachment type.
+ var a Attachment
+ switch msg := msg.(type) {
+ case *proto.ImageMessage:
+ a.MIME, a.Caption = msg.GetMimetype(), msg.GetCaption()
+ case *proto.AudioMessage:
+ a.MIME = msg.GetMimetype()
+ case *proto.VideoMessage:
+ a.MIME, a.Caption = msg.GetMimetype(), msg.GetCaption()
+ case *proto.DocumentMessage:
+ a.MIME, a.Caption, a.Filename = msg.GetMimetype(), msg.GetCaption(), msg.GetFileName()
+ }
+
+ // Ignore attachments with empty or unknown MIME types.
+ if a.MIME == "" {
+ continue
+ }
+
+ // Set filename from SHA256 checksum and MIME type, if none is already set.
+ if a.Filename == "" {
+ a.Filename = fmt.Sprintf("%x%s", msg.GetFileSha256(), extensionByType(a.MIME))
+ }
+
+ // Attempt to download and decrypt raw attachment data, if any.
+ data, err := client.Download(msg)
+ if err != nil {
+ return nil, err
+ }
+
+ a.Data = data
+ result = append(result, a)
+ }
+
+ return result, nil
+}
+
+// KnownMediaTypes represents MIME type to WhatsApp media types known to be handled by WhatsApp in a
+// special way (that is, not as generic file uploads).
+var knownMediaTypes = map[string]whatsmeow.MediaType{
+ "image/jpeg": whatsmeow.MediaImage,
+ "audio/ogg": whatsmeow.MediaAudio,
+ "application/ogg": whatsmeow.MediaAudio,
+ "video/mp4": whatsmeow.MediaVideo,
+}
+
+// UploadAttachment attempts to push the given attachment data to WhatsApp according to the MIME type
+// specified within. Attachments are handled as generic file uploads unless they're of a specific
+// format, see [knownMediaTypes] for more information.
+func uploadAttachment(client *whatsmeow.Client, attach Attachment) (*proto.Message, error) {
+ mediaType := knownMediaTypes[attach.MIME]
+ if mediaType == "" {
+ mediaType = whatsmeow.MediaDocument
+ }
+
+ upload, err := client.Upload(context.Background(), attach.Data, mediaType)
+ if err != nil {
+ return nil, err
+ }
+
+ var message *proto.Message
+ switch mediaType {
+ case whatsmeow.MediaImage:
+ message = &proto.Message{
+ ImageMessage: &proto.ImageMessage{
+ Url: &upload.URL,
+ DirectPath: &upload.DirectPath,
+ MediaKey: upload.MediaKey,
+ Mimetype: &attach.MIME,
+ FileEncSha256: upload.FileEncSHA256,
+ FileSha256: upload.FileSHA256,
+ FileLength: ptrTo(uint64(len(attach.Data))),
+ },
+ }
+ case whatsmeow.MediaAudio:
+ message = &proto.Message{
+ AudioMessage: &proto.AudioMessage{
+ Url: &upload.URL,
+ DirectPath: &upload.DirectPath,
+ MediaKey: upload.MediaKey,
+ Mimetype: &attach.MIME,
+ FileEncSha256: upload.FileEncSHA256,
+ FileSha256: upload.FileSHA256,
+ FileLength: ptrTo(uint64(len(attach.Data))),
+ },
+ }
+ case whatsmeow.MediaVideo:
+ message = &proto.Message{
+ VideoMessage: &proto.VideoMessage{
+ Url: &upload.URL,
+ DirectPath: &upload.DirectPath,
+ MediaKey: upload.MediaKey,
+ Mimetype: &attach.MIME,
+ FileEncSha256: upload.FileEncSHA256,
+ FileSha256: upload.FileSHA256,
+ FileLength: ptrTo(uint64(len(attach.Data))),
+ }}
+ case whatsmeow.MediaDocument:
+ message = &proto.Message{
+ DocumentMessage: &proto.DocumentMessage{
+ Url: &upload.URL,
+ DirectPath: &upload.DirectPath,
+ MediaKey: upload.MediaKey,
+ Mimetype: &attach.MIME,
+ FileEncSha256: upload.FileEncSHA256,
+ FileSha256: upload.FileSHA256,
+ FileLength: ptrTo(uint64(len(attach.Data))),
+ FileName: &attach.Filename,
+ }}
+ }
+
+ return message, nil
+}
+
+// ExtensionByType returns the file extension for the given MIME type, or a generic extension if the
+// MIME type is unknown.
+func extensionByType(typ string) string {
+ if ext, _ := mime.ExtensionsByType(typ); len(ext) > 0 {
+ return ext[0]
+ }
+ return ".bin"
+}
+
+// ChatStateKind represents the different kinds of chat-states possible in WhatsApp.
+type ChatStateKind int
+
+// The chat states handled by the overarching session event handler.
+const (
+ ChatStateComposing ChatStateKind = 1 + iota
+ ChatStatePaused
+)
+
+// A ChatState represents the activity of a contact within a certain discussion, for instance,
+// whether the contact is currently composing a message. This is separate to the concept of a
+// Presence, which is the contact's general state across all discussions.
+type ChatState struct {
+ JID string
+ Kind ChatStateKind
+}
+
+// NewChatStateEvent returns event data meant for [Session.propagateEvent] for the primitive
+// chat-state event given.
+func newChatStateEvent(evt *events.ChatPresence) (EventKind, *EventPayload) {
+ var state = ChatState{JID: evt.MessageSource.Sender.ToNonAD().String()}
+ switch evt.State {
+ case types.ChatPresenceComposing:
+ state.Kind = ChatStateComposing
+ case types.ChatPresencePaused:
+ state.Kind = ChatStatePaused
+ }
+ return EventChatState, &EventPayload{ChatState: state}
+}
+
+// ReceiptKind represents the different types of delivery receipts possible in WhatsApp.
+type ReceiptKind int
+
+// The delivery receipts handled by the overarching session event handler.
+const (
+ ReceiptDelivered ReceiptKind = 1 + iota
+ ReceiptRead
+)
+
+// A Receipt represents a notice of delivery or presentation for [Message] instances sent or
+// received. Receipts can be delivered for many messages at once, but are generally all delivered
+// under one specific state at a time.
+type Receipt struct {
+ Kind ReceiptKind
+ MessageIDs []string
+ JID string
+ Timestamp int64
+ IsCarbon bool
+}
+
+// NewReceiptEvent returns event data meant for [Session.propagateEvent] for the primive receipt
+// event given. Unknown or invalid receipts will return an [EventUnknown] event with nil data.
+func newReceiptEvent(evt *events.Receipt) (EventKind, *EventPayload) {
+ var receipt = Receipt{
+ MessageIDs: append([]string{}, evt.MessageIDs...),
+ Timestamp: evt.Timestamp.Unix(),
+ IsCarbon: evt.MessageSource.IsFromMe,
+ }
+
+ if len(receipt.MessageIDs) == 0 {
+ return EventUnknown, nil
+ }
+
+ if receipt.IsCarbon {
+ receipt.JID = evt.MessageSource.Chat.ToNonAD().String()
+ } else {
+ receipt.JID = evt.MessageSource.Sender.ToNonAD().String()
+ }
+
+ switch evt.Type {
+ case events.ReceiptTypeDelivered:
+ receipt.Kind = ReceiptDelivered
+ case events.ReceiptTypeRead:
+ receipt.Kind = ReceiptRead
+ }
+
+ return EventReceipt, &EventPayload{Receipt: receipt}
+}
diff --git a/slidge/plugins/whatsapp/gateway.go b/slidge/plugins/whatsapp/gateway.go
new file mode 100644
index 0000000..a443fad
--- /dev/null
+++ b/slidge/plugins/whatsapp/gateway.go
@@ -0,0 +1,183 @@
+package whatsapp
+
+import (
+ // Standard library.
+ "crypto/tls"
+ "fmt"
+ "net/http"
+ "runtime"
+ "time"
+
+ // Third-party libraries.
+ _ "github.com/mattn/go-sqlite3"
+ "go.mau.fi/whatsmeow/store"
+ "go.mau.fi/whatsmeow/store/sqlstore"
+ "go.mau.fi/whatsmeow/types"
+ walog "go.mau.fi/whatsmeow/util/log"
+)
+
+// A LinkedDevice represents a unique pairing session between the gateway and WhatsApp. It is not
+// unique to the underlying "main" device (or phone number), as multiple linked devices may be paired
+// with any main device.
+type LinkedDevice struct {
+ // ID is an opaque string identifying this LinkedDevice to the Session. Noted that this string
+ // is currently equivalent to a password, and needs to be protected accordingly.
+ ID string
+}
+
+// JID returns the WhatsApp JID corresponding to the LinkedDevice ID. Empty or invalid device IDs
+// may return invalid JIDs, and this function does not handle errors.
+func (d LinkedDevice) JID() types.JID {
+ jid, _ := types.ParseJID(d.ID)
+ return jid
+}
+
+// A ErrorLevel is a value representing the severity of a log message being handled.
+type ErrorLevel int
+
+// The log levels handled by the overarching Session logger.
+const (
+ LevelError ErrorLevel = 1 + iota
+ LevelWarning
+ LevelInfo
+ LevelDebug
+)
+
+// HandleLogFunc is the signature for the overarching Gateway log handling function.
+type HandleLogFunc func(ErrorLevel, string)
+
+// Errorf handles the given message as representing a (typically) fatal error.
+func (h HandleLogFunc) Errorf(msg string, args ...interface{}) {
+ h(LevelError, fmt.Sprintf(msg, args...))
+}
+
+// Warn handles the given message as representing a non-fatal error or warning thereof.
+func (h HandleLogFunc) Warnf(msg string, args ...interface{}) {
+ h(LevelWarning, fmt.Sprintf(msg, args...))
+}
+
+// Infof handles the given message as representing an informational notice.
+func (h HandleLogFunc) Infof(msg string, args ...interface{}) {
+ h(LevelInfo, fmt.Sprintf(msg, args...))
+}
+
+// Debugf handles the given message as representing an internal-only debug message.
+func (h HandleLogFunc) Debugf(msg string, args ...interface{}) {
+ h(LevelDebug, fmt.Sprintf(msg, args...))
+}
+
+// Sub is a no-op and will return the receiver itself.
+func (h HandleLogFunc) Sub(string) walog.Logger {
+ return h
+}
+
+// A Gateway represents a persistent process for establishing individual sessions between linked
+// devices and WhatsApp.
+type Gateway struct {
+ DBPath string // The filesystem path for the client database.
+ Name string // The name to display when linking devices on WhatsApp.
+ SkipVerifyTLS bool // Whether or not our internal HTTP client will skip TLS certificate verification.
+
+ // Internal variables.
+ sessions map[LinkedDevice]*Session
+ container *sqlstore.Container
+ httpClient *http.Client
+ logger walog.Logger
+}
+
+// Session returns a new or existing Session for the LinkedDevice given. If the linked device does
+// not have a valid ID, a pair operation will be required, as described in [Session.Login].
+func (w *Gateway) Session(device LinkedDevice) *Session {
+ if _, ok := w.sessions[device]; !ok {
+ w.sessions[device] = &Session{
+ device: device,
+ gateway: w,
+ }
+ }
+ return w.sessions[device]
+}
+
+// DestroySession removes stored data for all sessions related to the given linked device. If given
+// an empty device ID, this function will not perform any processing.
+func (w *Gateway) DestroySession(device LinkedDevice) error {
+ if _, ok := w.sessions[device]; !ok {
+ w.logger.Infof("Destroying session with no attached device")
+ return nil
+ }
+
+ // Attempt to log out and clean up session, but don't fail the process entirely, as this may have
+ // already happened out-of-band.
+ if err := w.sessions[device].Logout(); err != nil {
+ w.logger.Warnf("Failed to logout when destroying session: %s", err)
+ } else if err = w.CleanupSession(device); err != nil {
+ w.logger.Warnf("Failed to clean up session: %s", err)
+ }
+
+ delete(w.sessions, device)
+ return nil
+}
+
+// CleanupSession will remove all invalid and obsolete references to the given device, and should be
+// used when pairing a new device or unregistering from the Gateway.
+func (w *Gateway) CleanupSession(device LinkedDevice) error {
+ devices, err := w.container.GetAllDevices()
+ if err != nil {
+ return err
+ }
+
+ for _, d := range devices {
+ if d.ID == nil {
+ w.logger.Infof("Removing invalid device %s from database", d.ID.String())
+ _ = d.Delete()
+ } else if device.ID != "" {
+ if jid := device.JID(); d.ID.ToNonAD() == jid.ToNonAD() && *d.ID != jid {
+ w.logger.Infof("Removing obsolete device %s from database", d.ID.String())
+ _ = d.Delete()
+ }
+ }
+ }
+
+ return nil
+}
+
+// Init performs initialization procedures for the Gateway, and is expected to be run before any
+// calls to [Gateway.Session].
+func (w *Gateway) Init() error {
+ container, err := sqlstore.New("sqlite3", w.DBPath, w.logger)
+ if err != nil {
+ return err
+ }
+
+ if w.Name != "" {
+ store.SetOSInfo(w.Name, [...]uint32{1, 0, 0})
+ }
+
+ // Set up shared HTTP client with less lenient timeouts.
+ w.httpClient = &http.Client{
+ Timeout: time.Second * 10,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: w.SkipVerifyTLS},
+ },
+ }
+
+ w.container = container
+ return nil
+}
+
+// SetLogHandler specifies the log handling function to use for all [Gateway] and [Session] operations.
+func (w *Gateway) SetLogHandler(h HandleLogFunc) {
+ w.logger = HandleLogFunc(func(level ErrorLevel, message string) {
+ // Don't allow other Goroutines from using this thread, as this might lead to concurrent
+ // use of the GIL, which can lead to crashes.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ h(level, message)
+ })
+}
+
+// NewGateway returns a new, un-initialized Gateway. This function should always be followed by calls
+// to [Gateway.Init], assuming a valid [Gateway.DBPath] is set.
+func NewGateway() *Gateway {
+ return &Gateway{sessions: make(map[LinkedDevice]*Session)}
+}
diff --git a/slidge/plugins/whatsapp/go.mod b/slidge/plugins/whatsapp/go.mod
new file mode 100644
index 0000000..c18aeea
--- /dev/null
+++ b/slidge/plugins/whatsapp/go.mod
@@ -0,0 +1,17 @@
+module git.sr.ht/~nicoco/slidge/slidge/plugins/whatsapp
+
+go 1.19
+
+require (
+ github.com/go-python/gopy v0.4.5
+ github.com/mattn/go-sqlite3 v1.14.16
+ go.mau.fi/libsignal v0.0.0-20221015105917-d970e7c3c9cf
+ go.mau.fi/whatsmeow v0.0.0-20221117205735-357841c4ff30
+)
+
+require (
+ filippo.io/edwards25519 v1.0.0 // indirect
+ github.com/gorilla/websocket v1.5.0 // indirect
+ golang.org/x/crypto v0.0.0-20221012134737-56aed061732a // indirect
+ google.golang.org/protobuf v1.28.1 // indirect
+)
diff --git a/slidge/plugins/whatsapp/go.sum b/slidge/plugins/whatsapp/go.sum
new file mode 100644
index 0000000..fff6b02
--- /dev/null
+++ b/slidge/plugins/whatsapp/go.sum
@@ -0,0 +1,22 @@
+filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek=
+filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
+github.com/go-python/gopy v0.4.5 h1:cwnd24UKA91vFFZoRvavb/vzBSKV99Gp3+2d5+jTG0U=
+github.com/go-python/gopy v0.4.5/go.mod h1:tlA/KcD7rM8B+NQJR4SASwiinfKY0aiMFanHszR8BZA=
+github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
+github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+go.mau.fi/libsignal v0.0.0-20221015105917-d970e7c3c9cf h1:mzPxXBgDPHKDHMVV1tIWh7lwCiRpzCsXC0gNRX+K07c=
+go.mau.fi/libsignal v0.0.0-20221015105917-d970e7c3c9cf/go.mod h1:XCjaU93vl71YNRPn059jMrK0xRDwVO5gKbxoPxow9mQ=
+go.mau.fi/whatsmeow v0.0.0-20221117205735-357841c4ff30 h1:AVHEkGJSI6NsdbxVs9YEGPFR68uru1Ke/hH72EqzGpk=
+go.mau.fi/whatsmeow v0.0.0-20221117205735-357841c4ff30/go.mod h1:2yweL8nczvtlIxkrvCb0y8xiO13rveX9lJPambwYV/E=
+golang.org/x/crypto v0.0.0-20221012134737-56aed061732a h1:NmSIgad6KjE6VvHciPZuNRTKxGhlPfD6OA87W/PLkqg=
+golang.org/x/crypto v0.0.0-20221012134737-56aed061732a/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
+google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
diff --git a/slidge/plugins/whatsapp/session.go b/slidge/plugins/whatsapp/session.go
new file mode 100644
index 0000000..d772f05
--- /dev/null
+++ b/slidge/plugins/whatsapp/session.go
@@ -0,0 +1,331 @@
+package whatsapp
+
+import (
+ // Standard library.
+ "context"
+ "fmt"
+ "io"
+ "runtime"
+ "time"
+
+ // Third-party libraries.
+ _ "github.com/mattn/go-sqlite3"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/appstate"
+ "go.mau.fi/whatsmeow/binary/proto"
+ "go.mau.fi/whatsmeow/store"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+const (
+ // The default host part for user JIDs on WhatsApp.
+ DefaultUserServer = types.DefaultUserServer
+)
+
+// HandleEventFunc represents a handler for incoming events sent to the Python Session, accepting an
+// event type and payload. Note that this is distinct to the [Session.handleEvent] function, which
+// may emit events into the Python Session event handler but which otherwise does not process across
+// Python/Go boundaries.
+type HandleEventFunc func(EventKind, *EventPayload)
+
+// A Session represents a connection (active or not) between a linked device and WhatsApp. Active
+// sessions need to be established by logging in, after which incoming events will be forwarded to
+// the adapter event handler, and outgoing events will be forwarded to WhatsApp.
+type Session struct {
+ device LinkedDevice // The linked device this session corresponds to.
+ eventHandler HandleEventFunc // The event handler for the overarching Session.
+ client *whatsmeow.Client // The concrete client connection to WhatsApp for this session.
+ gateway *Gateway // The Gateway this Session is attached to.
+}
+
+// Login attempts to authenticate the given [Session], either by re-using the [LinkedDevice] attached
+// or by initiating a pairing session for a new linked device. Callers are expected to have set an
+// event handler in order to receive any incoming events from the underlying WhatsApp session.
+func (s *Session) Login() error {
+ var err error
+ var store *store.Device
+
+ // Try to fetch existing device from given device JID.
+ if s.device.ID != "" {
+ store, err = s.gateway.container.GetDevice(s.device.JID())
+ if err != nil {
+ return err
+ }
+ }
+
+ if store == nil {
+ store = s.gateway.container.NewDevice()
+ }
+
+ s.client = whatsmeow.NewClient(store, s.gateway.logger)
+ s.client.AddEventHandler(s.handleEvent)
+
+ // Simply connect our client if already registered.
+ if s.client.Store.ID != nil {
+ return s.client.Connect()
+ }
+
+ // Attempt out-of-band registration of client via QR code.
+ qrChan, _ := s.client.GetQRChannel(context.Background())
+ if err = s.client.Connect(); err != nil {
+ return err
+ }
+
+ go func() {
+ for e := range qrChan {
+ if !s.client.IsConnected() {
+ return
+ }
+ switch e.Event {
+ case "code":
+ s.propagateEvent(EventQRCode, &EventPayload{QRCode: e.Code})
+ }
+ }
+ }()
+
+ return nil
+}
+
+// Logout disconnects and removes the current linked device locally and initiates a logout remotely.
+func (s *Session) Logout() error {
+ if s.client.Store.ID == nil {
+ return nil
+ }
+
+ return s.client.Logout()
+}
+
+// Disconnects detaches the current connection to WhatsApp without removing any linked device state.
+func (s *Session) Disconnect() error {
+ s.client.Disconnect()
+ return nil
+}
+
+// SendMessage processes the given Message and sends a WhatsApp message for the kind and contact JID
+// specified within. In general, different message kinds require different fields to be set; see the
+// documentation for the [Message] type for more information.
+func (s *Session) SendMessage(message Message) error {
+ jid, err := types.ParseJID(message.JID)
+ if err != nil {
+ return fmt.Errorf("Could not parse sender JID for message: %s", err)
+ }
+
+ var payload *proto.Message
+ var messageID string
+
+ switch message.Kind {
+ case MessageAttachment:
+ // Handle message with attachment, if any.
+ if len(message.Attachments) == 0 {
+ return nil
+ }
+
+ // Attempt to download attachment data if URL is set.
+ if url := message.Attachments[0].URL; url != "" {
+ if resp, err := s.gateway.httpClient.Get(url); err != nil {
+ return fmt.Errorf("Failed downloading attachment: %s", err)
+ } else if buf, err := io.ReadAll(resp.Body); err != nil {
+ return fmt.Errorf("Failed downloading attachment: %s", err)
+ } else {
+ resp.Body.Close()
+ message.Attachments[0].Data = buf
+ }
+ }
+
+ // Ignore attachments with no data set or downloaded.
+ if len(message.Attachments[0].Data) == 0 {
+ return nil
+ }
+
+ // Upload attachment into WhatsApp before sending message.
+ if payload, err = uploadAttachment(s.client, message.Attachments[0]); err != nil {
+ return fmt.Errorf("Failed uploading attachment: %s", err)
+ }
+ messageID = message.ID
+ case MessageRevoke:
+ // Don't send message, but revoke existing message by ID.
+ payload = s.client.BuildRevoke(s.device.JID().ToNonAD(), types.EmptyJID, message.ID)
+ case MessageReaction:
+ // Send message as emoji reaction to a given message.
+ payload = &proto.Message{
+ ReactionMessage: &proto.ReactionMessage{
+ Key: &proto.MessageKey{
+ RemoteJid: &message.JID,
+ FromMe: &message.IsCarbon,
+ Id: &message.ID,
+ },
+ Text: &message.Body,
+ SenderTimestampMs: ptrTo(time.Now().UnixMilli()),
+ },
+ }
+ default:
+ // Compose extended message when made as a reply to a different message, otherwise compose
+ // plain-text message for body given for all other message kinds.
+ if message.ReplyID != "" {
+ payload = &proto.Message{
+ ExtendedTextMessage: &proto.ExtendedTextMessage{
+ Text: &message.Body,
+ ContextInfo: &proto.ContextInfo{
+ StanzaId: &message.ReplyID,
+ QuotedMessage: &proto.Message{Conversation: ptrTo(message.ReplyBody)},
+ },
+ },
+ }
+ } else {
+ payload = &proto.Message{Conversation: &message.Body}
+ }
+ messageID = message.ID
+ }
+
+ _, err = s.client.SendMessage(context.Background(), jid, messageID, payload)
+ return err
+}
+
+// SendChatState sends the given chat state notification (e.g. composing message) to WhatsApp for the
+// contact specified within.
+func (s *Session) SendChatState(state ChatState) error {
+ jid, err := types.ParseJID(state.JID)
+ if err != nil {
+ return fmt.Errorf("Could not parse sender JID for chat state: %s", err)
+ }
+
+ var presence types.ChatPresence
+ switch state.Kind {
+ case ChatStateComposing:
+ presence = types.ChatPresenceComposing
+ case ChatStatePaused:
+ presence = types.ChatPresencePaused
+ }
+
+ return s.client.SendChatPresence(jid, presence, "")
+}
+
+// SendReceipt sends a read receipt to WhatsApp for the message IDs specified within.
+func (s *Session) SendReceipt(receipt Receipt) error {
+ jid, err := types.ParseJID(receipt.JID)
+ if err != nil {
+ return fmt.Errorf("Could not parse sender JID for chat state: %s", err)
+ }
+
+ ids := append([]types.MessageID{}, receipt.MessageIDs...)
+ return s.client.MarkRead(ids, time.Unix(receipt.Timestamp, 0), jid, types.EmptyJID)
+}
+
+// FetchRoster subscribes to the WhatsApp roster currently stored in the Session's internal state.
+// If `refresh` is `true`, FetchRoster will pull application state from the remote service and
+// synchronize any contacts found with the adapter.
+func (s *Session) FetchRoster(refresh bool) error {
+ // Synchronize remote application state with local state if requested.
+ if refresh {
+ err := s.client.FetchAppState(appstate.WAPatchCriticalUnblockLow, false, false)
+ if err != nil {
+ s.gateway.logger.Warnf("Could not fetch app state from server: %s", err)
+ }
+ }
+
+ // Synchronize local contact state with overarching gateway for all local contacts.
+ contacts, err := s.client.Store.Contacts.GetAllContacts()
+ if err != nil {
+ return fmt.Errorf("Failed fetching local contacts for %s", s.device.ID)
+ }
+
+ for jid, info := range contacts {
+ if err = s.client.SubscribePresence(jid); err != nil {
+ s.gateway.logger.Warnf("Failed to subscribe to presence for %s", jid)
+ }
+
+ if refresh {
+ go s.propagateEvent(newContactSyncEvent(s.client, jid, info))
+ }
+ }
+
+ return nil
+}
+
+// SetEventHandler assigns the given handler function for propagating internal events into the Python
+// gateway. Note that the event handler function is not entirely safe to use directly, and all calls
+// should instead be made via the [propagateEvent] function.
+func (s *Session) SetEventHandler(h HandleEventFunc) {
+ s.eventHandler = h
+}
+
+// PropagateEvent handles the given event kind and payload with the adapter event handler defined in
+// SetEventHandler. If no event handler is set, this function will return early with no error.
+func (s *Session) propagateEvent(kind EventKind, payload *EventPayload) {
+ if s.eventHandler == nil {
+ s.gateway.logger.Errorf("Event handler not set when propagating event %d with payload %v", kind, payload)
+ return
+ } else if kind == EventUnknown {
+ return
+ }
+
+ // Send empty payload instead of a nil pointer, as Python has trouble handling the latter.
+ if payload == nil {
+ payload = &EventPayload{}
+ }
+
+ // Don't allow other Goroutines from using this thread, as this might lead to concurrent use of
+ // the GIL, which can lead to crashes.
+ runtime.LockOSThread()
+ defer runtime.UnlockOSThread()
+
+ s.eventHandler(kind, payload)
+}
+
+// HandleEvent processes the given incoming WhatsApp event, checking its concrete type and
+// propagating it to the adapter event handler. Unknown or unhandled events are ignored, and any
+// errors that occur during processing are logged.
+func (s *Session) handleEvent(evt interface{}) {
+ s.gateway.logger.Debugf("Handling event: %#v", evt)
+
+ switch evt := evt.(type) {
+ case *events.AppStateSyncComplete:
+ if len(s.client.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
+ s.propagateEvent(EventConnected, nil)
+ if err := s.client.SendPresence(types.PresenceAvailable); err != nil {
+ s.gateway.logger.Warnf("Failed to send available presence: %s", err)
+ }
+ }
+ case *events.Connected, *events.PushNameSetting:
+ if len(s.client.Store.PushName) == 0 {
+ return
+ }
+ s.propagateEvent(EventConnected, nil)
+ if err := s.client.SendPresence(types.PresenceAvailable); err != nil {
+ s.gateway.logger.Warnf("Failed to send available presence: %s", err)
+ }
+ case *events.Message:
+ s.propagateEvent(newMessageEvent(s.client, evt))
+ case *events.Receipt:
+ s.propagateEvent(newReceiptEvent(evt))
+ case *events.Presence:
+ s.propagateEvent(newPresenceEvent(evt))
+ case *events.PushName:
+ s.propagateEvent(newContactSyncEvent(s.client, evt.JID, types.ContactInfo{FullName: evt.NewPushName}))
+ case *events.ChatPresence:
+ s.propagateEvent(newChatStateEvent(evt))
+ case *events.LoggedOut:
+ s.client.Disconnect()
+ if err := s.client.Store.Delete(); err != nil {
+ s.gateway.logger.Warnf("Unable to delete local device state on logout: %s", err)
+ }
+ s.propagateEvent(EventLoggedOut, nil)
+ case *events.PairSuccess:
+ if s.client.Store.ID == nil {
+ s.gateway.logger.Errorf("Pairing succeeded, but device ID is missing")
+ return
+ }
+ deviceID := s.client.Store.ID.String()
+ s.propagateEvent(EventPairSuccess, &EventPayload{PairDeviceID: deviceID})
+ if err := s.gateway.CleanupSession(LinkedDevice{ID: deviceID}); err != nil {
+ s.gateway.logger.Warnf("Failed to clean up devices after pair: %s", err)
+ }
+ }
+}
+
+// PtrTo returns a pointer to the given value, and is used for convenience when converting between
+// concrete and pointer values without assigning to a variable.
+func ptrTo[T any](t T) *T {
+ return &t
+}
diff --git a/watcher.py b/watcher.py
new file mode 100644
index 0000000..9b85438
--- /dev/null
+++ b/watcher.py
@@ -0,0 +1,33 @@
+import sys
+
+from watchdog.observers import Observer
+from watchdog.tricks import AutoRestartTrick, ShellCommandTrick
+
+if __name__ == "__main__":
+ observer = Observer()
+ auto_restart = AutoRestartTrick(
+ command=["python", "-m", "slidge"] + sys.argv[2:] if len(sys.argv) > 2 else [],
+ patterns=["*.py"],
+ ignore_patterns=["generated/*.py"],
+ )
+ gopy_build = ShellCommandTrick(
+ shell_command='cd "$(dirname ${watch_src_path})" && \
+ gopy build -output=generated -no-make=true . && \
+ touch "$(dirname ${watch_src_path})/__init__.py"',
+ patterns=["*.go"],
+ ignore_patterns=["generated/*.go"],
+ drop_during_process=True,
+ )
+
+ path = sys.argv[1] if len(sys.argv) > 1 else "."
+ observer.schedule(auto_restart, path, recursive=True)
+ observer.schedule(gopy_build, path, recursive=True)
+ observer.start()
+
+ try:
+ auto_restart.start()
+ while observer.is_alive():
+ observer.join(1)
+ finally:
+ observer.stop()
+ observer.join()
--
2.38.1