slidge 0.1.0b2__py3-none-any.whl → 0.1.1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (155) hide show
  1. slidge/__init__.py +55 -31
  2. slidge/__main__.py +118 -116
  3. slidge/command/__init__.py +28 -0
  4. slidge/command/adhoc.py +258 -0
  5. slidge/command/admin.py +193 -0
  6. slidge/command/base.py +441 -0
  7. slidge/command/categories.py +3 -0
  8. slidge/command/chat_command.py +288 -0
  9. slidge/command/register.py +179 -0
  10. slidge/command/user.py +250 -0
  11. slidge/contact/__init__.py +8 -0
  12. slidge/contact/contact.py +452 -0
  13. slidge/contact/roster.py +192 -0
  14. slidge/core/__init__.py +2 -0
  15. slidge/core/cache.py +183 -0
  16. slidge/core/config.py +216 -0
  17. slidge/core/gateway/__init__.py +3 -0
  18. slidge/core/gateway/base.py +895 -0
  19. slidge/core/gateway/caps.py +63 -0
  20. slidge/core/gateway/delivery_receipt.py +52 -0
  21. slidge/core/gateway/disco.py +80 -0
  22. slidge/core/gateway/mam.py +75 -0
  23. slidge/core/gateway/muc_admin.py +35 -0
  24. slidge/core/gateway/ping.py +66 -0
  25. slidge/core/gateway/presence.py +95 -0
  26. slidge/core/gateway/registration.py +53 -0
  27. slidge/core/gateway/search.py +102 -0
  28. slidge/core/gateway/session_dispatcher.py +789 -0
  29. slidge/core/gateway/vcard_temp.py +130 -0
  30. slidge/core/mixins/__init__.py +19 -0
  31. slidge/core/mixins/attachment.py +506 -0
  32. slidge/core/mixins/avatar.py +167 -0
  33. slidge/core/mixins/base.py +31 -0
  34. slidge/core/mixins/disco.py +130 -0
  35. slidge/core/mixins/lock.py +31 -0
  36. slidge/core/mixins/message.py +398 -0
  37. slidge/core/mixins/message_maker.py +154 -0
  38. slidge/core/mixins/presence.py +217 -0
  39. slidge/core/mixins/recipient.py +43 -0
  40. slidge/core/pubsub.py +282 -116
  41. slidge/core/session.py +595 -372
  42. slidge/group/__init__.py +10 -0
  43. slidge/group/archive.py +125 -0
  44. slidge/group/bookmarks.py +163 -0
  45. slidge/group/participant.py +458 -0
  46. slidge/group/room.py +1103 -0
  47. slidge/migration.py +18 -0
  48. slidge/slixfix/__init__.py +68 -0
  49. slidge/{util/xep_0084 → slixfix/link_preview}/__init__.py +3 -5
  50. slidge/slixfix/link_preview/link_preview.py +17 -0
  51. slidge/slixfix/link_preview/stanza.py +99 -0
  52. slidge/slixfix/roster.py +60 -0
  53. slidge/{util → slixfix}/xep_0077/register.py +14 -2
  54. slidge/slixfix/xep_0077/stanza.py +104 -0
  55. slidge/{util → slixfix}/xep_0100/gateway.py +25 -15
  56. slidge/slixfix/xep_0100/stanza.py +9 -0
  57. slidge/slixfix/xep_0153/__init__.py +10 -0
  58. slidge/slixfix/xep_0153/stanza.py +25 -0
  59. slidge/slixfix/xep_0153/vcard_avatar.py +23 -0
  60. slidge/slixfix/xep_0264/__init__.py +5 -0
  61. slidge/slixfix/xep_0264/stanza.py +36 -0
  62. slidge/slixfix/xep_0264/thumbnail.py +23 -0
  63. slidge/slixfix/xep_0292/__init__.py +5 -0
  64. slidge/slixfix/xep_0292/vcard4.py +100 -0
  65. slidge/slixfix/xep_0313/__init__.py +12 -0
  66. slidge/slixfix/xep_0313/mam.py +262 -0
  67. slidge/slixfix/xep_0313/stanza.py +359 -0
  68. slidge/slixfix/xep_0317/__init__.py +5 -0
  69. slidge/slixfix/xep_0317/hats.py +17 -0
  70. slidge/slixfix/xep_0317/stanza.py +28 -0
  71. slidge/{util → slixfix}/xep_0356_old/privilege.py +9 -7
  72. slidge/slixfix/xep_0424/__init__.py +9 -0
  73. slidge/slixfix/xep_0424/retraction.py +77 -0
  74. slidge/slixfix/xep_0424/stanza.py +28 -0
  75. slidge/slixfix/xep_0490/__init__.py +8 -0
  76. slidge/slixfix/xep_0490/mds.py +47 -0
  77. slidge/slixfix/xep_0490/stanza.py +17 -0
  78. slidge/util/__init__.py +4 -6
  79. slidge/util/archive_msg.py +61 -0
  80. slidge/util/conf.py +206 -0
  81. slidge/util/db.py +57 -76
  82. slidge/util/schema.sql +126 -0
  83. slidge/util/sql.py +508 -0
  84. slidge/util/test.py +215 -25
  85. slidge/util/types.py +177 -4
  86. slidge/util/util.py +225 -59
  87. slidge-0.1.1.dist-info/METADATA +110 -0
  88. slidge-0.1.1.dist-info/RECORD +96 -0
  89. {slidge-0.1.0b2.dist-info → slidge-0.1.1.dist-info}/WHEEL +1 -1
  90. slidge/core/contact.py +0 -891
  91. slidge/core/gateway.py +0 -916
  92. slidge/plugins/discord/__init__.py +0 -90
  93. slidge/plugins/discord/client.py +0 -108
  94. slidge/plugins/discord/session.py +0 -162
  95. slidge/plugins/dummy.py +0 -203
  96. slidge/plugins/facebook.py +0 -493
  97. slidge/plugins/hackernews.py +0 -213
  98. slidge/plugins/mattermost/__init__.py +0 -1
  99. slidge/plugins/mattermost/api.py +0 -280
  100. slidge/plugins/mattermost/gateway.py +0 -365
  101. slidge/plugins/mattermost/websocket.py +0 -252
  102. slidge/plugins/signal/__init__.py +0 -3
  103. slidge/plugins/signal/contact.py +0 -106
  104. slidge/plugins/signal/gateway.py +0 -282
  105. slidge/plugins/signal/session.py +0 -448
  106. slidge/plugins/signal/txt.py +0 -53
  107. slidge/plugins/skype.py +0 -325
  108. slidge/plugins/steam.py +0 -310
  109. slidge/plugins/telegram/__init__.py +0 -5
  110. slidge/plugins/telegram/client.py +0 -228
  111. slidge/plugins/telegram/config.py +0 -12
  112. slidge/plugins/telegram/contact.py +0 -176
  113. slidge/plugins/telegram/gateway.py +0 -150
  114. slidge/plugins/telegram/session.py +0 -256
  115. slidge/util/xep_0030/__init__.py +0 -13
  116. slidge/util/xep_0030/disco.py +0 -811
  117. slidge/util/xep_0030/stanza/__init__.py +0 -7
  118. slidge/util/xep_0030/stanza/info.py +0 -270
  119. slidge/util/xep_0030/stanza/items.py +0 -147
  120. slidge/util/xep_0030/static.py +0 -467
  121. slidge/util/xep_0055/__init__.py +0 -5
  122. slidge/util/xep_0055/search.py +0 -75
  123. slidge/util/xep_0055/stanza.py +0 -10
  124. slidge/util/xep_0077/stanza.py +0 -71
  125. slidge/util/xep_0084/avatar.py +0 -137
  126. slidge/util/xep_0084/stanza.py +0 -104
  127. slidge/util/xep_0115/__init__.py +0 -12
  128. slidge/util/xep_0115/caps.py +0 -379
  129. slidge/util/xep_0115/stanza.py +0 -16
  130. slidge/util/xep_0115/static.py +0 -137
  131. slidge/util/xep_0292/__init__.py +0 -1
  132. slidge/util/xep_0292/stanza.py +0 -167
  133. slidge/util/xep_0292/vcard4.py +0 -75
  134. slidge/util/xep_0333/__init__.py +0 -10
  135. slidge/util/xep_0333/markers.py +0 -96
  136. slidge/util/xep_0333/stanza.py +0 -34
  137. slidge/util/xep_0356/__init__.py +0 -7
  138. slidge/util/xep_0356/permissions.py +0 -35
  139. slidge/util/xep_0356/privilege.py +0 -160
  140. slidge/util/xep_0356/stanza.py +0 -44
  141. slidge/util/xep_0363/__init__.py +0 -16
  142. slidge/util/xep_0363/http_upload.py +0 -215
  143. slidge/util/xep_0363/stanza.py +0 -46
  144. slidge/util/xep_0461/__init__.py +0 -6
  145. slidge/util/xep_0461/reply.py +0 -48
  146. slidge/util/xep_0461/stanza.py +0 -47
  147. slidge-0.1.0b2.dist-info/METADATA +0 -171
  148. slidge-0.1.0b2.dist-info/RECORD +0 -81
  149. /slidge/{plugins/__init__.py → py.typed} +0 -0
  150. /slidge/{util → slixfix}/xep_0077/__init__.py +0 -0
  151. /slidge/{util → slixfix}/xep_0100/__init__.py +0 -0
  152. /slidge/{util → slixfix}/xep_0356_old/__init__.py +0 -0
  153. /slidge/{util → slixfix}/xep_0356_old/stanza.py +0 -0
  154. {slidge-0.1.0b2.dist-info → slidge-0.1.1.dist-info}/LICENSE +0 -0
  155. {slidge-0.1.0b2.dist-info → slidge-0.1.1.dist-info}/entry_points.txt +0 -0
