tangled
alpha
login
or
join now
zenfyr.dev
/
xpost
2
fork
atom
social media crossposting tool. 3rd time's the charm
mastodon
misskey
crossposting
bluesky
2
fork
atom
overview
issues
1
pulls
pipelines
some progress
zenfyr.dev
3 months ago
ac341580
c956a17f
verified
This commit was signed with the committer's
known signature
.
zenfyr.dev
SSH Key Fingerprint:
SHA256:TtcIcnTnoAB5mqHofsaOxIgiMzfVBxej1AXT7DQdrTE=
1/1
run-tests.yml
success
57s
+192
-5
3 changed files
expand all
collapse all
unified
split
cross
post.py
service.py
mastodon
output.py
+1
cross/post.py
···
31
31
id: str
32
32
parent_id: str | None
33
33
tokens: list[Token]
34
34
+
text_type: str = "text/plain"
34
35
attachments: AttachmentKeeper = field(default_factory=AttachmentKeeper)
+57
-5
cross/service.py
···
1
1
+
import logging
1
2
import sqlite3
2
3
from abc import ABC, abstractmethod
3
4
from typing import Any, Callable, cast
4
4
-
import logging
5
5
6
6
from cross.post import Post
7
7
from database.connection import DatabasePool
···
44
44
_ = cursor.execute("SELECT * FROM posts WHERE id = ?", (id,))
45
45
return cast(sqlite3.Row, cursor.fetchone())
46
46
47
47
+
def _get_mappings(
48
48
+
self, original: int, service: str, user: str
49
49
+
) -> list[sqlite3.Row]:
50
50
+
cursor = self.db.get_conn().cursor()
51
51
+
_ = cursor.execute(
52
52
+
"""
53
53
+
SELECT *
54
54
+
FROM posts AS p
55
55
+
JOIN mappings AS m
56
56
+
ON p.id = m.mapped
57
57
+
WHERE m.original = ?
58
58
+
AND p.service = ?
59
59
+
AND p.user = ?
60
60
+
ORDER BY p.id;
61
61
+
""",
62
62
+
(original, service, user),
63
63
+
)
64
64
+
return cursor.fetchall()
65
65
+
66
66
+
def _find_mapped_thread(
67
67
+
self, parent: str, iservice: str, iuser: str, oservice: str, ouser: str
68
68
+
):
69
69
+
reply_data = self._get_post(iservice, iuser, parent)
70
70
+
if not reply_data:
71
71
+
return None
72
72
+
73
73
+
reply_mappings: list[sqlite3.Row] | None = self._get_mappings(
74
74
+
reply_data["id"], oservice, ouser
75
75
+
)
76
76
+
if not reply_mappings:
77
77
+
return None
78
78
+
79
79
+
reply_identifier: sqlite3.Row = reply_mappings[-1]
80
80
+
root_identifier: sqlite3.Row = reply_mappings[0]
81
81
+
82
82
+
if reply_data["root_id"]:
83
83
+
root_data = self._get_post_by_id(reply_data["root_id"])
84
84
+
if not root_data:
85
85
+
return None
86
86
+
87
87
+
root_mappings = self._get_mappings(reply_data["root_id"], oservice, ouser)
88
88
+
if not root_mappings:
89
89
+
return None
90
90
+
root_identifier = root_mappings[0]
91
91
+
92
92
+
return (
93
93
+
root_identifier[0], # real ids
94
94
+
reply_identifier[0],
95
95
+
reply_data["root_id"], # db ids
96
96
+
reply_data["id"],
97
97
+
)
98
98
+
47
99
def _insert_post(self, post_data: dict[str, Any]):
48
100
values = [post_data.get(col) for col in columns]
49
101
cursor = self.db.get_conn().cursor()
···
80
132
81
133
82
134
class OutputService(Service):
83
83
-
def accept_post(self, post: Post):
135
135
+
def accept_post(self, service: str, user: str, post: Post):
84
136
self.log.warning("NOT IMPLEMENTED (%s), accept_post %s", self.url, post.id)
85
137
86
86
-
def delete_post(self, post_id: str):
138
138
+
def delete_post(self, service: str, user: str, post_id: str):
87
139
self.log.warning("NOT IMPLEMENTED (%s), delete_post %s", self.url, post_id)
88
140
89
89
-
def accept_repost(self, repost_id: str, reposted_id: str):
141
141
+
def accept_repost(self, service: str, user: str, repost_id: str, reposted_id: str):
90
142
self.log.warning(
91
143
"NOT IMPLEMENTED (%s), accept_repost %s of %s",
92
144
self.url,
···
94
146
reposted_id,
95
147
)
96
148
97
97
-
def delete_repost(self, repost_id: str):
149
149
+
def delete_repost(self, service: str, user: str, repost_id: str):
98
150
self.log.warning("NOT IMPLEMENTED (%s), delete_repost %s", self.url, repost_id)
99
151
100
152
+134
mastodon/output.py
···
1
1
from dataclasses import dataclass
2
2
from typing import Any, override
3
3
4
4
+
import requests
5
5
+
6
6
+
from cross.attachments import (
7
7
+
LanguagesAttachment,
8
8
+
QuoteAttachment,
9
9
+
RemoteUrlAttachment,
10
10
+
SensitiveAttachment,
11
11
+
)
12
12
+
from cross.post import Post
4
13
from cross.service import OutputService
5
14
from database.connection import DatabasePool
6
15
from mastodon.info import InstanceInfo, MastodonService, validate_and_transform
···
38
47
self.log.info("Getting %s configuration...", self.url)
39
48
response = self.fetch_instance_info()
40
49
self.instance_info: InstanceInfo = InstanceInfo.from_api(response)
50
50
+
51
51
+
def accept_post(self, service: str, user: str, post: Post):
52
52
+
new_root_id: int | None = None
53
53
+
new_parent_id: int | None = None
54
54
+
55
55
+
reply_ref: str | None = None
56
56
+
if post.parent_id:
57
57
+
thread = self._find_mapped_thread(
58
58
+
post.parent_id, service, user, self.url, self.user_id
59
59
+
)
60
60
+
61
61
+
if not thread:
62
62
+
self.log.error("Failed to find thread tuple in the database!")
63
63
+
return
64
64
+
_, reply_ref, new_root_id, new_parent_id = thread
65
65
+
66
66
+
quote = post.attachments.get(QuoteAttachment)
67
67
+
if quote:
68
68
+
if quote.quoted_user != user:
69
69
+
self.log.info("Quoted other user, skipping!")
70
70
+
return
71
71
+
72
72
+
quoted_post = self._get_post(service, user, quote.quoted_id)
73
73
+
if not quoted_post:
74
74
+
self.log.error("Failed to find quoted post in the database!")
75
75
+
return
76
76
+
77
77
+
quoted_mappings = self._get_mappings(quoted_post["id"], self.url, self.user_id)
78
78
+
if not quoted_mappings:
79
79
+
self.log.error("Failed to find mappings for quoted post!")
80
80
+
return
81
81
+
82
82
+
quoted_local_id = quoted_mappings[-1][0]
83
83
+
# TODO resolve service identifier
84
84
+
85
85
+
post_tokens = post.tokens.copy()
86
86
+
87
87
+
remote_url = post.attachments.get(RemoteUrlAttachment)
88
88
+
if remote_url and remote_url.url and post.text_type == "text/x.misskeymarkdown":
89
89
+
# TODO stip mfm
90
90
+
pass
91
91
+
92
92
+
raw_statuses = [] # TODO split tokens and media across posts
93
93
+
if not raw_statuses:
94
94
+
self.log.error("Failed to split post into statuses!")
95
95
+
return
96
96
+
97
97
+
langs = post.attachments.get(LanguagesAttachment)
98
98
+
sensitive = post.attachments.get(SensitiveAttachment)
99
99
+
100
100
+
if langs and langs.langs:
101
101
+
pass # TODO
102
102
+
103
103
+
if sensitive and sensitive.sensitive:
104
104
+
pass # TODO
105
105
+
106
106
+
def delete_post(self, service: str, user: str, post_id: str):
107
107
+
post = self._get_post(service, user, post_id)
108
108
+
if not post:
109
109
+
self.log.info("Post not found in db, skipping delete..")
110
110
+
return
111
111
+
112
112
+
mappings = self._get_mappings(post["id"], self.url, self.user_id)
113
113
+
for mapping in mappings[::-1]:
114
114
+
self.log.info("Deleting '%s'...", mapping["identifier"])
115
115
+
requests.delete(
116
116
+
f"{self.url}/api/v1/statuses/{mapping['identifier']}",
117
117
+
headers={"Authorization": f"Bearer {self._get_token()}"},
118
118
+
)
119
119
+
self._delete_post_by_id(mapping["id"])
120
120
+
121
121
+
def accept_repost(self, service: str, user: str, repost_id: str, reposted_id: str):
122
122
+
reposted = self._get_post(service, user, reposted_id)
123
123
+
if not reposted:
124
124
+
self.log.info("Post not found in db, skipping repost..")
125
125
+
return
126
126
+
127
127
+
mappings = self._get_mappings(reposted["id"], self.url, self.user_id)
128
128
+
if mappings:
129
129
+
rsp = requests.post(
130
130
+
f"{self.url}/api/v1/statuses/{mappings[0]['identifier']}/reblog",
131
131
+
headers={"Authorization": f"Bearer {self._get_token()}"},
132
132
+
)
133
133
+
134
134
+
if rsp.status_code != 200:
135
135
+
self.log.error(
136
136
+
"Failed to boost status! status_code: %s, msg: %s",
137
137
+
rsp.status_code,
138
138
+
rsp.content,
139
139
+
)
140
140
+
return
141
141
+
142
142
+
self._insert_post(
143
143
+
{
144
144
+
"user": self.user_id,
145
145
+
"service": self.url,
146
146
+
"identifier": rsp.json()["id"],
147
147
+
"reposted": mappings[0]["id"],
148
148
+
}
149
149
+
)
150
150
+
inserted = self._get_post(self.url, self.user_id, rsp.json()["id"])
151
151
+
if not inserted:
152
152
+
raise ValueError("Inserted post not found!")
153
153
+
self._insert_post_mapping(reposted["id"], inserted["id"])
154
154
+
155
155
+
def delete_repost(self, service: str, user: str, repost_id: str):
156
156
+
repost = self._get_post(service, user, repost_id)
157
157
+
if not repost:
158
158
+
self.log.info("Repost not found in db, skipping delete..")
159
159
+
return
160
160
+
161
161
+
mappings = self._get_mappings(repost["id"], self.url, self.user_id)
162
162
+
rmappings = self._get_mappings(repost["reposted"], self.url, self.user_id)
163
163
+
164
164
+
if mappings and rmappings:
165
165
+
self.log.info(
166
166
+
"Removing '%s' Repost of '%s'...",
167
167
+
mappings[0]["identifier"],
168
168
+
rmappings[0]["identifier"],
169
169
+
)
170
170
+
requests.post(
171
171
+
f"{self.url}/api/v1/statuses/{rmappings[0]['identifier']}/unreblog",
172
172
+
headers={"Authorization": f"Bearer {self._get_token()}"},
173
173
+
)
174
174
+
self._delete_post_by_id(mappings[0]["id"])
41
175
42
176
@override
43
177
def _get_token(self) -> str: