~nicoco/public-inbox

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch

[PATCH slidge v2] Implement basic support for WhatsApp

Details
Message ID
<20221202001143.16559-1-alex@deuill.org>
DKIM signature
missing
Download raw message
Patch: +1535 -15
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
Reply to thread Export thread (mbox)