@@ -1,493 +0,0 @@
1
- import asyncio
2
- import io
3
- import logging
4
- import random
5
- import shelve
6
- from collections import OrderedDict, defaultdict
7
- from dataclasses import dataclass
8
- from mimetypes import guess_type
9
- from pathlib import Path
10
- from typing import Optional, Union
11
-
12
- import aiohttp
13
- import maufbapi.types.graphql
14
- from maufbapi import AndroidAPI, AndroidMQTT, AndroidState
15
- from maufbapi.types import mqtt as mqtt_t
16
- from maufbapi.types.graphql import Participant, ParticipantNode, Thread
17
- from maufbapi.types.graphql.responses import FriendshipStatus
18
- from slixmpp.exceptions import XMPPError
19
-
20
- from slidge import *
21
-
22
-
23
- class Gateway(BaseGateway):
24
- REGISTRATION_INSTRUCTIONS = "Enter facebook credentials"
25
- REGISTRATION_FIELDS = [
26
- FormField(var="email", label="Email", required=True),
27
- FormField(var="password", label="Password", required=True, private=True),
28
- ]
29
-
30
- ROSTER_GROUP = "Facebook"
31
-
32
- COMPONENT_NAME = "Facebook (slidge)"
33
- COMPONENT_TYPE = "facebook"
34
- COMPONENT_AVATAR = "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6c/Facebook_Messenger_logo_2018.svg/480px-Facebook_Messenger_logo_2018.svg.png"
35
-
36
- SEARCH_TITLE = "Search in your facebook friends"
37
- SEARCH_INSTRUCTIONS = "Enter something that can be used to search for one of your friends, eg, a first name"
38
- SEARCH_FIELDS = [FormField(var="query", label="Term(s)")]
39
-
40
-
41
- class Contact(LegacyContact["Session"]):
42
- legacy_id: str # facebook username, as in facebook.com/name.surname123
43
-
44
- def __init__(self, *a, **k):
45
- super(Contact, self).__init__(*a, **k)
46
- self._fb_id: Optional[int] = None
47
-
48
- async def fb_id(self):
49
- if self._fb_id is None:
50
- results = await self.session.api.search(
51
- self.legacy_id, entity_types=["user"]
52
- )
53
- for search_result in results.search_results.edges:
54
- result = search_result.node
55
- if (
56
- isinstance(result, Participant)
57
- and result.username == self.legacy_id
58
- ):
59
- self._fb_id = int(result.id)
60
- break
61
- else:
62
- raise XMPPError(
63
- "not-found", text=f"Cannot find the facebook ID of {self.legacy_id}"
64
- )
65
- self.session.contacts.by_fb_id_dict[self._fb_id] = self
66
- return self._fb_id
67
-
68
- async def populate_from_participant(
69
- self, participant: ParticipantNode, update_avatar=True
70
- ):
71
- if self.legacy_id != participant.messaging_actor.username:
72
- raise RuntimeError(
73
- "Attempted to populate a contact with a non-corresponding participant"
74
- )
75
- self.name = participant.messaging_actor.name
76
- self._fb_id = int(participant.id)
77
- if self.avatar is None or update_avatar:
78
- async with aiohttp.ClientSession() as session:
79
- async with session.get(
80
- participant.messaging_actor.profile_pic_large.uri
81
- ) as response:
82
- response.raise_for_status()
83
- self.avatar = await response.read()
84
-
85
-
86
- class Roster(LegacyRoster[Contact, "Session"]):
87
- def __init__(self, *a, **k):
88
- super().__init__(*a, **k)
89
- self.by_fb_id_dict: dict[int, Contact] = {}
90
-
91
- async def by_fb_id(self, fb_id: int) -> "Contact":
92
- contact = self.by_fb_id_dict.get(fb_id)
93
- if contact is None:
94
- thread = (await self.session.api.fetch_thread_info(fb_id))[0]
95
- return await self.by_thread(thread)
96
- return contact
97
-
98
- async def by_thread_key(self, t: mqtt_t.ThreadKey):
99
- if is_group_thread(t):
100
- raise ValueError("Thread seems to be a group thread")
101
- return await self.by_fb_id(t.other_user_id)
102
-
103
- async def by_thread(self, t: Thread):
104
- if t.is_group_thread:
105
- raise RuntimeError("Tried to populate a user from a group chat")
106
-
107
- if len(t.all_participants.nodes) != 2:
108
- raise RuntimeError(
109
- "Tried is not a group chat but doesn't have 2 participants ‽"
110
- )
111
-
112
- for participant in t.all_participants.nodes:
113
- if participant.id != self.session.me.id:
114
- break
115
- else:
116
- raise RuntimeError(
117
- "Couldn't find friend in thread participants", t.all_participants
118
- )
119
-
120
- contact = self.by_legacy_id(participant.messaging_actor.username)
121
- await contact.populate_from_participant(participant)
122
- self.by_fb_id_dict[int(participant.id)] = contact
123
- return contact
124
-
125
-
126
- class Session(BaseSession[Contact, Roster, Gateway]):
127
- fb_state: AndroidState
128
-
129
- shelf_path: Path
130
- mqtt: AndroidMQTT
131
- api: AndroidAPI
132
-
133
- me: maufbapi.types.graphql.OwnInfo
134
-
135
- sent_messages: defaultdict[int, "Messages"]
136
- received_messages: defaultdict[int, "Messages"]
137
- # keys = "contact ID"
138
-
139
- ack_futures: dict[int, asyncio.Future["FacebookMessage"]]
140
- # keys = "offline thread ID"
141
- reaction_futures: dict[str, asyncio.Future[None]]
142
- unsend_futures: dict[str, asyncio.Future[None]]
143
- # keys = "facebook message id"
144
-
145
- contacts: Roster
146
-
147
- def post_init(self):
148
- self.shelf_path = self.xmpp.home_dir / self.user.bare_jid
149
- self.ack_futures = {}
150
- self.reaction_futures: dict[str, asyncio.Future] = {}
151
- self.unsend_futures: dict[str, asyncio.Future] = {}
152
- self.sent_messages = defaultdict(Messages)
153
- self.received_messages = defaultdict(Messages)
154
-
155
- async def login(self):
156
- shelf: shelve.Shelf[AndroidState]
157
- with shelve.open(str(self.shelf_path)) as shelf:
158
- try:
159
- self.fb_state = s = shelf["state"]
160
- except KeyError:
161
- s = AndroidState()
162
- self.api = api = AndroidAPI(state=s)
163
- s.generate(random.randbytes(30)) # type: ignore
164
- await api.mobile_config_sessionless()
165
- try:
166
- login = await api.login(
167
- email=self.user.registration_form["email"],
168
- password=self.user.registration_form["password"],
169
- )
170
- except maufbapi.http.errors.IncorrectPassword:
171
- self.send_gateway_message("Incorrect password")
172
- raise
173
- except maufbapi.http.errors.TwoFactorRequired:
174
- code = await self.input(
175
- "Reply to this message with your 2 factor authentication code"
176
- )
177
- login = await api.login_2fa(
178
- email=self.user.registration_form["email"], code=code
179
- )
180
- log.debug("Login output: %s", login)
181
- self.fb_state = shelf["state"] = api.state
182
- else:
183
- self.api = api = AndroidAPI(state=s)
184
- self.mqtt = AndroidMQTT(api.state)
185
- self.me = await self.api.get_self()
186
- self.me.id = int(self.me.id) # bug in maufbapi?
187
- await self.add_friends()
188
- self.mqtt.seq_id_update_callback = lambda i: setattr(self.mqtt, "seq_id", i)
189
- self.mqtt.add_event_handler(mqtt_t.Message, self.on_fb_message)
190
- self.mqtt.add_event_handler(mqtt_t.ExtendedMessage, self.on_fb_message)
191
- self.mqtt.add_event_handler(mqtt_t.ReadReceipt, self.on_fb_message_read)
192
- self.mqtt.add_event_handler(mqtt_t.TypingNotification, self.on_fb_typing)
193
- self.mqtt.add_event_handler(mqtt_t.OwnReadReceipt, self.on_fb_user_read)
194
- self.mqtt.add_event_handler(mqtt_t.Reaction, self.on_fb_reaction)
195
- self.mqtt.add_event_handler(mqtt_t.UnsendMessage, self.on_fb_unsend)
196
-
197
- self.mqtt.add_event_handler(mqtt_t.NameChange, self.on_fb_event)
198
- self.mqtt.add_event_handler(mqtt_t.AvatarChange, self.on_fb_event)
199
- self.mqtt.add_event_handler(mqtt_t.Presence, self.on_fb_event)
200
- self.mqtt.add_event_handler(mqtt_t.AddMember, self.on_fb_event)
201
- self.mqtt.add_event_handler(mqtt_t.RemoveMember, self.on_fb_event)
202
- self.mqtt.add_event_handler(mqtt_t.ThreadChange, self.on_fb_event)
203
- self.mqtt.add_event_handler(mqtt_t.MessageSyncError, self.on_fb_event)
204
- self.mqtt.add_event_handler(mqtt_t.ForcedFetch, self.on_fb_event)
205
- # self.mqtt.add_event_handler(Connect, self.on_connect)
206
- # self.mqtt.add_event_handler(Disconnect, self.on_disconnect)
207
- self.xmpp.loop.create_task(self.mqtt.listen(self.mqtt.seq_id))
208
- return f"Connected as '{self.me.name} <{self.me.email}>'"
209
-
210
- async def add_friends(self, n=2):
211
- thread_list = await self.api.fetch_thread_list(msg_count=0, thread_count=n)
212
- self.mqtt.seq_id = int(thread_list.sync_sequence_id)
213
- self.log.debug("SEQ ID: %s", self.mqtt.seq_id)
214
- self.log.debug("Thread list: %s", thread_list)
215
- self.log.debug("Thread list page info: %s", thread_list.page_info)
216
- for t in thread_list.nodes:
217
- if t.is_group_thread:
218
- log.debug("Skipping group: %s", t)
219
- continue
220
- c = await self.contacts.by_thread(t)
221
- await c.add_to_roster()
222
- c.online()
223
-
224
- async def logout(self):
225
- pass
226
-
227
- async def send_text(self, t: str, c: Contact, *, reply_to_msg_id=None) -> str:
228
- resp: mqtt_t.SendMessageResponse = await self.mqtt.send_message(
229
- target=(fb_id := await c.fb_id()),
230
- message=t,
231
- is_group=False,
232
- reply_to=reply_to_msg_id,
233
- )
234
- fut = self.ack_futures[
235
- resp.offline_threading_id
236
- ] = self.xmpp.loop.create_future()
237
- log.debug("Send message response: %s", resp)
238
- if not resp.success:
239
- raise XMPPError(resp.error_message)
240
- fb_msg = await fut
241
- self.sent_messages[fb_id].add(fb_msg)
242
- return fb_msg.mid
243
-
244
- async def send_file(self, u: str, c: Contact, *, reply_to_msg_id=None):
245
- async with aiohttp.ClientSession() as s:
246
- async with s.get(u) as r:
247
- data = await r.read()
248
- oti = self.mqtt.generate_offline_threading_id()
249
- fut = self.ack_futures[oti] = self.xmpp.loop.create_future()
250
- resp = await self.api.send_media(
251
- data=data,
252
- file_name=u.split("/")[-1],
253
- mimetype=guess_type(u)[0] or "application/octet-stream",
254
- offline_threading_id=oti,
255
- chat_id=await c.fb_id(),
256
- is_group=False,
257
- reply_to=reply_to_msg_id,
258
- )
259
- ack = await fut
260
- log.debug("Upload ack: %s", ack)
261
- return resp.media_id
262
-
263
- async def active(self, c: Contact):
264
- pass
265
-
266
- async def inactive(self, c: Contact):
267
- pass
268
-
269
- async def composing(self, c: Contact):
270
- await self.mqtt.set_typing(target=await c.fb_id())
271
-
272
- async def paused(self, c: Contact):
273
- await self.mqtt.set_typing(target=await c.fb_id(), typing=False)
274
-
275
- async def displayed(self, legacy_msg_id: str, c: Contact):
276
- fb_id = await c.fb_id()
277
- try:
278
- t = self.received_messages[fb_id].by_mid[legacy_msg_id].timestamp_ms
279
- except KeyError:
280
- log.debug("Cannot find the timestamp of %s", legacy_msg_id)
281
- else:
282
- await self.mqtt.mark_read(target=fb_id, read_to=t, is_group=False)
283
-
284
- async def on_fb_message(self, evt: Union[mqtt_t.Message, mqtt_t.ExtendedMessage]):
285
- if isinstance(evt, mqtt_t.ExtendedMessage):
286
- reply_to = evt.reply_to_message.metadata.id
287
- msg = evt.message
288
- else:
289
- reply_to = None
290
- msg = evt
291
- meta = msg.metadata
292
- if is_group_thread(thread_key := meta.thread):
293
- return
294
- contact = await self.contacts.by_thread_key(thread_key)
295
-
296
- if not contact.added_to_roster:
297
- await contact.add_to_roster()
298
-
299
- log.debug("Facebook message: %s", evt)
300
- fb_msg = FacebookMessage(mid=meta.id, timestamp_ms=meta.timestamp)
301
- if meta.sender == self.me.id:
302
- try:
303
- fut = self.ack_futures.pop(meta.offline_threading_id)
304
- except KeyError:
305
- log.debug("Received carbon %s - %s", meta.id, msg.text)
306
- contact.carbon(body=msg.text, legacy_id=meta.id)
307
- log.debug("Sent carbon")
308
- self.sent_messages[thread_key.other_user_id].add(fb_msg)
309
- else:
310
- log.debug("Received echo of %s", meta.offline_threading_id)
311
- fut.set_result(fb_msg)
312
- else:
313
- if msg.text:
314
- contact.send_text(
315
- msg.text, legacy_msg_id=meta.id, reply_to_msg_id=reply_to
316
- )
317
- if msg.attachments:
318
- async with aiohttp.ClientSession() as c:
319
- for a in msg.attachments:
320
- try:
321
- url = (
322
- ((v := a.video_info) and v.download_url)
323
- or ((au := a.audio_info) and au.url)
324
- or a.image_info.uri_map.get(0)
325
- )
326
- except AttributeError:
327
- log.warning("Unhandled attachment: %s", a)
328
- contact.send_text(
329
- "/me sent an attachment that slidge does not support"
330
- )
331
- continue
332
- if url is None:
333
- continue
334
- async with c.get(url) as r:
335
- await contact.send_file(
336
- filename=a.file_name,
337
- content_type=a.mime_type,
338
- input_file=io.BytesIO(await r.read()),
339
- )
340
- self.received_messages[thread_key.other_user_id].add(fb_msg)
341
-
342
- async def on_fb_message_read(self, receipt: mqtt_t.ReadReceipt):
343
- log.debug("Facebook read: %s", receipt)
344
- try:
345
- mid = self.sent_messages[receipt.user_id].pop_up_to(receipt.read_to).mid
346
- except KeyError:
347
- log.debug("Cannot find MID of %s", receipt.read_to)
348
- else:
349
- contact = await self.contacts.by_thread_key(receipt.thread)
350
- contact.displayed(mid)
351
-
352
- async def on_fb_typing(self, notification: mqtt_t.TypingNotification):
353
- log.debug("Facebook typing: %s", notification)
354
- c = await self.contacts.by_fb_id(notification.user_id)
355
- if notification.typing_status:
356
- c.composing()
357
- else:
358
- c.paused()
359
-
360
- async def on_fb_user_read(self, receipt: mqtt_t.OwnReadReceipt):
361
- log.debug("Facebook own read: %s", receipt)
362
- when = receipt.read_to
363
- for thread in receipt.threads:
364
- c = await self.contacts.by_fb_id(thread.other_user_id)
365
- try:
366
- mid = self.received_messages[await c.fb_id()].pop_up_to(when).mid
367
- except KeyError:
368
- log.debug("Cannot find mid of %s", when)
369
- continue
370
- c.carbon_read(mid)
371
-
372
- async def on_fb_reaction(self, reaction: mqtt_t.Reaction):
373
- self.log.debug("Reaction: %s", reaction)
374
- if is_group_thread(tk := reaction.thread):
375
- return
376
- contact = await self.contacts.by_thread_key(tk)
377
- mid = reaction.message_id
378
- if reaction.reaction_sender_id == self.me.id:
379
- try:
380
- f = self.reaction_futures.pop(mid)
381
- except KeyError:
382
- contact.carbon_react(mid, reaction.reaction or "")
383
- else:
384
- f.set_result(None)
385
- else:
386
- contact.react(reaction.message_id, reaction.reaction or "")
387
-
388
- async def on_fb_unsend(self, unsend: mqtt_t.UnsendMessage):
389
- self.log.debug("Unsend: %s", unsend)
390
- if is_group_thread(tk := unsend.thread):
391
- return
392
- contact = await self.contacts.by_thread_key(tk)
393
- mid = unsend.message_id
394
- if unsend.user_id == self.me.id:
395
- try:
396
- f = self.unsend_futures.pop(mid)
397
- except KeyError:
398
- contact.carbon_retract(mid)
399
- else:
400
- f.set_result(None)
401
- else:
402
- contact.retract(unsend.message_id)
403
-
404
- async def correct(self, text: str, legacy_msg_id: str, c: Contact):
405
- await self.api.unsend(legacy_msg_id)
406
- return await self.send_text(text, c)
407
-
408
- async def react(self, legacy_msg_id: str, emojis: list[str], c: Contact):
409
- if len(emojis) == 0:
410
- emoji = None
411
- else:
412
- emoji = emojis[-1]
413
- if len(emojis) > 1: # only reaction per msg on facebook
414
- c.carbon_react(legacy_msg_id, emoji)
415
- f = self.reaction_futures[legacy_msg_id] = self.xmpp.loop.create_future()
416
- await self.api.react(legacy_msg_id, emoji)
417
- await f
418
-
419
- async def retract(self, legacy_msg_id: str, c: Contact):
420
- f = self.unsend_futures[legacy_msg_id] = self.xmpp.loop.create_future()
421
- await self.api.unsend(legacy_msg_id)
422
- await f
423
-
424
- async def search(self, form_values: dict[str, str]) -> SearchResult:
425
- results = await self.api.search(form_values["query"], entity_types=["user"])
426
- log.debug("Search results: %s", results)
427
- items = []
428
- for search_result in results.search_results.edges:
429
- result = search_result.node
430
- if isinstance(result, Participant):
431
- is_friend = (
432
- friend := result.friendship_status
433
- ) is not None and friend == FriendshipStatus.ARE_FRIENDS
434
- items.append(
435
- {
436
- "name": result.name + " (friend)"
437
- if is_friend
438
- else " (not friend)",
439
- "jid": f"{result.username}@{self.xmpp.boundjid.bare}",
440
- }
441
- )
442
-
443
- return SearchResult(
444
- fields=[
445
- FormField(var="name", label="Name"),
446
- FormField(var="jid", label="JID", type="jid-single"),
447
- ],
448
- items=items,
449
- )
450
-
451
- @staticmethod
452
- async def on_fb_event(evt):
453
- log.debug("Facebook event: %s", evt)
454
-
455
-
456
- @dataclass
457
- class FacebookMessage:
458
- mid: str
459
- timestamp_ms: int
460
-
461
-
462
- class Messages:
463
- def __init__(self):
464
- self.by_mid: OrderedDict[str, FacebookMessage] = OrderedDict()
465
- self.by_timestamp_ms: OrderedDict[int, FacebookMessage] = OrderedDict()
466
-
467
- def __len__(self):
468
- return len(self.by_mid)
469
-
470
- def add(self, m: FacebookMessage):
471
- self.by_mid[m.mid] = m
472
- self.by_timestamp_ms[m.timestamp_ms] = m
473
-
474
- def pop_up_to(self, approx_t: int) -> FacebookMessage:
475
- i = 0
476
- for i, t in enumerate(self.by_timestamp_ms.keys()):
477
- if t > approx_t:
478
- i -= 1
479
- break
480
- for j, t in enumerate(list(self.by_timestamp_ms.keys())):
481
- msg = self.by_timestamp_ms.pop(t)
482
- self.by_mid.pop(msg.mid)
483
- if j == i:
484
- return msg
485
- else:
486
- raise KeyError(approx_t)
487
-
488
-
489
- def is_group_thread(t: mqtt_t.ThreadKey):
490
- return t.other_user_id is None and t.thread_fbid is not None
491
-
492
-
493
- log = logging.getLogger(__name__)
@@ -1,213 +0,0 @@
1
- """
2
- Hackernews slidge plugin
3
-
4
- Will poll replies to items you've posted.
5
- For every reply, the chat window '<REPLY_ITEM_ID>@<BRIDGE_JID>' should open.
6
- It will contain your original post (as a carbon) and its reply as a normal chat message.
7
- You can re-reply by replying in your XMPP client directly.
8
- """
9
-
10
- import asyncio
11
- import logging
12
- import re
13
- from datetime import datetime
14
- from typing import Any, Optional
15
-
16
- import aiohttp
17
- from slixmpp import JID
18
- from slixmpp.exceptions import XMPPError
19
-
20
- from slidge import *
21
-
22
-
23
- class Gateway(BaseGateway):
24
- # FIXME: implement proper login process, but we might have to do something to handle captcha
25
- REGISTRATION_INSTRUCTIONS = (
26
- "Enter the hackernews cookie from your browser's dev console "
27
- "(something like your-user-name ampersand XXXXXXXXXXXXXXXXXXXXXXXXX)"
28
- )
29
- REGISTRATION_FIELDS = [
30
- FormField(var="cookie", label="'user' cookie", required=True),
31
- ]
32
-
33
- ROSTER_GROUP = "HN" # Not used, we don't add anything to the roster
34
-
35
- COMPONENT_NAME = "Hackernews (slidge)"
36
- COMPONENT_TYPE = "hackernews"
37
-
38
- COMPONENT_AVATAR = "https://news.ycombinator.com/favicon.ico"
39
-
40
- async def validate(
41
- self, user_jid: JID, registration_form: dict[str, Optional[str]]
42
- ):
43
- if registration_form["cookie"] is None:
44
- raise ValueError("A cookie is required")
45
- async with aiohttp.ClientSession(
46
- cookies={"user": registration_form["cookie"]}
47
- ) as session:
48
- async with session.get(LOGIN_URL, allow_redirects=False) as r:
49
- log.debug("Login response: %s - %s", r.status, await r.text())
50
- if r.status != 302:
51
- raise ValueError("Cookie does not seem valid")
52
-
53
-
54
- class Session(BaseSession[LegacyContact, LegacyRoster, Gateway]):
55
- http_session: aiohttp.ClientSession
56
- highest_handled_submission_id: int
57
- hn_username: str
58
-
59
- def post_init(self):
60
- self.http_session = aiohttp.ClientSession(
61
- cookies={"user": self.user.registration_form["cookie"]}
62
- )
63
- self.highest_handled_submission_id = 0
64
- self.hn_username = self.user.registration_form["cookie"].split("&")[0]
65
-
66
- async def login(self):
67
- kid_ids: list[int] = []
68
- for submission_id in await self.get_user_submissions():
69
- user_submission = await self.get_item(submission_id)
70
- for kid_id in user_submission.get("kids", []):
71
- kid_ids.append(kid_id)
72
-
73
- if kid_ids:
74
- self.highest_handled_submission_id = max(kid_ids)
75
-
76
- self.xmpp.loop.create_task(self.main_loop())
77
- return f"Logged as {self.hn_username}"
78
-
79
- async def main_loop(self):
80
- kid_ids: list[int] = []
81
- while True:
82
- kid_ids.clear()
83
- for submission_id in await self.get_user_submissions():
84
- try:
85
- user_submission = await self.get_item(submission_id)
86
- except aiohttp.ContentTypeError as e:
87
- log.warning("Hackernews API problem: %s")
88
- log.exception(e)
89
- continue
90
- for kid_id in user_submission.get("kids", []):
91
- if kid_id <= self.highest_handled_submission_id:
92
- continue
93
- await self.send_own_and_reply(user_submission, kid_id)
94
- kid_ids.append(kid_id)
95
- if kid_ids:
96
- self.highest_handled_submission_id = max(kid_ids)
97
- await asyncio.sleep(POLL_INTERVAL)
98
-
99
- async def get_user_submissions(self) -> list[int]:
100
- log.debug("Getting user subs: %s", self.hn_username)
101
- async with self.http_session.get(
102
- f"{API_URL}/user/{self.hn_username}.json"
103
- ) as r:
104
- if r.status != 200:
105
- log.warning("Bad response from API: %s", r)
106
- raise RuntimeError
107
- return (await r.json())["submitted"]
108
-
109
- async def send_own_and_reply(self, user_submission, reply_id):
110
- contact: LegacyContact = self.contacts.by_legacy_id(reply_id)
111
- date = datetime.fromtimestamp(user_submission["time"])
112
- contact.carbon(
113
- parse_comment_text(user_submission["text"]),
114
- when=date,
115
- )
116
- kid = await self.get_item(reply_id)
117
- contact.send_text(parse_comment_text(kid["text"]))
118
-
119
- async def get_item(self, item_id):
120
- async with self.http_session.get(f"{API_URL}/item/{item_id}.json") as r:
121
- return await r.json()
122
-
123
- async def logout(self):
124
- pass
125
-
126
- async def send_text(self, t: str, c: LegacyContact, *, reply_to_msg_id=None):
127
- goto = f"threads?id={self.hn_username}#{c.legacy_id}"
128
- url = f"{REPLY_URL}?id={c.legacy_id}&goto={goto}"
129
- async with self.http_session.get(url) as r:
130
- if r.status != 200:
131
- raise XMPPError(text="Couldn't get the post reply web page from HN")
132
- form_page_content = await r.text()
133
- match = re.search(HMAC_RE, form_page_content)
134
-
135
- if match is None:
136
- raise XMPPError(
137
- text="Couldn't find the HMAC hidden input on the comment reply thread"
138
- )
139
-
140
- await asyncio.sleep(SLEEP_BEFORE_POST)
141
-
142
- form_dict = {
143
- "hmac": match.group(1),
144
- "parent": c.legacy_id,
145
- "text": t,
146
- "goto": goto,
147
- }
148
-
149
- form = aiohttp.FormData(form_dict)
150
- async with self.http_session.post(REPLY_POST_URL, data=form) as r:
151
- first_attempt_html_content = await r.text()
152
-
153
- log.debug("Reply response #1: %s", r)
154
- if r.status != 200:
155
- raise XMPPError(text=f"Problem replying: {r}")
156
-
157
- if REPOST_TEXT in first_attempt_html_content:
158
- match = re.search(HMAC_RE, first_attempt_html_content)
159
- if match is None:
160
- raise XMPPError(
161
- text="We should repost but haven't found any hmac field on the repost page"
162
- )
163
-
164
- await asyncio.sleep(SLEEP_BEFORE_POST2)
165
- form = aiohttp.FormData(form_dict | {"hmac": match.group(1)})
166
- async with self.http_session.post(REPLY_POST_URL, data=form) as r:
167
- log.debug("Reply response #2: %s", r)
168
- if r.status != 200:
169
- raise XMPPError(text=f"Problem replying: {r}")
170
-
171
- # none of the following make sense in a HN context,
172
- # this is just to avoid raising NotImplementedErrors
173
- async def send_file(self, u: str, c: LegacyContact, *, reply_to_msg_id=None):
174
- pass
175
-
176
- async def active(self, c: LegacyContact):
177
- pass
178
-
179
- async def inactive(self, c: LegacyContact):
180
- pass
181
-
182
- async def composing(self, c: LegacyContact):
183
- pass
184
-
185
- async def paused(self, c: LegacyContact):
186
- pass
187
-
188
- async def displayed(self, legacy_msg_id: Any, c: LegacyContact):
189
- pass
190
-
191
- async def correct(self, text: str, legacy_msg_id: Any, c: LegacyContact):
192
- pass
193
-
194
- async def search(self, form_values: dict[str, str]):
195
- pass
196
-
197
-
198
- def parse_comment_text(text: str):
199
- # TODO: use regex or something more efficient here
200
- return text.replace("<p>", "\n").replace("&#x27;", "'").replace("&#x2F;", "/")
201
-
202
-
203
- LOGIN_URL = "https://news.ycombinator.com/login"
204
- REPLY_URL = "https://news.ycombinator.com/reply"
205
- REPLY_POST_URL = "https://news.ycombinator.com/comment"
206
- API_URL = "https://hacker-news.firebaseio.com/v0"
207
- POLL_INTERVAL = 30 # seconds
208
- SLEEP_BEFORE_POST = 30 # seconds
209
- SLEEP_BEFORE_POST2 = 5 # seconds
210
- REPOST_TEXT = "<tr><td>Please confirm that this is your comment by submitting it one"
211
- HMAC_RE = re.compile(r'name="hmac" value="([a-zA-Z\d]*)"')
212
-
213
- log = logging.getLogger(__name__)