1
0
Fork 0
slidge-whatsapp/slidge_whatsapp/session.py

570 lines
21 KiB
Python

from asyncio import iscoroutine, run_coroutine_threadsafe
from datetime import datetime, timezone
from functools import wraps
from os import fdopen
from os.path import basename
from pathlib import Path
from re import search
from shelve import open
from tempfile import mkstemp
from threading import Lock
from typing import Optional, Union
from aiohttp import ClientSession
from linkpreview import Link, LinkPreview
from slidge import BaseSession, GatewayUser, global_config
from slidge.contact.roster import ContactIsUser
from slidge.util.types import (
LegacyAttachment,
MessageReference,
PseudoPresenceShow,
ResourceDict,
)
from . import config
from .contact import Contact, Roster
from .gateway import Gateway
from .generated import go, whatsapp
from .group import MUC, Bookmarks
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 use the re-login adhoc command "
"and re-scan the QR code on your main device."
)
URL_SEARCH_REGEX = r"(?P<url>https?://[^\s]+)"
Recipient = Union[Contact, MUC]
def ignore_contact_is_user(func):
@wraps(func)
async def wrapped(self, *a, **k):
try:
return await func(self, *a, **k)
except ContactIsUser as e:
self.log.debug("A wild ContactIsUser has been raised!", exc_info=e)
return wrapped
class Session(BaseSession[str, Recipient]):
xmpp: Gateway
contacts: Roster
bookmarks: Bookmarks
def __init__(self, user: GatewayUser):
super().__init__(user)
self.user_shelf_path = (
global_config.HOME_DIR / "whatsapp" / (self.user.bare_jid + ".shelf")
)
with open(str(self.user_shelf_path)) as shelf:
try:
device = whatsapp.LinkedDevice(ID=shelf["device_id"])
except KeyError:
device = whatsapp.LinkedDevice()
self.whatsapp = self.xmpp.whatsapp.NewSession(device)
self._handle_event = make_sync(self.handle_event, self.xmpp.loop)
self.whatsapp.SetEventHandler(self._handle_event)
self._connected = self.xmpp.loop.create_future()
self.user_phone: Optional[str] = None
self._lock = Lock()
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()
self._connected = self.xmpp.loop.create_future()
return await self._connected
async def logout(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()
def __get_connected_status_message(self):
return f"Connected as {self.user_phone}"
@ignore_contact_is_user
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.EventPair:
self.send_gateway_message(MESSAGE_PAIR_SUCCESS)
with open(str(self.user_shelf_path)) as shelf:
shelf["device_id"] = data.PairDeviceID
elif event == whatsapp.EventConnected:
if self._connected.done():
# On re-pair, Session.login() is not called by slidge core, so
# the status message is not updated
self.send_gateway_status(
self.__get_connected_status_message(), show="chat"
)
else:
self.contacts.user_legacy_id = data.ConnectedJID
self.user_phone = "+" + data.ConnectedJID.split("@")[0]
self._connected.set_result(self.__get_connected_status_message())
elif event == whatsapp.EventLoggedOut:
self.logged = False
self.send_gateway_message(MESSAGE_LOGGED_OUT)
self.send_gateway_status("Logged out", show="away")
elif event == whatsapp.EventContact:
await self.contacts.add_whatsapp_contact(data.Contact)
elif event == whatsapp.EventGroup:
await self.bookmarks.add_whatsapp_group(data.Group)
elif event == whatsapp.EventPresence:
contact = await self.contacts.by_legacy_id(data.Presence.JID)
await contact.update_presence(data.Presence.Kind, data.Presence.LastSeen)
elif event == whatsapp.EventChatState:
await self.handle_chat_state(data.ChatState)
elif event == whatsapp.EventReceipt:
with self._lock:
await self.handle_receipt(data.Receipt)
elif event == whatsapp.EventCall:
await self.handle_call(data.Call)
elif event == whatsapp.EventMessage:
with self._lock:
await self.handle_message(data.Message)
async def handle_chat_state(self, state: whatsapp.ChatState):
contact = await self.get_contact_or_participant(state.JID, state.GroupJID)
if state.Kind == whatsapp.ChatStateComposing:
contact.composing()
elif state.Kind == whatsapp.ChatStatePaused:
contact.paused()
async def handle_receipt(self, receipt: whatsapp.Receipt):
"""
Handle incoming delivered/read receipt, as propagated by the WhatsApp adapter.
"""
contact = await self.get_contact_or_participant(receipt.JID, receipt.GroupJID)
for message_id in receipt.MessageIDs:
if receipt.Kind == whatsapp.ReceiptDelivered:
contact.received(message_id)
elif receipt.Kind == whatsapp.ReceiptRead:
contact.displayed(legacy_msg_id=message_id, carbon=receipt.IsCarbon)
async def handle_call(self, call: whatsapp.Call):
contact = await self.contacts.by_legacy_id(call.JID)
if call.State == whatsapp.CallMissed:
text = "Missed call"
else:
text = "Call"
text = (
text
+ f" from {contact.name or 'tel:' + str(contact.jid.local)} (xmpp:{contact.jid.bare})"
)
if call.Timestamp > 0:
call_at = datetime.fromtimestamp(call.Timestamp, tz=timezone.utc)
text = text + f" at {call_at}"
self.send_gateway_message(text)
async def _get_reply_to(
self, message: whatsapp.Message, muc: Optional["MUC"] = None
) -> Optional[MessageReference]:
if not message.ReplyID:
return None
reply_to = MessageReference(
legacy_id=message.ReplyID,
body=message.ReplyBody
if muc is None
else muc.replace_mentions(message.ReplyBody),
)
if message.OriginJID == self.contacts.user_legacy_id:
reply_to.author = self.user
else:
reply_to.author = await self.get_contact_or_participant(
message.OriginJID, message.GroupJID
)
return reply_to
async def _get_preview(self, text: str) -> Optional[whatsapp.Preview]:
if not config.ENABLE_LINK_PREVIEWS:
return None
match = search(URL_SEARCH_REGEX, text)
if not match:
return None
url = match.group("url")
async with self.http.get(url) as resp:
if resp.status != 200:
self.log.debug(
"Could not generate a preview for %s because response status was %s",
url,
resp.status,
)
return None
if resp.content_type != "text/html":
self.log.debug(
"Could not generate a preview for %s because content type is %s",
url,
resp.content_type,
)
return None
try:
html = await resp.text()
except Exception as e:
self.log.debug("Could not generate a preview for %s", url, exc_info=e)
return None
preview = LinkPreview(Link(url, html))
if not preview.title:
return None
try:
return whatsapp.Preview(
Title=preview.title,
Description=preview.description or "",
URL=url,
ImagePath=await get_url_temp(self.http, preview.image),
)
except Exception as e:
self.log.debug("Could not generate a preview for %s", url, exc_info=e)
return None
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 = await self.get_contact_or_participant(message.JID, message.GroupJID)
muc = getattr(contact, "muc", None)
reply_to = await self._get_reply_to(message, muc)
message_timestamp = (
datetime.fromtimestamp(message.Timestamp, tz=timezone.utc)
if message.Timestamp > 0
else None
)
if message.Kind == whatsapp.MessagePlain:
if hasattr(contact, "muc"):
body = contact.muc.replace_mentions(message.Body)
else:
body = message.Body
contact.send_text(
body=body,
legacy_msg_id=message.ID,
when=message_timestamp,
reply_to=reply_to,
carbon=message.IsCarbon,
)
elif message.Kind == whatsapp.MessageAttachment:
attachments = Attachment.convert_list(message.Attachments, muc)
await contact.send_files(
attachments=attachments,
legacy_msg_id=message.ID,
reply_to=reply_to,
when=message_timestamp,
carbon=message.IsCarbon,
)
for attachment in attachments:
if global_config.NO_UPLOAD_METHOD != "symlink":
self.log.debug("Removing '%s' from disk", attachment.path)
if attachment.path is None:
continue
Path(attachment.path).unlink(missing_ok=True)
elif message.Kind == whatsapp.MessageEdit:
contact.correct(
legacy_msg_id=message.ID,
new_text=message.Body,
when=message_timestamp,
reply_to=reply_to,
carbon=message.IsCarbon,
)
elif message.Kind == whatsapp.MessageRevoke:
contact.retract(legacy_msg_id=message.ID, carbon=message.IsCarbon)
elif message.Kind == whatsapp.MessageReaction:
emojis = [message.Body] if message.Body else []
contact.react(
legacy_msg_id=message.ID, emojis=emojis, carbon=message.IsCarbon
)
async def send_text(
self,
chat: Recipient,
text: str,
*,
reply_to_msg_id: Optional[str] = None,
reply_to_fallback_text: Optional[str] = None,
reply_to=None,
**_,
):
"""
Send outgoing plain-text message to given WhatsApp contact.
"""
message_id = self.whatsapp.GenerateMessageID()
message_preview = await self._get_preview(text) or whatsapp.Preview()
message = whatsapp.Message(
ID=message_id, JID=chat.legacy_id, Body=text, Preview=message_preview
)
set_reply_to(chat, message, reply_to_msg_id, reply_to_fallback_text, reply_to)
self.whatsapp.SendMessage(message)
return message_id
async def send_file(
self,
chat: Recipient,
url: str,
http_response,
reply_to_msg_id: Optional[str] = None,
reply_to_fallback_text: Optional[str] = None,
reply_to=None,
**_,
):
"""
Send outgoing media message (i.e. audio, image, document) to given WhatsApp contact.
"""
message_id = self.whatsapp.GenerateMessageID()
message_attachment = whatsapp.Attachment(
MIME=http_response.content_type,
Filename=basename(url),
Path=await get_url_temp(self.http, url),
)
message = whatsapp.Message(
Kind=whatsapp.MessageAttachment,
ID=message_id,
JID=chat.legacy_id,
ReplyID=reply_to_msg_id if reply_to_msg_id else "",
Attachments=whatsapp.Slice_whatsapp_Attachment([message_attachment]),
)
set_reply_to(chat, message, reply_to_msg_id, reply_to_fallback_text, reply_to)
self.whatsapp.SendMessage(message)
return message_id
async def presence(
self,
resource: str,
show: PseudoPresenceShow,
status: str,
resources: dict[str, ResourceDict],
merged_resource: Optional[ResourceDict],
):
"""
Send outgoing availability status (i.e. presence) based on combined status of all connected
XMPP clients.
"""
if not merged_resource:
self.whatsapp.SendPresence(whatsapp.PresenceUnavailable, "")
else:
presence = (
whatsapp.PresenceAvailable
if merged_resource["show"] in ["chat", ""]
else whatsapp.PresenceUnavailable
)
self.whatsapp.SendPresence(
presence, merged_resource["status"] if merged_resource["status"] else ""
)
async def active(self, c: Recipient, thread=None):
"""
WhatsApp has no equivalent to the "active" chat state, so calls to this function are no-ops.
"""
pass
async def inactive(self, c: Recipient, thread=None):
"""
WhatsApp has no equivalent to the "inactive" chat state, so calls to this function are no-ops.
"""
pass
async def composing(self, c: Recipient, thread=None):
"""
Send "composing" chat state to given WhatsApp contact, signifying that a message is currently
being composed.
"""
state = whatsapp.ChatState(JID=c.legacy_id, Kind=whatsapp.ChatStateComposing)
self.whatsapp.SendChatState(state)
async def paused(self, c: Recipient, thread=None):
"""
Send "paused" chat state to given WhatsApp contact, signifying that an (unsent) message is no
longer being composed.
"""
state = whatsapp.ChatState(JID=c.legacy_id, Kind=whatsapp.ChatStatePaused)
self.whatsapp.SendChatState(state)
async def displayed(self, c: Recipient, legacy_msg_id: str, thread=None):
"""
Send "read" receipt, signifying that the WhatsApp message sent has been displayed on the XMPP
client.
"""
receipt = whatsapp.Receipt(
MessageIDs=go.Slice_string([legacy_msg_id]),
JID=c.get_message_sender(legacy_msg_id)
if isinstance(c, MUC)
else c.legacy_id,
GroupJID=c.legacy_id if c.is_group else "",
)
self.whatsapp.SendReceipt(receipt)
async def react(
self, c: Recipient, legacy_msg_id: str, emojis: list[str], thread=None
):
"""
Send or remove emoji reaction to existing WhatsApp message.
Slidge core makes sure that the emojis parameter is always empty or a
*single* emoji.
"""
is_carbon = self._is_carbon(c, legacy_msg_id)
message_sender_id = (
c.get_message_sender(legacy_msg_id)
if not is_carbon and isinstance(c, MUC)
else ""
)
message = whatsapp.Message(
Kind=whatsapp.MessageReaction,
ID=legacy_msg_id,
JID=c.legacy_id,
OriginJID=message_sender_id,
Body=emojis[0] if emojis else "",
IsCarbon=is_carbon,
)
self.whatsapp.SendMessage(message)
async def retract(self, c: Recipient, legacy_msg_id: str, thread=None):
"""
Request deletion (aka retraction) for a given WhatsApp message.
"""
message = whatsapp.Message(
Kind=whatsapp.MessageRevoke, ID=legacy_msg_id, JID=c.legacy_id
)
self.whatsapp.SendMessage(message)
async def correct(self, c: Recipient, text: str, legacy_msg_id: str, thread=None):
"""
Request correction (aka editing) for a given WhatsApp message.
"""
message = whatsapp.Message(
Kind=whatsapp.MessageEdit, ID=legacy_msg_id, JID=c.legacy_id, Body=text
)
self.whatsapp.SendMessage(message)
async def on_avatar(
self,
bytes_: Optional[bytes],
hash_: Optional[str],
type_: Optional[str],
width: Optional[int],
height: Optional[int],
) -> None:
"""
Update profile picture in WhatsApp for corresponding avatar change in XMPP.
"""
self.whatsapp.SetAvatar("", await get_bytes_temp(bytes_) if bytes_ else "")
async def search(self, form_values: dict[str, str]):
self.send_gateway_message("Searching on WhatsApp has not been implemented yet.")
async def get_contact_or_participant(
self, legacy_contact_id: str, legacy_group_jid: str
):
"""
Return either a Contact or a Participant instance for the given contact and group JIDs.
"""
if legacy_group_jid:
muc = await self.bookmarks.by_legacy_id(legacy_group_jid)
return await muc.get_participant_by_legacy_id(legacy_contact_id)
else:
return await self.contacts.by_legacy_id(legacy_contact_id)
def _is_carbon(self, c: Recipient, legacy_msg_id: str):
if c.is_group:
return legacy_msg_id in self.muc_sent_msg_ids
else:
return legacy_msg_id in self.sent
class Attachment(LegacyAttachment):
@staticmethod
def convert_list(
attachments: list, muc: Optional["MUC"] = None
) -> list["Attachment"]:
return [Attachment.convert(attachment, muc) for attachment in attachments]
@staticmethod
def convert(
wa_attachment: whatsapp.Attachment, muc: Optional["MUC"] = None
) -> "Attachment":
return Attachment(
content_type=wa_attachment.MIME,
path=wa_attachment.Path,
caption=wa_attachment.Caption
if muc is None
else muc.replace_mentions(wa_attachment.Caption),
name=wa_attachment.Filename,
)
def make_sync(func, loop):
"""
Wrap async function in synchronous operation, running against the given loop in thread-safe mode.
"""
@wraps(func)
def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if iscoroutine(result):
future = 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 set_reply_to(
chat: Recipient,
message: whatsapp.Message,
reply_to_msg_id: Optional[str] = None,
reply_to_fallback_text: Optional[str] = None,
reply_to=None,
):
if reply_to_msg_id:
message.ReplyID = reply_to_msg_id
if reply_to:
message.OriginJID = (
reply_to.contact.legacy_id if chat.is_group else chat.legacy_id
)
if reply_to_fallback_text:
message.ReplyBody = strip_quote_prefix(reply_to_fallback_text)
message.Body = message.Body.lstrip()
return message
async def get_url_temp(client: ClientSession, url: str) -> Optional[str]:
temp_path = None
async with client.get(url) as resp:
if resp.status == 200:
temp_file, temp_path = mkstemp(dir=global_config.HOME_DIR / "tmp")
with fdopen(temp_file, "wb") as f:
f.write(await resp.read())
return temp_path
async def get_bytes_temp(buf: bytes) -> Optional[str]:
temp_file, temp_path = mkstemp(dir=global_config.HOME_DIR / "tmp")
with fdopen(temp_file, "wb") as f:
f.write(buf)
return temp_path
return None