···
1
+
import cross, media_util, util, database
2
+
from util import LOGGER
3
+
import requests, websockets
5
+
from typing import Callable, Any
9
+
URL = re.compile(r"(?:https?://|mailto:|localhost\b)[\w\-\._~:/\?#\[\]@!\$&'\(\)\*\+,;=%]+")
10
+
MD_INLINE_LINK = re.compile(r"\[([^\]]+)\]\(([^\)]+)\)")
11
+
MD_AUTOLINK = re.compile(r"<((?:https?://[^\s>]+|mailto:[^\s>]+))>")
12
+
HASHTAG = re.compile(r'(?<!\w)\#([\w]+)')
13
+
FEDIVERSE_HANDLE = re.compile(r'(?<![\w@])@([\w-]+)(?:@([\w\.-]+\.[\w\.-]+))?')
15
+
def get_image_common(mime: str):
16
+
if mime.startswith('image/'):
17
+
if mime == 'image/gif':
20
+
elif mime.startswith('video/'):
22
+
elif mime.startswith('audio/'):
27
+
def tokenize_note(note: dict) -> list[cross.Token]:
28
+
text: str = note.get('text', '')
32
+
mention_handles: dict = util.safe_get(note, 'mentionHandles', {})
33
+
handles: list[str] = []
35
+
for key, value in mention_handles.items():
36
+
handles.append(value)
38
+
tags: list[str] = util.safe_get(note, 'tags', [])
41
+
total: int = len(text)
42
+
buffer: list[str] = []
44
+
tokens: list[cross.Token] = []
49
+
tokens.append(cross.TextToken(''.join(buffer)))
52
+
while index < total:
53
+
if text[index] == '[':
54
+
md_inline = MD_INLINE_LINK.match(text, index)
57
+
label = md_inline.group(1)
58
+
href = md_inline.group(2)
59
+
tokens.append(cross.LinkToken(href, label))
60
+
index = md_inline.end()
63
+
if text[index] == '<':
64
+
md_auto = MD_AUTOLINK.match(text, index)
67
+
href = md_auto.group(1)
68
+
tokens.append(cross.LinkToken(href, href))
69
+
index = md_auto.end()
72
+
if text[index] == '#':
73
+
tag = HASHTAG.match(text, index)
75
+
tag_text = tag.group(1)
76
+
if tag_text.lower() in tags:
78
+
tokens.append(cross.TagToken(tag_text))
82
+
if text[index] == '@':
83
+
handle = FEDIVERSE_HANDLE.match(text, index)
85
+
handle_text = handle.group(0)
86
+
if handle_text.strip() in handles:
88
+
tokens.append(cross.MentionToken(handle_text, '')) # TODO misskey doesn't provide a uri
89
+
index = handle.end()
92
+
url = URL.match(text, index)
96
+
tokens.append(cross.LinkToken(href, href))
100
+
buffer.append(text[index])
106
+
class MisskeyPost(cross.Post):
107
+
def __init__(self, note: dict) -> None:
111
+
media_attachments: list[cross.MediaAttachment] = []
114
+
for attachment in note.get('files', []):
115
+
media_attachments.append(MisskeyAttachment(attachment))
116
+
sensitive |= attachment.get('isSensitive', False)
118
+
self.sensitive = sensitive
119
+
self.media_attachments = media_attachments
121
+
self.tokens = tokenize_note(self.note)
123
+
def get_tokens(self) -> list[cross.Token]:
126
+
def get_parent_id(self) -> str | None:
127
+
return self.note.get('replyId')
129
+
def get_attachments(self) -> list[cross.MediaAttachment]:
130
+
return self.media_attachments
132
+
def get_id(self) -> str:
133
+
return self.note['id']
135
+
def get_cw(self) -> str:
136
+
return util.safe_get(self.note, 'cw', '')
138
+
def get_languages(self) -> list[str]:
141
+
def is_sensitive(self) -> bool:
142
+
return self.sensitive
144
+
class MisskeyAttachment(cross.MediaAttachment):
145
+
def __init__(self, attachment: dict) -> None:
147
+
self.attachment = attachment
149
+
def create_meta(self, bytes: bytes) -> cross.MediaMeta:
150
+
# it's nort worth it
151
+
if get_image_common(self.attachment['type']):
152
+
o_meta = media_util.get_media_meta(bytes)
153
+
return cross.MediaMeta(o_meta['width'], o_meta['height'], o_meta.get('duration', -1))
154
+
return cross.MediaMeta(-1, -1, -1)
156
+
def get_url(self) -> str:
157
+
return self.attachment.get('url', '')
159
+
def get_type(self) -> str | None:
160
+
return get_image_common(self.attachment['type'])
162
+
def get_alt(self) -> str:
163
+
return util.safe_get(self.attachment, 'comment', '')
165
+
class MisskeyInput(cross.Input):
166
+
def __init__(self, settings: dict, db: cross.DataBaseWorker) -> None:
167
+
self.options = settings.get('options', {})
168
+
self.token = util.get_or_envvar(settings, 'token')
169
+
instance: str = util.get_or_envvar(settings, 'instance')
171
+
service = instance[:-1] if instance.endswith('/') else instance
173
+
LOGGER.info("Verifying %s credentails...", service)
174
+
responce = requests.post(f"{instance}/api/i", json={ 'i': self.token }, headers={
175
+
"Content-Type": "application/json"
177
+
if responce.status_code != 200:
178
+
LOGGER.error("Failed to validate user credentials!")
179
+
responce.raise_for_status()
182
+
super().__init__(service, responce.json()["id"], settings, db)
184
+
def _on_note(self, outputs: list[cross.Output], note: dict):
185
+
if note['userId'] != self.user_id:
188
+
if note.get('renoteId') or note.get('poll'):
189
+
# TODO polls not supported on bsky. maybe 3rd party? skip for now
190
+
# we don't handle reblogs. possible with bridgy(?) and self
191
+
LOGGER.info("Skipping '%s'! Renote or poll..", note['id'])
194
+
reply_id: str | None = note.get('replyId')
196
+
if note.get('reply', {}).get('userId') != self.user_id:
197
+
LOGGER.info("Skipping '%s'! Reply to other user..", note['id'])
200
+
if note.get('visibility') not in self.options.get('allowed_visibility', []):
201
+
LOGGER.info("Skipping '%s'! '%s' visibility..", note['id'], note.get('visibility'))
207
+
parent_post = database.find_post(self.db, reply_id, self.user_id, self.service)
208
+
if not parent_post:
209
+
LOGGER.info("Skipping '%s' as parent post was not found in db!", note['id'])
212
+
root_id = parent_post['id']
213
+
parent_id = root_id
214
+
if parent_post['root_id']:
215
+
root_id = parent_post['root_id']
217
+
LOGGER.info("Crossposting '%s'...", note['id'])
218
+
if root_id and parent_id:
219
+
database.insert_reply(
228
+
database.insert_post(
235
+
cross_post = MisskeyPost(note)
236
+
for output in outputs:
237
+
output.accept_post(cross_post)
239
+
def _on_delete(self, outputs: list[cross.Output], note: dict):
242
+
def _on_message(self, outputs: list[cross.Output], data: dict):
244
+
if data['type'] == 'channel':
245
+
type: str = data['body']['type']
246
+
if type == 'note' or type == 'reply':
247
+
note_body = data['body']['body']
248
+
self._on_note(outputs, note_body)
253
+
async def _send_keepalive(self, ws: websockets.WebSocketClientProtocol):
256
+
await asyncio.sleep(120)
259
+
LOGGER.debug("Sent keepalive h..")
261
+
LOGGER.info("WebSocket is closed, stopping keepalive task.")
263
+
except Exception as e:
264
+
LOGGER.error(f"Error sending keepalive: {e}")
267
+
async def _listen_for_messages(
269
+
ws: websockets.WebSocketClientProtocol,
270
+
submit: Callable[[Callable[[], Any]], Any],
271
+
outputs: list[cross.Output]):
273
+
async for msg in ws:
274
+
data = json.loads(msg)
276
+
# TODO listen to deletes somehow
277
+
if False and data['type'] == 'channel':
278
+
payload_type = data['body']['type']
279
+
if payload_type == 'reply' or payload_type == 'note':
280
+
user_id = data['body']['body']['userId']
281
+
if self.user_id == user_id:
282
+
note_id = data['body']['body']['id']
283
+
await ws.send(json.dumps({
289
+
LOGGER.info('Subscribed to note %s updates.', note_id)
291
+
submit(lambda: self._on_message(outputs, data))
293
+
async def _subscribe_to_home(self, ws: websockets.WebSocketClientProtocol):
294
+
home_message = json.dumps({
297
+
"channel": "homeTimeline",
298
+
"id": str(uuid.uuid4())
301
+
await ws.send(home_message)
302
+
LOGGER.info("Subscribed to 'homeTimeline' channel...")
305
+
async def listen(self, outputs: list[cross.Output], submit: Callable[[Callable[[], Any]], Any]):
306
+
streaming: str = f"wss://{self.service.split("://", 1)[1]}"
307
+
url: str = f"{streaming}/streaming?i={self.token}"
309
+
async for ws in websockets.connect(url, extra_headers={"User-Agent": "XPost/0.0.2"}):
311
+
LOGGER.info("Listening to %s...", streaming)
312
+
await self._subscribe_to_home(ws)
314
+
keepalive = asyncio.create_task(self._send_keepalive(ws))
315
+
listen = asyncio.create_task(self._listen_for_messages(ws, submit, outputs))
317
+
await asyncio.gather(keepalive, listen)
318
+
except websockets.ConnectionClosedError as e:
319
+
LOGGER.error(e, stack_info=True, exc_info=True)
320
+
LOGGER.info("Reconnecting to %s...", streaming)