sleamdge 0.0.0.dev0__py3-none-any.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.
sleamdge/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from slidge import entrypoint
2
+ from slidge.util.util import get_version # noqa: F401
3
+
4
+ from . import contact, gateway, session
5
+
6
+
7
+ def main():
8
+ entrypoint("sleamdge")
9
+
10
+
11
+ __all__ = "contact", "gateway", "session", "main"
12
+
13
+ __version__ = get_version()
sleamdge/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sleamdge import main
2
+
3
+ main()
sleamdge/client.py ADDED
@@ -0,0 +1,217 @@
1
+ import asyncio
2
+ import logging
3
+ from asyncio import Future, get_running_loop
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Generic, Optional, TypeVar, Union
6
+
7
+ import steam
8
+ from slixmpp.exceptions import XMPPError
9
+
10
+ if TYPE_CHECKING:
11
+ from .contact import Contact
12
+ from .group import Participant
13
+ from .session import Session
14
+
15
+
16
+ ClanOrGroup = Union[steam.Clan, steam.Group]
17
+ Channel = Union[steam.GroupChannel, steam.ClanChannel]
18
+
19
+
20
+ class CredentialsValidation(steam.Client):
21
+ """
22
+ A client for entering the OTP interactively and validating credentials
23
+ """
24
+
25
+ def __init__(self, **k) -> None:
26
+ super().__init__(**k)
27
+ self.code_future: Future[str] = get_running_loop().create_future()
28
+
29
+ async def code(self) -> str:
30
+ return await self.code_future
31
+
32
+
33
+ class SteamClient(steam.Client):
34
+ """
35
+ The main steam client of registered users
36
+ """
37
+
38
+ def __init__(self, session: "Session", **k) -> None:
39
+ self.session = session
40
+ self.__outbox = Pending[int]()
41
+ self.__outbox_reaction = Pending[tuple[int, str, bool]]()
42
+ super().__init__(**k)
43
+
44
+ async def code(self) -> str:
45
+ return await self.session.input(
46
+ "You have been disconnected, please enter the code "
47
+ "you received via email or steam guard"
48
+ )
49
+
50
+ async def get_contact_or_participant(
51
+ self, message: steam.Message
52
+ ) -> Optional[Union["Contact", "Participant"]]:
53
+ if isinstance(message, steam.UserMessage):
54
+ return await self.session.contacts.by_steam_user(
55
+ message.channel.participant
56
+ )
57
+ elif isinstance(message, (steam.GroupMessage, steam.ClanMessage)):
58
+ muc = await self.session.bookmarks.by_steam_channel(message.channel)
59
+ return await muc.get_participant_by_legacy_id(message.author.id)
60
+ return None
61
+
62
+ async def on_typing(self, user: steam.User, when: datetime):
63
+ if user == self.user:
64
+ return
65
+ c = await self.session.contacts.by_steam_user(user)
66
+ c.composing()
67
+
68
+ async def on_message(self, message: steam.Message) -> None:
69
+ if self.__outbox.set_if_pending(message.id):
70
+ return
71
+
72
+ c = await self.get_contact_or_participant(message)
73
+ if c is None:
74
+ return
75
+
76
+ c.send_text(
77
+ message.clean_content, message.id, carbon=message.author == self.user
78
+ )
79
+
80
+ async def on_reaction_add(self, reaction: steam.MessageReaction) -> None:
81
+ if not reaction.emoticon:
82
+ return
83
+ if self.__outbox_reaction.set_if_pending(
84
+ (reaction.message.id, reaction.emoticon.name, True)
85
+ ):
86
+ return
87
+ await self.update_reactions(reaction)
88
+
89
+ async def on_reaction_remove(self, reaction: steam.MessageReaction) -> None:
90
+ if not reaction.emoticon:
91
+ return
92
+ if self.__outbox_reaction.set_if_pending(
93
+ (reaction.message.id, reaction.emoticon.name, False)
94
+ ):
95
+ return
96
+
97
+ await self.update_reactions(reaction)
98
+
99
+ async def on_clan_update(self, before: steam.Clan, after: steam.Clan, /) -> None:
100
+ await self.on_group_or_clan_update(before, after)
101
+
102
+ async def on_clan_join(self, clan: steam.Clan, /) -> None:
103
+ for c in clan.channels:
104
+ await self.session.bookmarks.by_steam_channel(c)
105
+
106
+ async def on_clan_leave(self, clan: steam.Clan, /) -> None:
107
+ # TODO:
108
+ pass
109
+
110
+ async def on_group_update(self, before: steam.Group, after: steam.Group, /) -> None:
111
+ await self.on_group_or_clan_update(before, after)
112
+
113
+ async def on_group_or_clan_update(
114
+ self, before: ClanOrGroup, after: ClanOrGroup, /
115
+ ) -> None:
116
+ channels_before = set[Channel](before.channels)
117
+ channels_after = set[Channel](after.channels)
118
+
119
+ for channel in channels_after - channels_before:
120
+ muc = await self.session.bookmarks.by_steam_channel(channel)
121
+ if isinstance(after, steam.Group):
122
+ await muc.add_to_bookmarks()
123
+ for channel in channels_before - channels_after:
124
+ muc = await self.session.bookmarks.by_steam_channel(channel)
125
+ muc.get_system_participant().send_text(
126
+ "This channel has been destroyed but this is not yet implemented in slidge…"
127
+ )
128
+
129
+ existing_mucs = [
130
+ await self.session.bookmarks.by_steam_channel(c) for c in channels_before
131
+ ]
132
+
133
+ for muc in existing_mucs:
134
+ await muc.update_info()
135
+
136
+ async def on_group_join(self, group: steam.Group, /) -> None:
137
+ for c in group.channels:
138
+ muc = await self.session.bookmarks.by_steam_channel(c)
139
+ await muc.add_to_bookmarks()
140
+
141
+ async def on_group_leave(self, group: steam.Group, /) -> None:
142
+ # TODO:
143
+ pass
144
+
145
+ async def update_reactions(self, reaction: steam.MessageReaction) -> None:
146
+ message = reaction.message
147
+ if not reaction.emoticon:
148
+ return
149
+ c = await self.get_contact_or_participant(message)
150
+ if c is None:
151
+ return
152
+
153
+ self.session.log.debug("Reaction: %s", reaction)
154
+ await c.update_reaction(reaction)
155
+
156
+ async def on_user_update(self, before: steam.User, after: steam.User) -> None:
157
+ if after.id == self.user.id:
158
+ return
159
+ c = await self.session.contacts.by_steam_user(after)
160
+ await c.update_info(after)
161
+
162
+ async def add_emoticon(self, msg: steam.Message, emoticon: steam.Emoticon) -> None:
163
+ e = self.__outbox_reaction.place((msg.id, emoticon.name, True))
164
+ await msg.add_emoticon(emoticon)
165
+ await e.wait()
166
+
167
+ async def remove_emoticon(
168
+ self, msg: steam.Message, emoticon: steam.Emoticon
169
+ ) -> None:
170
+ e = self.__outbox_reaction.place((msg.id, emoticon.name, False))
171
+ await msg.remove_emoticon(emoticon)
172
+ await e.wait()
173
+
174
+ async def send(
175
+ self,
176
+ recipient: Union[steam.User, steam.GroupChannel, steam.ClanChannel],
177
+ text: str,
178
+ ) -> int:
179
+ message = await recipient.send(text)
180
+ if message is None:
181
+ raise XMPPError("internal-server-error", "No message ID")
182
+ e = self.__outbox.place(message.id)
183
+ await e.wait()
184
+ return message.id
185
+
186
+ async def fetch_user_named(self, name: str):
187
+ id64 = await steam.utils.id64_from_url(
188
+ f"https://steamcommunity.com/id/{name}", self.session.http
189
+ )
190
+ return await self._state.fetch_user(id64) if id64 is not None else None
191
+
192
+
193
+ T = TypeVar("T")
194
+
195
+
196
+ class Pending(Generic[T]):
197
+ """
198
+ Helper for the fact that everything we send is echoed, and we mostly
199
+ want to ignore echoes/
200
+ """
201
+
202
+ def __init__(self) -> None:
203
+ self.__data = dict[T, asyncio.Event]()
204
+
205
+ def place(self, key: T) -> asyncio.Event:
206
+ e = self.__data[key] = asyncio.Event()
207
+ return e
208
+
209
+ def set_if_pending(self, key: T) -> bool:
210
+ e = self.__data.pop(key, None)
211
+ if e is None:
212
+ return False
213
+ e.set()
214
+ return True
215
+
216
+
217
+ log = logging.getLogger(__name__)
sleamdge/contact.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, AsyncIterator, Optional
4
+
5
+ import steam
6
+ from slidge import LegacyContact, LegacyRoster
7
+ from slixmpp.exceptions import XMPPError
8
+ from steam.types.id import ID32
9
+
10
+ from .util import EMOJIS_SET, emojize
11
+
12
+ if TYPE_CHECKING:
13
+ from .session import Session
14
+
15
+
16
+ class Roster(LegacyRoster[ID32, "Contact"]):
17
+ session: "Session"
18
+
19
+ async def by_steam_user(self, user: steam.User) -> Contact:
20
+ return await self.by_legacy_id(user.id)
21
+
22
+ async def jid_username_to_legacy_id(self, local: str) -> ID32:
23
+ try:
24
+ local_int = int(local)
25
+ except ValueError:
26
+ raise XMPPError("bad-request", f"This is not a valid integer: {local}")
27
+ return ID32(local_int)
28
+
29
+ async def fill(self) -> AsyncIterator["Contact"]:
30
+ for user in await self.session.steam.user.friends():
31
+ c = await self.by_steam_user(user)
32
+ c.is_friend = True
33
+ yield c
34
+
35
+
36
+ class Contact(LegacyContact[ID32]):
37
+ MARKS = False
38
+ CORRECTION = False
39
+ RETRACTION = False
40
+
41
+ session: "Session"
42
+
43
+ async def get_steam_channel(self) -> steam.User:
44
+ u = self.session.steam.get_user(self.legacy_id)
45
+ if u is None:
46
+ raise XMPPError("item-not-found")
47
+ return u
48
+
49
+ async def update_info(self, user: Optional[steam.User] = None) -> None:
50
+ if user is None:
51
+ user = await self.get_steam_channel()
52
+ self.name = user.name
53
+ self.avatar = user.avatar.url
54
+ await self.update_state(user)
55
+
56
+ async def update_state(self, user: Optional[steam.User] = None) -> None:
57
+ if user is None:
58
+ user = await self.get_steam_channel()
59
+ self.log.debug("Rich presence: %s", user.rich_presence)
60
+ match user.state:
61
+ case steam.PersonaState.Online:
62
+ self.online()
63
+ case steam.PersonaState.Offline:
64
+ if user.last_seen_online:
65
+ # workaround for gajim not parsing last presence for "unavailable"
66
+ self.extended_away(last_seen=user.last_seen_online)
67
+ else:
68
+ self.offline()
69
+ case steam.PersonaState.Busy:
70
+ self.busy(last_seen=user.last_seen_online)
71
+ case steam.PersonaState.Away:
72
+ self.away(last_seen=user.last_seen_online)
73
+ case steam.PersonaState.Snooze:
74
+ self.extended_away(last_seen=user.last_seen_online)
75
+ case steam.PersonaState.LookingToPlay:
76
+ self.online(status="Looking to play", last_seen=user.last_seen_online)
77
+ case steam.PersonaState.LookingToTrade:
78
+ self.online(status="Looking to trade", last_seen=user.last_seen_online)
79
+
80
+ async def available_emojis(self, legacy_msg_id=None) -> set[str]:
81
+ return EMOJIS_SET
82
+
83
+ async def update_reaction(self, reaction: steam.MessageReaction) -> None:
84
+ user = self.session.steam.user
85
+ carbon = reaction.user == user
86
+ self.log.debug("Reaction: carbon: %s - %s - %s", user, reaction.user, carbon)
87
+ message = await self.fetch_message(reaction.message.id)
88
+
89
+ reactions = list[str]()
90
+ for r in message.reactions:
91
+ if emoticon := r.emoticon:
92
+ emoji = emojize(emoticon)
93
+ self.log.debug("UPDATING R: %s %s %s", emoji, r.user, user)
94
+ if r.user == user and carbon:
95
+ reactions.append(emoji)
96
+ elif r.user != user and not carbon:
97
+ reactions.append(emoji)
98
+ self.log.debug("UPDATING RS: %s %s %s", message.id, reactions, carbon)
99
+ self.react(message.id, reactions, carbon=carbon)
100
+
101
+ async def fetch_message(self, legacy_msg_id: int) -> steam.UserMessage:
102
+ msg = await (await self.get_steam_channel()).fetch_message(legacy_msg_id)
103
+ if msg is None:
104
+ raise XMPPError(
105
+ "item-not-found", f"Could not find the legacy message {legacy_msg_id}"
106
+ )
107
+ if not isinstance(msg, steam.UserMessage):
108
+ raise XMPPError(
109
+ "internal-server-error", f"This is not a 1:1 message: '{type(msg)}'"
110
+ )
111
+ return msg
sleamdge/gateway.py ADDED
@@ -0,0 +1,87 @@
1
+ import asyncio
2
+ import logging
3
+ from typing import Optional
4
+
5
+ from slidge import BaseGateway, GatewayUser
6
+ from slidge.command.register import FormField, RegistrationType
7
+ from slixmpp import JID
8
+ from slixmpp.exceptions import XMPPError
9
+
10
+ from .client import CredentialsValidation
11
+ from .types import ChannelId
12
+
13
+
14
+ class Gateway(BaseGateway):
15
+ REGISTRATION_INSTRUCTIONS = "Enter steam credentials"
16
+ REGISTRATION_FIELDS = [
17
+ FormField(var="username", label="Steam username", required=True),
18
+ FormField(var="password", label="Password", private=True, required=True),
19
+ ]
20
+ REGISTRATION_TYPE = RegistrationType.TWO_FACTOR_CODE
21
+
22
+ ROSTER_GROUP = "Steam"
23
+
24
+ SEARCH_FIELDS = [
25
+ FormField(
26
+ var="user_name",
27
+ label="The name of the user after https://steamcommunity.com/id",
28
+ required=True,
29
+ ),
30
+ ]
31
+
32
+ COMPONENT_NAME = "Steam (slidge)"
33
+ COMPONENT_TYPE = "steam"
34
+
35
+ COMPONENT_AVATAR = "https://logos-download.com/wp-content/uploads/2016/05/Steam_icon_logo_logotype.png"
36
+
37
+ GROUPS = True
38
+
39
+ LEGACY_MSG_ID_TYPE = int
40
+ LEGACY_CONTACT_ID_TYPE = int
41
+ LEGACY_ROOM_ID_TYPE = ChannelId.from_str
42
+
43
+ def __init__(self):
44
+ super().__init__()
45
+ self.__pending = dict[JID, tuple[asyncio.Task, CredentialsValidation]]()
46
+
47
+ async def validate(
48
+ self, user_jid: JID, registration_form: dict[str, Optional[str]]
49
+ ):
50
+ username = registration_form["username"]
51
+ password = registration_form["password"]
52
+
53
+ assert isinstance(username, str)
54
+ assert isinstance(password, str)
55
+
56
+ client = CredentialsValidation()
57
+
58
+ async def wrap():
59
+ try:
60
+ await client.login(username, password)
61
+ except Exception as e:
62
+ self.log.exception("Login failed for %s", user_jid, exc_info=e)
63
+
64
+ task = self.xmpp.loop.create_task(wrap())
65
+ self.__pending[JID(user_jid.bare)] = (task, client)
66
+ return {}
67
+
68
+ async def validate_two_factor_code(self, user: GatewayUser, code: str) -> dict:
69
+ task, client = self.__pending.pop(user.jid)
70
+ client.code_future.set_result(code)
71
+ try:
72
+ await asyncio.wait_for(asyncio.shield(client.wait_until_ready()), 5)
73
+ if task.done() and (exc := task.exception()):
74
+ raise XMPPError("not-authorized", f"That didn't work: {exc}")
75
+ await asyncio.wait_for(client.wait_until_ready(), 60)
76
+ except (asyncio.TimeoutError, asyncio.CancelledError):
77
+ raise XMPPError("not-authorized", f"Authorization timed out")
78
+ await client.wait_until_ready()
79
+ token = client.refresh_token
80
+ await client.close()
81
+ # For unknown reason, closing and re-opening the connection right away hangs,
82
+ # waiting is a dirty but mostly functional workaround.
83
+ await asyncio.sleep(10)
84
+ return {"refresh_token": token}
85
+
86
+
87
+ log = logging.getLogger(__name__)
sleamdge/group.py ADDED
@@ -0,0 +1,174 @@
1
+ from typing import TYPE_CHECKING, AsyncIterator, Optional, Union
2
+
3
+ from slidge import LegacyBookmarks, LegacyMUC, LegacyParticipant, MucType
4
+ from slidge.util.types import HoleBound
5
+ from slixmpp.exceptions import XMPPError
6
+ from steam import (
7
+ Clan,
8
+ ClanChannel,
9
+ ClanMessage,
10
+ Group,
11
+ GroupChannel,
12
+ GroupMessage,
13
+ MessageReaction,
14
+ )
15
+
16
+ from .contact import Contact
17
+ from .types import ChannelId, Parent, ParentType
18
+ from .util import EMOJIS_SET, emojize
19
+
20
+ if TYPE_CHECKING:
21
+ from .session import Session
22
+
23
+
24
+ class Bookmarks(LegacyBookmarks[ChannelId, "LegacyMUC"]):
25
+ session: "Session"
26
+
27
+ async def by_steam_channel(self, channel: Union[GroupChannel, ClanChannel]):
28
+ if channel.group:
29
+ parent = Parent(ParentType.GROUP, channel.group.id)
30
+ elif channel.clan:
31
+ parent = Parent(ParentType.CLAN, channel.clan.id)
32
+ else:
33
+ raise XMPPError(
34
+ "internal-server-error", "No group or clan associated to this channel"
35
+ )
36
+ return await self.by_legacy_id(ChannelId(parent, channel.id))
37
+
38
+ async def legacy_id_to_jid_local_part(self, legacy_id: ChannelId) -> str:
39
+ return str(legacy_id)
40
+
41
+ async def jid_local_part_to_legacy_id(self, local_part: str) -> ChannelId:
42
+ try:
43
+ return ChannelId.from_str(local_part)
44
+ except ValueError as e:
45
+ raise XMPPError("bad-request", f"This is not a valid sleamdge MUC id: {e}")
46
+
47
+ async def fill(self):
48
+ for group in self.session.steam.groups:
49
+ for group_channel in group.channels:
50
+ channel_id = ChannelId(
51
+ Parent(ParentType.GROUP, group.id), group_channel.id
52
+ )
53
+ muc = await self.by_legacy_id(channel_id)
54
+ await muc.add_to_bookmarks()
55
+ for clan in self.session.steam.clans:
56
+ for clan_channel in clan.channels:
57
+ channel_id = ChannelId(
58
+ Parent(ParentType.CLAN, clan.id), clan_channel.id
59
+ )
60
+ await self.by_legacy_id(channel_id)
61
+
62
+
63
+ class Participant(LegacyParticipant):
64
+ contact: Contact
65
+ muc: "MUC"
66
+ session: "Session"
67
+
68
+ async def update_reaction(self, reaction: MessageReaction) -> None:
69
+ message = await self.muc.fetch_message(reaction.message.id)
70
+
71
+ reactions = list[str]()
72
+ if emoticon := reaction.emoticon:
73
+ reactions.append(emojize(emoticon))
74
+ for r in message.reactions:
75
+ self.log.debug("add reaction: %s", r)
76
+ if emoticon := r.emoticon:
77
+ emoji = emojize(emoticon)
78
+ self.log.debug(
79
+ "add reaction: %s %s %s", emoji, r.user.id, self.contact.legacy_id
80
+ )
81
+ if r.user.id == self.contact.legacy_id:
82
+ reactions.append(emoji)
83
+ self.log.debug("updating reactions: %s %s", message.id, reactions)
84
+ self.react(message.id, reactions)
85
+
86
+
87
+ class MUC(LegacyMUC[ChannelId, int, Participant, int]):
88
+ session: "Session"
89
+
90
+ async def get_steam_channel(self) -> Union[GroupChannel, ClanChannel]:
91
+ parent_id = self.legacy_id.parent
92
+ parent: Optional[Union[Group, Clan]]
93
+ if parent_id.type == ParentType.GROUP:
94
+ parent = self.session.steam.get_group(parent_id.id)
95
+ else:
96
+ parent = self.session.steam.get_clan(parent_id.id)
97
+ if not parent:
98
+ raise XMPPError("internal-server-error", "No parent for this channel")
99
+ channel = parent.get_channel(self.legacy_id.channel_id)
100
+ if not channel:
101
+ raise XMPPError(
102
+ "item-not-found",
103
+ f"Channel {self.legacy_id.channel_id} not found in {parent}",
104
+ )
105
+ return channel
106
+
107
+ async def fetch_message(
108
+ self, legacy_msg_id: int
109
+ ) -> Union[GroupMessage, ClanMessage]:
110
+ msg = await (await self.get_steam_channel()).fetch_message(legacy_msg_id)
111
+ if msg is None:
112
+ raise XMPPError(
113
+ "item-not-found", f"Could not find the legacy message {legacy_msg_id}"
114
+ )
115
+ if not isinstance(msg, (GroupMessage, ClanMessage)):
116
+ raise XMPPError(
117
+ "internal-server-error", f"This is not a group message: '{type(msg)}'"
118
+ )
119
+ return msg
120
+
121
+ async def update_info(self):
122
+ channel = await self.get_steam_channel()
123
+ self.log.debug("Channel: %s", channel)
124
+ parent: Union[Group, Clan] = channel.group # type:ignore
125
+ if parent is None:
126
+ self.name = channel.name
127
+ self.type = MucType.GROUP
128
+ return
129
+
130
+ self.name = f"{parent.name}/{channel.name or 'Home'}"
131
+ self.description = parent.tagline
132
+ sha = parent.avatar.sha.hex()
133
+ if self.avatar != sha:
134
+ # TODO: use avatar.url when this is fixed upstream
135
+ await self.set_avatar(
136
+ f"https://avatars.akamai.steamstatic.com/{sha}_256.jpg", sha
137
+ )
138
+ if isinstance(parent, Clan):
139
+ self.type = MucType.CHANNEL_NON_ANONYMOUS
140
+ else:
141
+ self.type = MucType.GROUP
142
+
143
+ async def fill_participants(self) -> AsyncIterator[Participant]:
144
+ g = (await self.get_steam_channel()).group
145
+ if not g:
146
+ return
147
+ try:
148
+ for m in await g.chunk():
149
+ yield await self.get_participant_by_legacy_id(m.id)
150
+ except AttributeError:
151
+ # workaround for https://github.com/Gobot1234/steam.py/issues/565
152
+ for m in g.members:
153
+ yield await self.get_participant_by_legacy_id(m.id)
154
+
155
+ async def backfill(
156
+ self,
157
+ after: Optional[HoleBound] = None,
158
+ before: Optional[HoleBound] = None,
159
+ ):
160
+ c = await self.get_steam_channel()
161
+ async for msg in c.history(
162
+ before=None if before is None else before.timestamp,
163
+ after=None if after is None else after.timestamp,
164
+ ):
165
+ part = await self.get_participant_by_legacy_id(msg.author.id)
166
+ part.send_text(
167
+ msg.clean_content,
168
+ when=msg.created_at,
169
+ archive_only=True,
170
+ legacy_msg_id=msg.id,
171
+ )
172
+
173
+ async def available_emojis(self, legacy_msg_id=None) -> set[str]:
174
+ return EMOJIS_SET