slidge-whatsapp 0.2.2__cp313-cp313-manylinux_2_36_aarch64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

@@ -0,0 +1,17 @@
1
+ """
2
+ WhatsApp gateway using the multi-device API.
3
+ """
4
+
5
+ from slidge import entrypoint
6
+ from slidge.util.util import get_version # noqa: F401
7
+
8
+ from . import command, config, contact, group, session
9
+ from .gateway import Gateway
10
+
11
+
12
+ def main():
13
+ entrypoint("slidge_whatsapp")
14
+
15
+
16
+ __version__ = "v0.2.2"
17
+ __all__ = "Gateway", "session", "command", "contact", "config", "group", "main"
@@ -0,0 +1,3 @@
1
+ from slidge_whatsapp import main
2
+
3
+ main()
@@ -0,0 +1,143 @@
1
+ from typing import TYPE_CHECKING, Optional
2
+
3
+ from slidge.command import Command, CommandAccess, Form, FormField
4
+ from slidge.util import is_valid_phone_number
5
+ from slixmpp import JID
6
+ from slixmpp.exceptions import XMPPError
7
+
8
+ from .generated import whatsapp
9
+
10
+ if TYPE_CHECKING:
11
+ from .session import Session
12
+
13
+
14
+ class Logout(Command):
15
+ NAME = "🔓 Disconnect from WhatsApp"
16
+ HELP = (
17
+ "Disconnects active WhatsApp session without removing any linked device credentials. "
18
+ "To re-connect, use the 're-login' command."
19
+ )
20
+ NODE = "wa_logout"
21
+ CHAT_COMMAND = "logout"
22
+ ACCESS = CommandAccess.USER_LOGGED
23
+
24
+ async def run(
25
+ self,
26
+ session: Optional["Session"], # type:ignore
27
+ ifrom: JID,
28
+ *args,
29
+ ) -> str:
30
+ assert session is not None
31
+ try:
32
+ session.shutdown()
33
+ except Exception as e:
34
+ session.send_gateway_status(f"Logout failed: {e}", show="dnd")
35
+ raise XMPPError(
36
+ "internal-server-error",
37
+ etype="wait",
38
+ text=f"Could not logout WhatsApp session: {e}",
39
+ )
40
+ session.send_gateway_status("Logged out", show="away")
41
+ return "Logged out successfully"
42
+
43
+
44
+ class PairPhone(Command):
45
+ NAME = "📱 Complete registration via phone number"
46
+ HELP = (
47
+ "As an alternative to QR code verification, this allows you to complete registration "
48
+ "by inputing a one-time code into the official WhatsApp client; this requires that you "
49
+ "provide the phone number used for the main device, in international format "
50
+ "(e.g. +447700900000). See more information here: https://faq.whatsapp.com/1324084875126592"
51
+ )
52
+ NODE = "wa_pair_phone"
53
+ CHAT_COMMAND = "pair-phone"
54
+ ACCESS = CommandAccess.USER_NON_LOGGED
55
+
56
+ async def run(
57
+ self,
58
+ session: Optional["Session"], # type:ignore
59
+ ifrom: JID,
60
+ *args,
61
+ ) -> Form:
62
+ return Form(
63
+ title="Pair to WhatsApp via phone number",
64
+ instructions="Enter your phone number in international format (e.g. +447700900000)",
65
+ fields=[FormField(var="phone", label="Phone number", required=True)],
66
+ handler=self.finish, # type:ignore
67
+ )
68
+
69
+ @staticmethod
70
+ async def finish(form_values: dict, session: "Session", _ifrom: JID):
71
+ p = form_values.get("phone")
72
+ if not is_valid_phone_number(p):
73
+ raise ValueError("Not a valid phone number", p)
74
+ code = session.whatsapp.PairPhone(p)
75
+ return f"Please open the official WhatsApp client and input the following code: {code}"
76
+
77
+
78
+ class ChangePresence(Command):
79
+ NAME = "📴 Set WhatsApp web presence"
80
+ HELP = (
81
+ "If you want to receive notifications in the WhatsApp official client,"
82
+ "you need to set your presence to unavailable. As a side effect, you "
83
+ "won't receive receipts and presences from your contacts."
84
+ )
85
+ NODE = "wa_presence"
86
+ CHAT_COMMAND = "presence"
87
+ ACCESS = CommandAccess.USER_LOGGED
88
+
89
+ async def run(
90
+ self,
91
+ session: Optional["Session"], # type:ignore
92
+ ifrom: JID,
93
+ *args,
94
+ ) -> Form:
95
+ return Form(
96
+ title="Set WhatsApp web presence",
97
+ instructions="Choose what type of presence you want to set",
98
+ fields=[
99
+ FormField(
100
+ var="presence",
101
+ value="available",
102
+ type="list-single",
103
+ options=[
104
+ {"label": "Available", "value": "available"},
105
+ {"label": "Unavailable", "value": "unavailable"},
106
+ ],
107
+ )
108
+ ],
109
+ handler=self.finish, # type:ignore
110
+ )
111
+
112
+ @staticmethod
113
+ async def finish(form_values: dict, session: "Session", _ifrom: JID):
114
+ p = form_values.get("presence")
115
+ if p == "available":
116
+ session.whatsapp.SendPresence(whatsapp.PresenceAvailable, "")
117
+ elif p == "unavailable":
118
+ session.whatsapp.SendPresence(whatsapp.PresenceUnavailable, "")
119
+ else:
120
+ raise ValueError("Not a valid presence kind.", p)
121
+ return f"Presence succesfully set to {p}"
122
+
123
+
124
+ class SubscribeToPresences(Command):
125
+ NAME = "🔔 Subscribe to contacts' presences"
126
+ HELP = (
127
+ "Subscribes to and refreshes contacts' presences; typically this is "
128
+ "done automatically, but re-subscribing might be useful in case contact "
129
+ "presences are stuck or otherwise not updating."
130
+ )
131
+ NODE = "wa_subscribe"
132
+ CHAT_COMMAND = "subscribe"
133
+ ACCESS = CommandAccess.USER_LOGGED
134
+
135
+ async def run(
136
+ self,
137
+ session: Optional["Session"], # type:ignore
138
+ ifrom: JID,
139
+ *args,
140
+ ) -> str:
141
+ assert session is not None
142
+ session.whatsapp.GetContacts(False)
143
+ return "Looks like no exception was raised. Success, I guess?"
@@ -0,0 +1,32 @@
1
+ """
2
+ Config contains plugin-specific configuration for WhatsApp, and is loaded automatically by the
3
+ core configuration framework.
4
+ """
5
+
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from slidge import global_config
10
+
11
+ # workaround because global_config.HOME_DIR is not defined unless
12
+ # called by slidge's main(), which is a problem for tests, docs and the
13
+ # dedicated slidge-whatsapp setuptools entrypoint
14
+ try:
15
+ DB_PATH: Optional[Path] = global_config.HOME_DIR / "whatsapp" / "whatsapp.db"
16
+ except AttributeError:
17
+ DB_PATH: Optional[Path] = None # type:ignore
18
+
19
+ DB_PATH__DOC = (
20
+ "The path to the database used for the WhatsApp plugin. Default to "
21
+ "${SLIDGE_HOME_DIR}/whatsapp/whatsapp.db"
22
+ )
23
+
24
+ ALWAYS_SYNC_ROSTER = False
25
+ ALWAYS_SYNC_ROSTER__DOC = (
26
+ "Whether or not to perform a full sync of the WhatsApp roster on startup."
27
+ )
28
+
29
+ ENABLE_LINK_PREVIEWS = True
30
+ ENABLE_LINK_PREVIEWS__DOC = (
31
+ "Whether or not previews for links (URLs) should be generated on outgoing messages"
32
+ )
@@ -0,0 +1,77 @@
1
+ from datetime import datetime, timezone
2
+ from typing import TYPE_CHECKING
3
+
4
+ from slidge import LegacyContact, LegacyRoster
5
+ from slixmpp.exceptions import XMPPError
6
+
7
+ from . import config
8
+ from .generated import whatsapp
9
+
10
+ if TYPE_CHECKING:
11
+ from .session import Session
12
+
13
+
14
+ class Contact(LegacyContact[str]):
15
+ CORRECTION = True
16
+ REACTIONS_SINGLE_EMOJI = True
17
+
18
+ async def update_presence(
19
+ self, presence: whatsapp.PresenceKind, last_seen_timestamp: int
20
+ ):
21
+ last_seen = (
22
+ datetime.fromtimestamp(last_seen_timestamp, tz=timezone.utc)
23
+ if last_seen_timestamp > 0
24
+ else None
25
+ )
26
+ if presence == whatsapp.PresenceUnavailable:
27
+ self.away(last_seen=last_seen)
28
+ else:
29
+ self.online(last_seen=last_seen)
30
+
31
+
32
+ class Roster(LegacyRoster[str, Contact]):
33
+ session: "Session"
34
+
35
+ async def fill(self):
36
+ """
37
+ Retrieve contacts from remote WhatsApp service, subscribing to their presence and adding to
38
+ local roster.
39
+ """
40
+ contacts = self.session.whatsapp.GetContacts(refresh=config.ALWAYS_SYNC_ROSTER)
41
+ for contact in contacts:
42
+ c = await self.add_whatsapp_contact(contact)
43
+ if c is not None:
44
+ yield c
45
+
46
+ async def add_whatsapp_contact(self, data: whatsapp.Contact) -> Contact | None:
47
+ """
48
+ Adds a WhatsApp contact to local roster, filling all required and optional information.
49
+ """
50
+ # Don't attempt to add ourselves to the roster.
51
+ if data.JID == self.user_legacy_id:
52
+ return None
53
+ contact = await self.by_legacy_id(data.JID)
54
+ contact.name = data.Name
55
+ contact.is_friend = True
56
+ try:
57
+ avatar = self.session.whatsapp.GetAvatar(data.JID, contact.avatar or "")
58
+ if avatar.URL and contact.avatar != avatar.ID:
59
+ await contact.set_avatar(avatar.URL, avatar.ID)
60
+ elif avatar.URL == "":
61
+ await contact.set_avatar(None)
62
+ except RuntimeError as err:
63
+ self.session.log.error(
64
+ "Failed getting avatar for contact %s: %s", data.JID, err
65
+ )
66
+ contact.set_vcard(full_name=contact.name, phone=str(contact.jid.local))
67
+ return contact
68
+
69
+ async def legacy_id_to_jid_username(self, legacy_id: str) -> str:
70
+ return "+" + legacy_id[: legacy_id.find("@")]
71
+
72
+ async def jid_username_to_legacy_id(self, jid_username: str) -> str:
73
+ if jid_username.startswith("#"):
74
+ raise XMPPError("item-not-found", "Invalid contact ID: group ID given")
75
+ if not jid_username.startswith("+"):
76
+ raise XMPPError("item-not-found", "Invalid contact ID, expected '+' prefix")
77
+ return jid_username.removeprefix("+") + "@" + whatsapp.DefaultUserServer