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.
Files changed (61) hide show
  1. pygpt_net/CHANGELOG.txt +4 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +15 -1
  4. pygpt_net/controller/chat/response.py +5 -3
  5. pygpt_net/controller/chat/stream.py +40 -2
  6. pygpt_net/controller/plugins/plugins.py +25 -0
  7. pygpt_net/controller/presets/editor.py +33 -88
  8. pygpt_net/controller/presets/experts.py +20 -1
  9. pygpt_net/controller/presets/presets.py +2 -2
  10. pygpt_net/controller/ui/mode.py +17 -66
  11. pygpt_net/core/agents/runner.py +15 -7
  12. pygpt_net/core/experts/experts.py +3 -3
  13. pygpt_net/data/config/config.json +3 -3
  14. pygpt_net/data/config/models.json +3 -3
  15. pygpt_net/data/locale/locale.de.ini +2 -0
  16. pygpt_net/data/locale/locale.en.ini +2 -0
  17. pygpt_net/data/locale/locale.es.ini +2 -0
  18. pygpt_net/data/locale/locale.fr.ini +2 -0
  19. pygpt_net/data/locale/locale.it.ini +2 -0
  20. pygpt_net/data/locale/locale.pl.ini +3 -1
  21. pygpt_net/data/locale/locale.uk.ini +2 -0
  22. pygpt_net/data/locale/locale.zh.ini +2 -0
  23. pygpt_net/plugin/base/plugin.py +35 -3
  24. pygpt_net/plugin/bitbucket/__init__.py +12 -0
  25. pygpt_net/plugin/bitbucket/config.py +267 -0
  26. pygpt_net/plugin/bitbucket/plugin.py +125 -0
  27. pygpt_net/plugin/bitbucket/worker.py +569 -0
  28. pygpt_net/plugin/facebook/__init__.py +12 -0
  29. pygpt_net/plugin/facebook/config.py +359 -0
  30. pygpt_net/plugin/facebook/plugin.py +114 -0
  31. pygpt_net/plugin/facebook/worker.py +698 -0
  32. pygpt_net/plugin/github/__init__.py +12 -0
  33. pygpt_net/plugin/github/config.py +441 -0
  34. pygpt_net/plugin/github/plugin.py +124 -0
  35. pygpt_net/plugin/github/worker.py +674 -0
  36. pygpt_net/plugin/google/__init__.py +12 -0
  37. pygpt_net/plugin/google/config.py +367 -0
  38. pygpt_net/plugin/google/plugin.py +126 -0
  39. pygpt_net/plugin/google/worker.py +826 -0
  40. pygpt_net/plugin/slack/__init__.py +12 -0
  41. pygpt_net/plugin/slack/config.py +349 -0
  42. pygpt_net/plugin/slack/plugin.py +116 -0
  43. pygpt_net/plugin/slack/worker.py +639 -0
  44. pygpt_net/plugin/telegram/__init__.py +12 -0
  45. pygpt_net/plugin/telegram/config.py +308 -0
  46. pygpt_net/plugin/telegram/plugin.py +118 -0
  47. pygpt_net/plugin/telegram/worker.py +563 -0
  48. pygpt_net/plugin/twitter/__init__.py +12 -0
  49. pygpt_net/plugin/twitter/config.py +491 -0
  50. pygpt_net/plugin/twitter/plugin.py +126 -0
  51. pygpt_net/plugin/twitter/worker.py +837 -0
  52. pygpt_net/provider/agents/llama_index/legacy/openai_assistant.py +35 -3
  53. pygpt_net/ui/base/config_dialog.py +4 -0
  54. pygpt_net/ui/dialog/preset.py +34 -77
  55. pygpt_net/ui/layout/toolbox/presets.py +2 -2
  56. pygpt_net/ui/main.py +3 -1
  57. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/METADATA +145 -2
  58. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/RECORD +61 -33
  59. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.2.dist-info}/WHEEL +0 -0
  61. {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)