pygpt-net 2.6.1__py3-none-any.whl → 2.6.2__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.
- pygpt_net/CHANGELOG.txt +4 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +15 -1
- pygpt_net/controller/chat/response.py +5 -3
- pygpt_net/controller/chat/stream.py +40 -2
- pygpt_net/controller/plugins/plugins.py +25 -0
- pygpt_net/controller/presets/editor.py +33 -88
- pygpt_net/controller/presets/experts.py +20 -1
- pygpt_net/controller/presets/presets.py +2 -2
- pygpt_net/controller/ui/mode.py +17 -66
- pygpt_net/core/agents/runner.py +15 -7
- pygpt_net/core/experts/experts.py +3 -3
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/locale/locale.de.ini +2 -0
- pygpt_net/data/locale/locale.en.ini +2 -0
- pygpt_net/data/locale/locale.es.ini +2 -0
- pygpt_net/data/locale/locale.fr.ini +2 -0
- pygpt_net/data/locale/locale.it.ini +2 -0
- pygpt_net/data/locale/locale.pl.ini +3 -1
- pygpt_net/data/locale/locale.uk.ini +2 -0
- pygpt_net/data/locale/locale.zh.ini +2 -0
- pygpt_net/plugin/base/plugin.py +35 -3
- pygpt_net/plugin/bitbucket/__init__.py +12 -0
- pygpt_net/plugin/bitbucket/config.py +267 -0
- pygpt_net/plugin/bitbucket/plugin.py +125 -0
- pygpt_net/plugin/bitbucket/worker.py +569 -0
- pygpt_net/plugin/facebook/__init__.py +12 -0
- pygpt_net/plugin/facebook/config.py +359 -0
- pygpt_net/plugin/facebook/plugin.py +114 -0
- pygpt_net/plugin/facebook/worker.py +698 -0
- pygpt_net/plugin/github/__init__.py +12 -0
- pygpt_net/plugin/github/config.py +441 -0
- pygpt_net/plugin/github/plugin.py +124 -0
- pygpt_net/plugin/github/worker.py +674 -0
- pygpt_net/plugin/google/__init__.py +12 -0
- pygpt_net/plugin/google/config.py +367 -0
- pygpt_net/plugin/google/plugin.py +126 -0
- pygpt_net/plugin/google/worker.py +826 -0
- pygpt_net/plugin/slack/__init__.py +12 -0
- pygpt_net/plugin/slack/config.py +349 -0
- pygpt_net/plugin/slack/plugin.py +116 -0
- pygpt_net/plugin/slack/worker.py +639 -0
- pygpt_net/plugin/telegram/__init__.py +12 -0
- pygpt_net/plugin/telegram/config.py +308 -0
- pygpt_net/plugin/telegram/plugin.py +118 -0
- pygpt_net/plugin/telegram/worker.py +563 -0
- pygpt_net/plugin/twitter/__init__.py +12 -0
- pygpt_net/plugin/twitter/config.py +491 -0
- pygpt_net/plugin/twitter/plugin.py +126 -0
- pygpt_net/plugin/twitter/worker.py +837 -0
- pygpt_net/provider/agents/llama_index/legacy/openai_assistant.py +35 -3
- pygpt_net/ui/base/config_dialog.py +4 -0
- pygpt_net/ui/dialog/preset.py +34 -77
- pygpt_net/ui/layout/toolbox/presets.py +2 -2
- pygpt_net/ui/main.py +3 -1
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/METADATA +145 -2
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/RECORD +61 -33
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# ================================================== #
|
|
4
|
+
# This file is a part of PYGPT package #
|
|
5
|
+
# Website: https://pygpt.net #
|
|
6
|
+
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
+
# MIT License #
|
|
8
|
+
# Created By : Marcin Szczygliński #
|
|
9
|
+
# Updated Date: 2025.08.15 00:00:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import socket
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import http.server
|
|
18
|
+
import json
|
|
19
|
+
import mimetypes
|
|
20
|
+
import os
|
|
21
|
+
import random
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
from urllib.parse import urlencode, quote, urlparse, parse_qs
|
|
27
|
+
|
|
28
|
+
import requests
|
|
29
|
+
from PySide6.QtCore import Slot
|
|
30
|
+
|
|
31
|
+
from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WorkerSignals(BaseSignals):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Worker(BaseWorker):
|
|
39
|
+
"""
|
|
40
|
+
X (Twitter) plugin worker: Auth (OAuth2 PKCE), Users, Tweets, Search, Actions, Bookmarks, Media.
|
|
41
|
+
Auto-authorization when required (similar to Google plugin).
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *args, **kwargs):
|
|
45
|
+
super(Worker, self).__init__()
|
|
46
|
+
self.signals = WorkerSignals()
|
|
47
|
+
self.args = args
|
|
48
|
+
self.kwargs = kwargs
|
|
49
|
+
self.plugin = None
|
|
50
|
+
self.cmds = None
|
|
51
|
+
self.ctx = None
|
|
52
|
+
self.msg = None
|
|
53
|
+
|
|
54
|
+
# ---------------------- Core runner ----------------------
|
|
55
|
+
|
|
56
|
+
@Slot()
|
|
57
|
+
def run(self):
|
|
58
|
+
try:
|
|
59
|
+
responses = []
|
|
60
|
+
for item in self.cmds:
|
|
61
|
+
if self.is_stopped():
|
|
62
|
+
break
|
|
63
|
+
try:
|
|
64
|
+
response = None
|
|
65
|
+
if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
|
|
66
|
+
|
|
67
|
+
# -------- Auth --------
|
|
68
|
+
if item["cmd"] == "x_oauth_begin":
|
|
69
|
+
response = self.cmd_x_oauth_begin(item)
|
|
70
|
+
elif item["cmd"] == "x_oauth_exchange":
|
|
71
|
+
response = self.cmd_x_oauth_exchange(item)
|
|
72
|
+
elif item["cmd"] == "x_oauth_refresh":
|
|
73
|
+
response = self.cmd_x_oauth_refresh(item)
|
|
74
|
+
|
|
75
|
+
# -------- Users --------
|
|
76
|
+
elif item["cmd"] == "x_me":
|
|
77
|
+
response = self.cmd_x_me(item)
|
|
78
|
+
elif item["cmd"] == "x_user_by_username":
|
|
79
|
+
response = self.cmd_x_user_by_username(item)
|
|
80
|
+
elif item["cmd"] == "x_user_by_id":
|
|
81
|
+
response = self.cmd_x_user_by_id(item)
|
|
82
|
+
|
|
83
|
+
# -------- Tweets & timelines --------
|
|
84
|
+
elif item["cmd"] == "x_user_tweets":
|
|
85
|
+
response = self.cmd_x_user_tweets(item)
|
|
86
|
+
elif item["cmd"] == "x_search_recent":
|
|
87
|
+
response = self.cmd_x_search_recent(item)
|
|
88
|
+
elif item["cmd"] == "x_tweet_create":
|
|
89
|
+
response = self.cmd_x_tweet_create(item)
|
|
90
|
+
elif item["cmd"] == "x_tweet_delete":
|
|
91
|
+
response = self.cmd_x_tweet_delete(item)
|
|
92
|
+
elif item["cmd"] == "x_tweet_reply":
|
|
93
|
+
response = self.cmd_x_tweet_reply(item)
|
|
94
|
+
elif item["cmd"] == "x_tweet_quote":
|
|
95
|
+
response = self.cmd_x_tweet_quote(item)
|
|
96
|
+
|
|
97
|
+
# -------- Tweet actions --------
|
|
98
|
+
elif item["cmd"] == "x_like":
|
|
99
|
+
response = self.cmd_x_like(item)
|
|
100
|
+
elif item["cmd"] == "x_unlike":
|
|
101
|
+
response = self.cmd_x_unlike(item)
|
|
102
|
+
elif item["cmd"] == "x_retweet":
|
|
103
|
+
response = self.cmd_x_retweet(item)
|
|
104
|
+
elif item["cmd"] == "x_unretweet":
|
|
105
|
+
response = self.cmd_x_unretweet(item)
|
|
106
|
+
elif item["cmd"] == "x_hide_reply":
|
|
107
|
+
response = self.cmd_x_hide_reply(item)
|
|
108
|
+
|
|
109
|
+
# -------- Bookmarks --------
|
|
110
|
+
elif item["cmd"] == "x_bookmarks_list":
|
|
111
|
+
response = self.cmd_x_bookmarks_list(item)
|
|
112
|
+
elif item["cmd"] == "x_bookmark_add":
|
|
113
|
+
response = self.cmd_x_bookmark_add(item)
|
|
114
|
+
elif item["cmd"] == "x_bookmark_remove":
|
|
115
|
+
response = self.cmd_x_bookmark_remove(item)
|
|
116
|
+
|
|
117
|
+
# -------- Media --------
|
|
118
|
+
elif item["cmd"] == "x_upload_media":
|
|
119
|
+
response = self.cmd_x_upload_media(item)
|
|
120
|
+
elif item["cmd"] == "x_media_set_alt_text":
|
|
121
|
+
response = self.cmd_x_media_set_alt_text(item)
|
|
122
|
+
|
|
123
|
+
if response:
|
|
124
|
+
responses.append(response)
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
responses.append(self.make_response(item, self.throw_error(e)))
|
|
128
|
+
|
|
129
|
+
if responses:
|
|
130
|
+
self.reply_more(responses)
|
|
131
|
+
if self.msg is not None:
|
|
132
|
+
self.status(self.msg)
|
|
133
|
+
except Exception as e:
|
|
134
|
+
self.error(e)
|
|
135
|
+
finally:
|
|
136
|
+
self.cleanup()
|
|
137
|
+
|
|
138
|
+
# ---------------------- HTTP / Auth helpers ----------------------
|
|
139
|
+
|
|
140
|
+
def _api_base(self) -> str:
|
|
141
|
+
return (self.plugin.get_option_value("api_base") or "https://api.x.com").rstrip("/")
|
|
142
|
+
|
|
143
|
+
def _auth_base(self) -> str:
|
|
144
|
+
return (self.plugin.get_option_value("authorize_base") or "https://x.com").rstrip("/")
|
|
145
|
+
|
|
146
|
+
def _timeout(self) -> int:
|
|
147
|
+
try:
|
|
148
|
+
return int(self.plugin.get_option_value("http_timeout") or 30)
|
|
149
|
+
except Exception:
|
|
150
|
+
return 30
|
|
151
|
+
|
|
152
|
+
def _now(self) -> int:
|
|
153
|
+
return int(time.time())
|
|
154
|
+
|
|
155
|
+
def _get(self, path: str, params: dict = None, user_context: bool = False):
|
|
156
|
+
url = f"{self._api_base()}{path}"
|
|
157
|
+
headers = self._auth_header(user_context=user_context)
|
|
158
|
+
r = requests.get(url, headers=headers, params=params or {}, timeout=self._timeout())
|
|
159
|
+
return self._handle_response(r)
|
|
160
|
+
|
|
161
|
+
def _delete(self, path: str, params: dict = None, user_context: bool = False):
|
|
162
|
+
url = f"{self._api_base()}{path}"
|
|
163
|
+
headers = self._auth_header(user_context=user_context)
|
|
164
|
+
r = requests.delete(url, headers=headers, params=params or {}, timeout=self._timeout())
|
|
165
|
+
return self._handle_response(r)
|
|
166
|
+
|
|
167
|
+
def _post_json(self, path: str, payload: dict, user_context: bool = False):
|
|
168
|
+
url = f"{self._api_base()}{path}"
|
|
169
|
+
headers = self._auth_header(user_context=user_context)
|
|
170
|
+
headers["Content-Type"] = "application/json"
|
|
171
|
+
r = requests.post(url, headers=headers, json=payload or {}, timeout=self._timeout())
|
|
172
|
+
return self._handle_response(r)
|
|
173
|
+
|
|
174
|
+
def _post_form(self, path: str, form: dict, files: dict | None = None, user_context: bool = False):
|
|
175
|
+
url = f"{self._api_base()}{path}"
|
|
176
|
+
headers = self._auth_header(user_context=user_context)
|
|
177
|
+
r = requests.post(url, headers=headers, data=form or {}, files=files, timeout=self._timeout())
|
|
178
|
+
return self._handle_response(r)
|
|
179
|
+
|
|
180
|
+
def _handle_response(self, r: requests.Response) -> dict:
|
|
181
|
+
try:
|
|
182
|
+
data = r.json() if r.content else {}
|
|
183
|
+
except Exception:
|
|
184
|
+
data = {"raw": r.text}
|
|
185
|
+
if not (200 <= r.status_code < 300):
|
|
186
|
+
if isinstance(data, dict) and data.get("errors"):
|
|
187
|
+
raise RuntimeError(json.dumps({"status": r.status_code, "errors": data.get("errors")}, ensure_ascii=False))
|
|
188
|
+
raise RuntimeError(f"HTTP {r.status_code}: {data or r.text}")
|
|
189
|
+
data["_meta"] = {
|
|
190
|
+
"status": r.status_code,
|
|
191
|
+
"x-rate-remaining": r.headers.get("x-rate-limit-remaining"),
|
|
192
|
+
"x-rate-reset": r.headers.get("x-rate-limit-reset"),
|
|
193
|
+
}
|
|
194
|
+
return data
|
|
195
|
+
|
|
196
|
+
def _auth_header(self, user_context: bool = False) -> Dict[str, str]:
|
|
197
|
+
if user_context:
|
|
198
|
+
token = self._ensure_user_token(optional=True)
|
|
199
|
+
if not token and bool(self.plugin.get_option_value("oauth_auto_begin") or True):
|
|
200
|
+
# try to auto-run PKCE auth and exchange
|
|
201
|
+
self._auto_authorize_interactive()
|
|
202
|
+
token = self._ensure_user_token(optional=False)
|
|
203
|
+
else:
|
|
204
|
+
token = (self.plugin.get_option_value("bearer_token") or "").strip()
|
|
205
|
+
if not token:
|
|
206
|
+
# fallback to user token if bearer not provided
|
|
207
|
+
token = self._ensure_user_token(optional=True)
|
|
208
|
+
if not token and bool(self.plugin.get_option_value("oauth_auto_begin") or True):
|
|
209
|
+
self._auto_authorize_interactive()
|
|
210
|
+
token = self._ensure_user_token(optional=False)
|
|
211
|
+
if not token:
|
|
212
|
+
raise RuntimeError("Missing bearer/access token. Configure bearer_token or complete OAuth2.")
|
|
213
|
+
return {
|
|
214
|
+
"Authorization": f"Bearer {token}",
|
|
215
|
+
"User-Agent": "pygpt-net-x-plugin/1.1",
|
|
216
|
+
"Accept": "application/json",
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def _ensure_user_token(self, optional: bool = False) -> Optional[str]:
|
|
220
|
+
access = (self.plugin.get_option_value("oauth2_access_token") or "").strip()
|
|
221
|
+
exp = int(self.plugin.get_option_value("oauth2_expires_at") or 0)
|
|
222
|
+
refresh = (self.plugin.get_option_value("oauth2_refresh_token") or "").strip()
|
|
223
|
+
if access and exp and self._now() >= exp and refresh:
|
|
224
|
+
self._refresh_access_token()
|
|
225
|
+
access = (self.plugin.get_option_value("oauth2_access_token") or "").strip()
|
|
226
|
+
if not access and not optional:
|
|
227
|
+
raise RuntimeError("User access token missing. Run OAuth first.")
|
|
228
|
+
return access or None
|
|
229
|
+
|
|
230
|
+
def _save_tokens(self, tok: dict):
|
|
231
|
+
access = tok.get("access_token")
|
|
232
|
+
refresh = tok.get("refresh_token")
|
|
233
|
+
expires_in = int(tok.get("expires_in") or 0)
|
|
234
|
+
expires_at = self._now() + expires_in - 60 if expires_in else 0
|
|
235
|
+
self.plugin.set_option_value("oauth2_access_token", access or "")
|
|
236
|
+
if refresh:
|
|
237
|
+
self.plugin.set_option_value("oauth2_refresh_token", refresh)
|
|
238
|
+
if expires_at:
|
|
239
|
+
self.plugin.set_option_value("oauth2_expires_at", str(expires_at))
|
|
240
|
+
|
|
241
|
+
def _refresh_access_token(self):
|
|
242
|
+
client_id = self.plugin.get_option_value("oauth2_client_id") or ""
|
|
243
|
+
client_secret = self.plugin.get_option_value("oauth2_client_secret") or ""
|
|
244
|
+
refresh = self.plugin.get_option_value("oauth2_refresh_token") or ""
|
|
245
|
+
if not (client_id and refresh):
|
|
246
|
+
raise RuntimeError("Cannot refresh: missing client_id or refresh_token")
|
|
247
|
+
token_url = f"{self._api_base()}/2/oauth2/token"
|
|
248
|
+
data = {
|
|
249
|
+
"grant_type": "refresh_token",
|
|
250
|
+
"refresh_token": refresh,
|
|
251
|
+
"client_id": client_id,
|
|
252
|
+
}
|
|
253
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
254
|
+
if client_secret and bool(self.plugin.get_option_value("oauth2_confidential") or False):
|
|
255
|
+
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
|
256
|
+
headers["Authorization"] = f"Basic {basic}"
|
|
257
|
+
data.pop("client_id", None)
|
|
258
|
+
r = requests.post(token_url, headers=headers, data=data, timeout=self._timeout())
|
|
259
|
+
res = self._handle_response(r)
|
|
260
|
+
self._save_tokens(res)
|
|
261
|
+
|
|
262
|
+
# ---------------------- OAuth2 PKCE (auto) ----------------------
|
|
263
|
+
|
|
264
|
+
def _gen_code_verifier(self, n: int = 64) -> str:
|
|
265
|
+
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
266
|
+
return "".join(random.choice(alphabet) for _ in range(n))
|
|
267
|
+
|
|
268
|
+
def _code_challenge(self, verifier: str) -> str:
|
|
269
|
+
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
|
270
|
+
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
|
271
|
+
|
|
272
|
+
# --- helpers for local callback port management ---
|
|
273
|
+
|
|
274
|
+
def _can_bind(self, host: str, port: int) -> bool:
|
|
275
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
276
|
+
try:
|
|
277
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
278
|
+
s.bind((host, port))
|
|
279
|
+
return True
|
|
280
|
+
except Exception:
|
|
281
|
+
return False
|
|
282
|
+
finally:
|
|
283
|
+
try:
|
|
284
|
+
s.close()
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
def _pick_port(self, host: str, preferred: int) -> int:
|
|
289
|
+
# try preferred or next ones; finally ephemeral
|
|
290
|
+
base = preferred if preferred and preferred >= 1024 else 8731
|
|
291
|
+
for p in range(base, base + 50):
|
|
292
|
+
if self._can_bind(host, p):
|
|
293
|
+
return p
|
|
294
|
+
# last resort ephemeral
|
|
295
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
296
|
+
s.bind((host, 0))
|
|
297
|
+
free_port = s.getsockname()[1]
|
|
298
|
+
s.close()
|
|
299
|
+
return free_port
|
|
300
|
+
|
|
301
|
+
def _prepare_effective_redirect(self, redirect_uri: str) -> str:
|
|
302
|
+
# choose a safe, available port for localhost/127.0.0.1
|
|
303
|
+
u = urlparse(redirect_uri)
|
|
304
|
+
host = u.hostname or "127.0.0.1"
|
|
305
|
+
scheme = u.scheme or "http"
|
|
306
|
+
path = u.path or "/"
|
|
307
|
+
if not self._redirect_is_local(redirect_uri):
|
|
308
|
+
return redirect_uri # non-local redirect untouched
|
|
309
|
+
|
|
310
|
+
port = u.port
|
|
311
|
+
allow_fallback = bool(self.plugin.get_option_value("oauth_allow_port_fallback") or True)
|
|
312
|
+
if not port or port < 1024 or not self._can_bind(host, port):
|
|
313
|
+
if not allow_fallback and (not port or port < 1024):
|
|
314
|
+
raise RuntimeError("Configured redirect_uri uses a privileged or unavailable port. Use a port >1024.")
|
|
315
|
+
# prefer configured oauth_local_port, else 8731
|
|
316
|
+
pref = int(self.plugin.get_option_value("oauth_local_port") or 8731)
|
|
317
|
+
new_port = self._pick_port(host, pref)
|
|
318
|
+
return f"{scheme}://{host}:{new_port}{path}"
|
|
319
|
+
return redirect_uri
|
|
320
|
+
|
|
321
|
+
# adjust signature to allow overriding redirect per session
|
|
322
|
+
def _build_auth_url(self, scopes: str, verifier: str, state: str, redirect_uri: Optional[str] = None) -> str:
|
|
323
|
+
client_id = self.plugin.get_option_value("oauth2_client_id") or ""
|
|
324
|
+
redirect_uri = redirect_uri or (self.plugin.get_option_value("oauth2_redirect_uri") or "")
|
|
325
|
+
challenge = self._code_challenge(verifier)
|
|
326
|
+
return (
|
|
327
|
+
f"{self._auth_base()}/i/oauth2/authorize?"
|
|
328
|
+
+ urlencode({
|
|
329
|
+
"response_type": "code",
|
|
330
|
+
"client_id": client_id,
|
|
331
|
+
"redirect_uri": redirect_uri,
|
|
332
|
+
"scope": scopes,
|
|
333
|
+
"state": state,
|
|
334
|
+
"code_challenge": challenge,
|
|
335
|
+
"code_challenge_method": "S256",
|
|
336
|
+
})
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def _auto_authorize_interactive(self):
|
|
340
|
+
client_id = self.plugin.get_option_value("oauth2_client_id") or ""
|
|
341
|
+
redirect_cfg = self.plugin.get_option_value("oauth2_redirect_uri") or ""
|
|
342
|
+
if not (client_id and redirect_cfg):
|
|
343
|
+
raise RuntimeError("OAuth auto-start: set oauth2_client_id and oauth2_redirect_uri first.")
|
|
344
|
+
|
|
345
|
+
scopes = (self.plugin.get_option_value("oauth2_scopes") or
|
|
346
|
+
"tweet.read users.read like.read like.write tweet.write bookmark.read bookmark.write tweet.moderate.write offline.access")
|
|
347
|
+
verifier = self._gen_code_verifier()
|
|
348
|
+
state = self._gen_code_verifier(32)
|
|
349
|
+
self.plugin.set_option_value("oauth2_code_verifier", verifier)
|
|
350
|
+
self.plugin.set_option_value("oauth2_state", state)
|
|
351
|
+
|
|
352
|
+
# choose an effective local redirect (safe, available port)
|
|
353
|
+
effective_redirect = self._prepare_effective_redirect(redirect_cfg)
|
|
354
|
+
auth_url = self._build_auth_url(scopes, verifier, state, redirect_uri=effective_redirect)
|
|
355
|
+
|
|
356
|
+
if bool(self.plugin.get_option_value("oauth_open_browser") or True):
|
|
357
|
+
try:
|
|
358
|
+
self.plugin.open_url(auth_url)
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
|
|
362
|
+
if bool(self.plugin.get_option_value("oauth_local_server") or True) and self._redirect_is_local(
|
|
363
|
+
effective_redirect):
|
|
364
|
+
code, st = self._run_local_callback_and_wait(auth_url, effective_redirect)
|
|
365
|
+
if (self.plugin.get_option_value("oauth2_state") or "") and st and st != self.plugin.get_option_value(
|
|
366
|
+
"oauth2_state"):
|
|
367
|
+
raise RuntimeError("OAuth state mismatch.")
|
|
368
|
+
self._exchange_code_for_token(code)
|
|
369
|
+
try:
|
|
370
|
+
me = self._get("/2/users/me", user_context=True)
|
|
371
|
+
usr = (me.get("data") or {})
|
|
372
|
+
if usr.get("id"):
|
|
373
|
+
self.plugin.set_option_value("user_id", usr["id"])
|
|
374
|
+
if usr.get("username"):
|
|
375
|
+
self.plugin.set_option_value("username", usr["username"])
|
|
376
|
+
except Exception:
|
|
377
|
+
pass
|
|
378
|
+
self.msg = f"X: Authorization complete on {effective_redirect}."
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
self.msg = f"Authorize in browser and run x_oauth_exchange with 'code'. URL: {auth_url}"
|
|
382
|
+
|
|
383
|
+
def _redirect_is_local(self, redirect_uri: str) -> bool:
|
|
384
|
+
try:
|
|
385
|
+
u = urlparse(redirect_uri)
|
|
386
|
+
return u.scheme in ("http",) and (u.hostname in ("127.0.0.1", "localhost"))
|
|
387
|
+
except Exception:
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
def _run_local_callback_and_wait(self, auth_url: str, redirect_uri: str) -> (str, str):
|
|
391
|
+
u = urlparse(redirect_uri)
|
|
392
|
+
host = u.hostname or "127.0.0.1"
|
|
393
|
+
port = u.port or 8731 # should be set already
|
|
394
|
+
path_expected = u.path or "/"
|
|
395
|
+
timeout_sec = int(self.plugin.get_option_value("oauth_local_timeout") or 180)
|
|
396
|
+
html_ok = (self.plugin.get_option_value("oauth_success_html")
|
|
397
|
+
or "<html><body><h3>Authorization complete. You can close this window.</h3></body></html>")
|
|
398
|
+
html_err = (self.plugin.get_option_value("oauth_fail_html")
|
|
399
|
+
or "<html><body><h3>Authorization failed.</h3></body></html>")
|
|
400
|
+
|
|
401
|
+
result = {"code": None, "state": None}
|
|
402
|
+
event = threading.Event()
|
|
403
|
+
|
|
404
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
405
|
+
def log_message(self, fmt, *args):
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
def do_GET(self):
|
|
409
|
+
try:
|
|
410
|
+
q = urlparse(self.path)
|
|
411
|
+
qs = parse_qs(q.query)
|
|
412
|
+
result["code"] = (qs.get("code") or [None])[0]
|
|
413
|
+
result["state"] = (qs.get("state") or [None])[0]
|
|
414
|
+
ok = result["code"] is not None
|
|
415
|
+
data = html_ok if ok else html_err
|
|
416
|
+
self.send_response(200 if ok else 400)
|
|
417
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
418
|
+
self.send_header("Content-Length", str(len(data.encode("utf-8"))))
|
|
419
|
+
self.end_headers()
|
|
420
|
+
self.wfile.write(data.encode("utf-8"))
|
|
421
|
+
finally:
|
|
422
|
+
event.set()
|
|
423
|
+
|
|
424
|
+
# bind here; if fails
|
|
425
|
+
try:
|
|
426
|
+
httpd = http.server.HTTPServer((host, port), Handler)
|
|
427
|
+
except PermissionError:
|
|
428
|
+
raise RuntimeError(
|
|
429
|
+
f"Cannot bind local callback on {host}:{port}. Use a port >1024 or change oauth_local_port.")
|
|
430
|
+
except OSError as e:
|
|
431
|
+
raise RuntimeError(
|
|
432
|
+
f"Port {port} busy on {host}. Change oauth_local_port or whitelist a different port in X App. ({e})")
|
|
433
|
+
|
|
434
|
+
srv_thr = threading.Thread(target=httpd.serve_forever, daemon=True)
|
|
435
|
+
srv_thr.start()
|
|
436
|
+
|
|
437
|
+
# ensure browser is open as last resort
|
|
438
|
+
try:
|
|
439
|
+
if bool(self.plugin.get_option_value("oauth_open_browser") or True):
|
|
440
|
+
self.plugin.open_url(auth_url)
|
|
441
|
+
except Exception:
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
got = event.wait(timeout=timeout_sec)
|
|
445
|
+
try:
|
|
446
|
+
httpd.shutdown()
|
|
447
|
+
except Exception:
|
|
448
|
+
pass
|
|
449
|
+
srv_thr.join(timeout=5)
|
|
450
|
+
|
|
451
|
+
if not got or not result["code"]:
|
|
452
|
+
raise RuntimeError(
|
|
453
|
+
"No OAuth code received (timeout). Check that the callback URL exactly matches your X App settings.")
|
|
454
|
+
return result["code"], result["state"]
|
|
455
|
+
|
|
456
|
+
def _exchange_code_for_token(self, code: str):
|
|
457
|
+
client_id = self.plugin.get_option_value("oauth2_client_id") or ""
|
|
458
|
+
client_secret = self.plugin.get_option_value("oauth2_client_secret") or ""
|
|
459
|
+
redirect_uri = self.plugin.get_option_value("oauth2_redirect_uri") or ""
|
|
460
|
+
verifier = self.plugin.get_option_value("oauth2_code_verifier") or ""
|
|
461
|
+
if not (client_id and redirect_uri and verifier and code):
|
|
462
|
+
raise RuntimeError("Exchange failed: missing client_id/redirect_uri/verifier/code.")
|
|
463
|
+
token_url = f"{self._api_base()}/2/oauth2/token"
|
|
464
|
+
data = {
|
|
465
|
+
"grant_type": "authorization_code",
|
|
466
|
+
"code": code,
|
|
467
|
+
"redirect_uri": redirect_uri,
|
|
468
|
+
"client_id": client_id,
|
|
469
|
+
"code_verifier": verifier,
|
|
470
|
+
}
|
|
471
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
472
|
+
if client_secret and bool(self.plugin.get_option_value("oauth2_confidential") or False):
|
|
473
|
+
basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
|
474
|
+
headers["Authorization"] = f"Basic {basic}"
|
|
475
|
+
data.pop("client_id", None)
|
|
476
|
+
r = requests.post(token_url, headers=headers, data=data, timeout=self._timeout())
|
|
477
|
+
res = self._handle_response(r)
|
|
478
|
+
self._save_tokens(res)
|
|
479
|
+
|
|
480
|
+
# ---------------------- Auth commands ----------------------
|
|
481
|
+
|
|
482
|
+
def cmd_x_oauth_begin(self, item: dict) -> dict:
|
|
483
|
+
p = item.get("params", {})
|
|
484
|
+
client_id = self.plugin.get_option_value("oauth2_client_id") or ""
|
|
485
|
+
redirect_cfg = self.plugin.get_option_value("oauth2_redirect_uri") or ""
|
|
486
|
+
scopes = p.get("scopes") or (self.plugin.get_option_value("oauth2_scopes") or
|
|
487
|
+
"tweet.read users.read like.read like.write tweet.write bookmark.read bookmark.write tweet.moderate.write offline.access")
|
|
488
|
+
if not (client_id and redirect_cfg):
|
|
489
|
+
return self.make_response(item, "Set oauth2_client_id and oauth2_redirect_uri in options first.")
|
|
490
|
+
verifier = self._gen_code_verifier()
|
|
491
|
+
state = p.get("state") or self._gen_code_verifier(32)
|
|
492
|
+
self.plugin.set_option_value("oauth2_code_verifier", verifier)
|
|
493
|
+
self.plugin.set_option_value("oauth2_state", state)
|
|
494
|
+
effective_redirect = self._prepare_effective_redirect(redirect_cfg)
|
|
495
|
+
auth_url = self._build_auth_url(scopes, verifier, state, redirect_uri=effective_redirect)
|
|
496
|
+
if bool(self.plugin.get_option_value("oauth_open_browser") or True):
|
|
497
|
+
try:
|
|
498
|
+
self.plugin.open_url(auth_url)
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
return self.make_response(item, {"authorize_url": auth_url, "redirect_uri": effective_redirect})
|
|
502
|
+
|
|
503
|
+
def cmd_x_oauth_exchange(self, item: dict) -> dict:
|
|
504
|
+
p = item.get("params", {})
|
|
505
|
+
code = p.get("code")
|
|
506
|
+
state = p.get("state")
|
|
507
|
+
expected_state = self.plugin.get_option_value("oauth2_state") or ""
|
|
508
|
+
if not code:
|
|
509
|
+
return self.make_response(item, "Param 'code' required.")
|
|
510
|
+
if expected_state and state and state != expected_state:
|
|
511
|
+
return self.make_response(item, "State mismatch.")
|
|
512
|
+
self._exchange_code_for_token(code)
|
|
513
|
+
# cache identity
|
|
514
|
+
try:
|
|
515
|
+
me = self._get("/2/users/me", user_context=True)
|
|
516
|
+
usr = (me.get("data") or {})
|
|
517
|
+
if usr.get("id"):
|
|
518
|
+
self.plugin.set_option_value("user_id", usr["id"])
|
|
519
|
+
if usr.get("username"):
|
|
520
|
+
self.plugin.set_option_value("username", usr["username"])
|
|
521
|
+
except Exception:
|
|
522
|
+
pass
|
|
523
|
+
return self.make_response(item, {
|
|
524
|
+
"access_token": self.plugin.get_option_value("oauth2_access_token"),
|
|
525
|
+
"refresh_token": self.plugin.get_option_value("oauth2_refresh_token"),
|
|
526
|
+
"expires_at": self.plugin.get_option_value("oauth2_expires_at"),
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
def cmd_x_oauth_refresh(self, item: dict) -> dict:
|
|
530
|
+
self._refresh_access_token()
|
|
531
|
+
return self.make_response(item, {
|
|
532
|
+
"access_token": self.plugin.get_option_value("oauth2_access_token"),
|
|
533
|
+
"expires_at": self.plugin.get_option_value("oauth2_expires_at"),
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
# ---------------------- Users ----------------------
|
|
537
|
+
|
|
538
|
+
def cmd_x_me(self, item: dict) -> dict:
|
|
539
|
+
params = item.get("params", {}) or {}
|
|
540
|
+
res = self._get("/2/users/me", params=params, user_context=True)
|
|
541
|
+
data = res.get("data") or {}
|
|
542
|
+
if data.get("id"):
|
|
543
|
+
self.plugin.set_option_value("user_id", data["id"])
|
|
544
|
+
if data.get("username"):
|
|
545
|
+
self.plugin.set_option_value("username", data["username"])
|
|
546
|
+
return self.make_response(item, res)
|
|
547
|
+
|
|
548
|
+
def cmd_x_user_by_username(self, item: dict) -> dict:
|
|
549
|
+
p = item.get("params", {})
|
|
550
|
+
username = p.get("username")
|
|
551
|
+
if not username:
|
|
552
|
+
return self.make_response(item, "Param 'username' required")
|
|
553
|
+
params = {}
|
|
554
|
+
if p.get("user_fields"):
|
|
555
|
+
params["user.fields"] = p.get("user_fields")
|
|
556
|
+
if p.get("expansions"):
|
|
557
|
+
params["expansions"] = p.get("expansions")
|
|
558
|
+
if p.get("tweet_fields"):
|
|
559
|
+
params["tweet.fields"] = p.get("tweet_fields")
|
|
560
|
+
res = self._get(f"/2/users/by/username/{quote(username)}", params=params, user_context=False)
|
|
561
|
+
return self.make_response(item, res)
|
|
562
|
+
|
|
563
|
+
def cmd_x_user_by_id(self, item: dict) -> dict:
|
|
564
|
+
p = item.get("params", {})
|
|
565
|
+
uid = p.get("id")
|
|
566
|
+
if not uid:
|
|
567
|
+
return self.make_response(item, "Param 'id' required")
|
|
568
|
+
params = {}
|
|
569
|
+
if p.get("user_fields"):
|
|
570
|
+
params["user.fields"] = p.get("user_fields")
|
|
571
|
+
res = self._get(f"/2/users/{uid}", params=params, user_context=False)
|
|
572
|
+
return self.make_response(item, res)
|
|
573
|
+
|
|
574
|
+
# ---------------------- Timelines / Search ----------------------
|
|
575
|
+
|
|
576
|
+
def cmd_x_user_tweets(self, item: dict) -> dict:
|
|
577
|
+
p = item.get("params", {})
|
|
578
|
+
uid = p.get("id")
|
|
579
|
+
if not uid:
|
|
580
|
+
return self.make_response(item, "Param 'id' (user id) required")
|
|
581
|
+
params = {"max_results": p.get("max_results", 20)}
|
|
582
|
+
for k in ("since_id", "until_id", "start_time", "end_time", "pagination_token"):
|
|
583
|
+
if p.get(k):
|
|
584
|
+
params[k] = p[k]
|
|
585
|
+
if p.get("exclude"):
|
|
586
|
+
params["exclude"] = ",".join(p["exclude"]) if isinstance(p["exclude"], list) else p["exclude"]
|
|
587
|
+
params.setdefault("tweet.fields", p.get("tweet_fields", "id,text,created_at,public_metrics,conversation_id,referenced_tweets,attachments"))
|
|
588
|
+
params.setdefault("expansions", p.get("expansions", "author_id,attachments.media_keys,referenced_tweets.id"))
|
|
589
|
+
params.setdefault("media.fields", p.get("media_fields", "media_key,type,url,preview_image_url,height,width,alt_text"))
|
|
590
|
+
res = self._get(f"/2/users/{uid}/tweets", params=params, user_context=False)
|
|
591
|
+
return self.make_response(item, res)
|
|
592
|
+
|
|
593
|
+
def cmd_x_search_recent(self, item: dict) -> dict:
|
|
594
|
+
p = item.get("params", {})
|
|
595
|
+
q = p.get("query")
|
|
596
|
+
if not q:
|
|
597
|
+
return self.make_response(item, "Param 'query' required")
|
|
598
|
+
params = {"query": q, "max_results": p.get("max_results", 25)}
|
|
599
|
+
for k in ("since_id", "until_id", "start_time", "end_time", "next_token"):
|
|
600
|
+
if p.get(k):
|
|
601
|
+
params[k] = p[k]
|
|
602
|
+
params.setdefault("tweet.fields", p.get("tweet_fields", "id,text,created_at,public_metrics,conversation_id,referenced_tweets,attachments,lang"))
|
|
603
|
+
params.setdefault("expansions", p.get("expansions", "author_id,attachments.media_keys,referenced_tweets.id"))
|
|
604
|
+
params.setdefault("media.fields", p.get("media_fields", "media_key,type,url,preview_image_url,height,width,alt_text"))
|
|
605
|
+
res = self._get("/2/tweets/search/recent", params=params, user_context=False)
|
|
606
|
+
return self.make_response(item, res)
|
|
607
|
+
|
|
608
|
+
# ---------------------- Tweet CRUD ----------------------
|
|
609
|
+
|
|
610
|
+
def _ensure_user_id(self) -> str:
|
|
611
|
+
uid = self.plugin.get_option_value("user_id") or ""
|
|
612
|
+
if uid:
|
|
613
|
+
return uid
|
|
614
|
+
me = self._get("/2/users/me", user_context=True)
|
|
615
|
+
uid = (me.get("data") or {}).get("id")
|
|
616
|
+
if not uid:
|
|
617
|
+
raise RuntimeError("Cannot resolve user_id (call x_me first).")
|
|
618
|
+
self.plugin.set_option_value("user_id", uid)
|
|
619
|
+
return uid
|
|
620
|
+
|
|
621
|
+
def cmd_x_tweet_create(self, item: dict) -> dict:
|
|
622
|
+
p = item.get("params", {})
|
|
623
|
+
text = p.get("text", "")
|
|
624
|
+
media_ids = p.get("media_ids") or []
|
|
625
|
+
quote_id = p.get("quote_tweet_id")
|
|
626
|
+
reply_to = p.get("in_reply_to_tweet_id")
|
|
627
|
+
tagged_user_ids = p.get("tagged_user_ids") or []
|
|
628
|
+
place_id = p.get("place_id")
|
|
629
|
+
reply_settings = p.get("reply_settings")
|
|
630
|
+
poll = p.get("poll")
|
|
631
|
+
card_uri = p.get("card_uri")
|
|
632
|
+
payload: Dict[str, Any] = {"text": text}
|
|
633
|
+
if media_ids or tagged_user_ids:
|
|
634
|
+
payload["media"] = {}
|
|
635
|
+
if media_ids:
|
|
636
|
+
payload["media"]["media_ids"] = media_ids
|
|
637
|
+
if tagged_user_ids:
|
|
638
|
+
payload["media"]["tagged_user_ids"] = tagged_user_ids
|
|
639
|
+
if quote_id:
|
|
640
|
+
payload["quote_tweet_id"] = quote_id
|
|
641
|
+
if reply_to:
|
|
642
|
+
payload["reply"] = {"in_reply_to_tweet_id": reply_to, "exclude_reply_user_ids": p.get("exclude_reply_user_ids")}
|
|
643
|
+
if place_id:
|
|
644
|
+
payload["geo"] = {"place_id": place_id}
|
|
645
|
+
if reply_settings:
|
|
646
|
+
payload["reply_settings"] = reply_settings
|
|
647
|
+
if poll:
|
|
648
|
+
payload["poll"] = poll
|
|
649
|
+
if card_uri:
|
|
650
|
+
payload["card_uri"] = card_uri
|
|
651
|
+
res = self._post_json("/2/tweets", payload, user_context=True)
|
|
652
|
+
return self.make_response(item, res)
|
|
653
|
+
|
|
654
|
+
def cmd_x_tweet_delete(self, item: dict) -> dict:
|
|
655
|
+
p = item.get("params", {})
|
|
656
|
+
tid = p.get("id")
|
|
657
|
+
if not tid:
|
|
658
|
+
return self.make_response(item, "Param 'id' (tweet id) required")
|
|
659
|
+
res = self._delete(f"/2/tweets/{tid}", user_context=True)
|
|
660
|
+
return self.make_response(item, res)
|
|
661
|
+
|
|
662
|
+
def cmd_x_tweet_reply(self, item: dict) -> dict:
|
|
663
|
+
p = item.get("params", {})
|
|
664
|
+
tid = p.get("in_reply_to_tweet_id")
|
|
665
|
+
if not tid:
|
|
666
|
+
return self.make_response(item, "Param 'in_reply_to_tweet_id' required")
|
|
667
|
+
p2 = dict(p)
|
|
668
|
+
p2["text"] = p.get("text", "")
|
|
669
|
+
p2["in_reply_to_tweet_id"] = tid
|
|
670
|
+
return self.cmd_x_tweet_create({"params": p2, "cmd": "x_tweet_create"})
|
|
671
|
+
|
|
672
|
+
def cmd_x_tweet_quote(self, item: dict) -> dict:
|
|
673
|
+
p = item.get("params", {})
|
|
674
|
+
qid = p.get("quote_tweet_id")
|
|
675
|
+
if not qid:
|
|
676
|
+
return self.make_response(item, "Param 'quote_tweet_id' required")
|
|
677
|
+
p2 = dict(p)
|
|
678
|
+
p2["quote_tweet_id"] = qid
|
|
679
|
+
return self.cmd_x_tweet_create({"params": p2, "cmd": "x_tweet_create"})
|
|
680
|
+
|
|
681
|
+
# ---------------------- Actions ----------------------
|
|
682
|
+
|
|
683
|
+
def cmd_x_like(self, item: dict) -> dict:
|
|
684
|
+
p = item.get("params", {})
|
|
685
|
+
tid = p.get("tweet_id")
|
|
686
|
+
if not tid:
|
|
687
|
+
return self.make_response(item, "Param 'tweet_id' required")
|
|
688
|
+
uid = p.get("user_id") or self._ensure_user_id()
|
|
689
|
+
res = self._post_json(f"/2/users/{uid}/likes", {"tweet_id": tid}, user_context=True)
|
|
690
|
+
return self.make_response(item, res)
|
|
691
|
+
|
|
692
|
+
def cmd_x_unlike(self, item: dict) -> dict:
|
|
693
|
+
p = item.get("params", {})
|
|
694
|
+
tid = p.get("tweet_id")
|
|
695
|
+
if not tid:
|
|
696
|
+
return self.make_response(item, "Param 'tweet_id' required")
|
|
697
|
+
uid = p.get("user_id") or self._ensure_user_id()
|
|
698
|
+
res = self._delete(f"/2/users/{uid}/likes/{tid}", user_context=True)
|
|
699
|
+
return self.make_response(item, res)
|
|
700
|
+
|
|
701
|
+
def cmd_x_retweet(self, item: dict) -> dict:
|
|
702
|
+
p = item.get("params", {})
|
|
703
|
+
tid = p.get("tweet_id")
|
|
704
|
+
if not tid:
|
|
705
|
+
return self.make_response(item, "Param 'tweet_id' required")
|
|
706
|
+
uid = p.get("user_id") or self._ensure_user_id()
|
|
707
|
+
res = self._post_json(f"/2/users/{uid}/retweets", {"tweet_id": tid}, user_context=True)
|
|
708
|
+
return self.make_response(item, res)
|
|
709
|
+
|
|
710
|
+
def cmd_x_unretweet(self, item: dict) -> dict:
|
|
711
|
+
p = item.get("params", {})
|
|
712
|
+
tid = p.get("tweet_id")
|
|
713
|
+
if not tid:
|
|
714
|
+
return self.make_response(item, "Param 'tweet_id' required")
|
|
715
|
+
uid = p.get("user_id") or self._ensure_user_id()
|
|
716
|
+
res = self._delete(f"/2/users/{uid}/retweets/{tid}", user_context=True)
|
|
717
|
+
return self.make_response(item, res)
|
|
718
|
+
|
|
719
|
+
def cmd_x_bookmarks_list(self, item: dict) -> dict:
|
|
720
|
+
p = item.get("params", {})
|
|
721
|
+
uid = p.get("user_id") or self._ensure_user_id()
|
|
722
|
+
params = {"max_results": p.get("max_results", 50)}
|
|
723
|
+
if p.get("pagination_token"):
|
|
724
|
+
params["pagination_token"] = p["pagination_token"]
|
|
725
|
+
params.setdefault("tweet.fields", p.get("tweet_fields", "id,text,created_at,public_metrics,attachments,referenced_tweets"))
|
|
726
|
+
params.setdefault("expansions", p.get("expansions", "author_id,attachments.media_keys,referenced_tweets.id"))
|
|
727
|
+
params.setdefault("media.fields", p.get("media_fields", "media_key,type,url,preview_image_url,height,width,alt_text"))
|
|
728
|
+
res = self._get(f"/2/users/{uid}/bookmarks", params=params, user_context=True)
|
|
729
|
+
return self.make_response(item, res)
|
|
730
|
+
|
|
731
|
+
def cmd_x_bookmark_add(self, item: dict) -> dict:
|
|
732
|
+
p = item.get("params", {})
|
|
733
|
+
tid = p.get("tweet_id")
|
|
734
|
+
if not tid:
|
|
735
|
+
return self.make_response(item, "Param 'tweet_id' required")
|
|
736
|
+
uid = p.get("user_id") or self._ensure_user_id()
|
|
737
|
+
res = self._post_json(f"/2/users/{uid}/bookmarks", {"tweet_id": tid}, user_context=True)
|
|
738
|
+
return self.make_response(item, res)
|
|
739
|
+
|
|
740
|
+
def cmd_x_bookmark_remove(self, item: dict) -> dict:
|
|
741
|
+
p = item.get("params", {})
|
|
742
|
+
tid = p.get("tweet_id")
|
|
743
|
+
if not tid:
|
|
744
|
+
return self.make_response(item, "Param 'tweet_id' required")
|
|
745
|
+
uid = p.get("user_id") or self._ensure_user_id()
|
|
746
|
+
res = self._delete(f"/2/users/{uid}/bookmarks/{tid}", user_context=True)
|
|
747
|
+
return self.make_response(item, res)
|
|
748
|
+
|
|
749
|
+
def cmd_x_hide_reply(self, item: dict) -> dict:
|
|
750
|
+
p = item.get("params", {})
|
|
751
|
+
tid = p.get("tweet_id")
|
|
752
|
+
hidden = bool(p.get("hidden", True))
|
|
753
|
+
if not tid:
|
|
754
|
+
return self.make_response(item, "Param 'tweet_id' required")
|
|
755
|
+
res = self._post_json(f"/2/tweets/{tid}/hidden", {"hidden": hidden}, user_context=True)
|
|
756
|
+
return self.make_response(item, res)
|
|
757
|
+
|
|
758
|
+
# ---------------------- Media ----------------------
|
|
759
|
+
|
|
760
|
+
def _guess_mime(self, path: str) -> str:
|
|
761
|
+
mt, _ = mimetypes.guess_type(path)
|
|
762
|
+
return mt or "application/octet-stream"
|
|
763
|
+
|
|
764
|
+
def cmd_x_upload_media(self, item: dict) -> dict:
|
|
765
|
+
p = item.get("params", {})
|
|
766
|
+
local = self.prepare_path(p.get("path") or "")
|
|
767
|
+
if not os.path.exists(local):
|
|
768
|
+
return self.make_response(item, f"Local file not found: {local}")
|
|
769
|
+
media_type = p.get("media_type") or self._guess_mime(local)
|
|
770
|
+
category = p.get("media_category") or ("tweet_image" if media_type.startswith("image/") else
|
|
771
|
+
"tweet_gif" if media_type == "image/gif" else
|
|
772
|
+
"tweet_video" if media_type.startswith("video/") else "tweet_image")
|
|
773
|
+
chunk_size = int(p.get("chunk_size") or (1024 * 1024))
|
|
774
|
+
poll = bool(p.get("wait_for_processing", True))
|
|
775
|
+
|
|
776
|
+
init_form = {
|
|
777
|
+
"command": "INIT",
|
|
778
|
+
"media_type": media_type,
|
|
779
|
+
"total_bytes": str(os.path.getsize(local)),
|
|
780
|
+
"media_category": category,
|
|
781
|
+
}
|
|
782
|
+
init_res = self._post_form("/2/media/upload", init_form, user_context=True)
|
|
783
|
+
data = init_res.get("data") or init_res
|
|
784
|
+
media_id = str(data.get("id") or data.get("media_id") or "")
|
|
785
|
+
if not media_id:
|
|
786
|
+
return self.make_response(item, "Failed to INIT media upload")
|
|
787
|
+
|
|
788
|
+
seg = 0
|
|
789
|
+
with open(local, "rb") as fh:
|
|
790
|
+
while True:
|
|
791
|
+
buf = fh.read(chunk_size)
|
|
792
|
+
if not buf:
|
|
793
|
+
break
|
|
794
|
+
files = {"media": ("blob", buf, media_type)}
|
|
795
|
+
append_form = {"command": "APPEND", "media_id": media_id, "segment_index": str(seg)}
|
|
796
|
+
self._post_form("/2/media/upload", append_form, files=files, user_context=True)
|
|
797
|
+
seg += 1
|
|
798
|
+
|
|
799
|
+
fin_res = self._post_form("/2/media/upload", {"command": "FINALIZE", "media_id": media_id}, user_context=True)
|
|
800
|
+
fin_data = fin_res.get("data") or fin_res
|
|
801
|
+
proc = ((fin_data or {}).get("processing_info") or {})
|
|
802
|
+
if poll and proc:
|
|
803
|
+
while True:
|
|
804
|
+
time.sleep(int(proc.get("check_after_secs") or 2))
|
|
805
|
+
st = self._get("/2/media/upload", params={"command": "STATUS", "media_id": media_id}, user_context=True)
|
|
806
|
+
sdata = st.get("data") or st
|
|
807
|
+
pinfo = (sdata or {}).get("processing_info") or {}
|
|
808
|
+
state = (pinfo or {}).get("state")
|
|
809
|
+
if state in ("succeeded", None):
|
|
810
|
+
break
|
|
811
|
+
if state == "failed":
|
|
812
|
+
raise RuntimeError(f"Media processing failed: {sdata}")
|
|
813
|
+
proc = pinfo
|
|
814
|
+
|
|
815
|
+
return self.make_response(item, {"media_id": media_id, "media_key": fin_data.get("media_key")})
|
|
816
|
+
|
|
817
|
+
def cmd_x_media_set_alt_text(self, item: dict) -> dict:
|
|
818
|
+
p = item.get("params", {})
|
|
819
|
+
media_id = p.get("media_id")
|
|
820
|
+
alt_text = p.get("alt_text")
|
|
821
|
+
if not (media_id and alt_text):
|
|
822
|
+
return self.make_response(item, "Params 'media_id' and 'alt_text' required")
|
|
823
|
+
payload = {"id": media_id, "metadata": {"alt_text": {"text": alt_text}}}
|
|
824
|
+
res = self._post_json("/2/media/metadata", payload, user_context=True)
|
|
825
|
+
return self.make_response(item, res)
|
|
826
|
+
|
|
827
|
+
# ---------------------- FS helpers ----------------------
|
|
828
|
+
|
|
829
|
+
def prepare_path(self, path: str) -> str:
|
|
830
|
+
if path in [".", "./"]:
|
|
831
|
+
return self.plugin.window.core.config.get_user_dir("data")
|
|
832
|
+
if self.is_absolute_path(path):
|
|
833
|
+
return path
|
|
834
|
+
return os.path.join(self.plugin.window.core.config.get_user_dir("data"), path)
|
|
835
|
+
|
|
836
|
+
def is_absolute_path(self, path: str) -> bool:
|
|
837
|
+
return os.path.isabs(path)
|