pygpt-net 2.6.1__py3-none-any.whl → 2.6.6__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 (131) hide show
  1. pygpt_net/CHANGELOG.txt +23 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +20 -1
  4. pygpt_net/config.py +55 -65
  5. pygpt_net/controller/__init__.py +5 -2
  6. pygpt_net/controller/calendar/note.py +101 -126
  7. pygpt_net/controller/chat/chat.py +38 -35
  8. pygpt_net/controller/chat/render.py +154 -214
  9. pygpt_net/controller/chat/response.py +5 -3
  10. pygpt_net/controller/chat/stream.py +92 -27
  11. pygpt_net/controller/config/config.py +39 -42
  12. pygpt_net/controller/config/field/checkbox.py +16 -12
  13. pygpt_net/controller/config/field/checkbox_list.py +36 -31
  14. pygpt_net/controller/config/field/cmd.py +51 -57
  15. pygpt_net/controller/config/field/combo.py +33 -16
  16. pygpt_net/controller/config/field/dictionary.py +48 -55
  17. pygpt_net/controller/config/field/input.py +50 -32
  18. pygpt_net/controller/config/field/slider.py +40 -45
  19. pygpt_net/controller/config/field/textarea.py +20 -6
  20. pygpt_net/controller/config/placeholder.py +110 -231
  21. pygpt_net/controller/ctx/common.py +48 -48
  22. pygpt_net/controller/ctx/ctx.py +91 -132
  23. pygpt_net/controller/lang/mapping.py +57 -95
  24. pygpt_net/controller/lang/plugins.py +64 -55
  25. pygpt_net/controller/lang/settings.py +39 -38
  26. pygpt_net/controller/layout/layout.py +176 -109
  27. pygpt_net/controller/mode/mode.py +88 -85
  28. pygpt_net/controller/model/model.py +73 -73
  29. pygpt_net/controller/plugins/plugins.py +209 -223
  30. pygpt_net/controller/plugins/presets.py +54 -55
  31. pygpt_net/controller/plugins/settings.py +54 -69
  32. pygpt_net/controller/presets/editor.py +33 -88
  33. pygpt_net/controller/presets/experts.py +20 -1
  34. pygpt_net/controller/presets/presets.py +293 -298
  35. pygpt_net/controller/settings/profile.py +16 -4
  36. pygpt_net/controller/theme/theme.py +72 -81
  37. pygpt_net/controller/ui/mode.py +118 -186
  38. pygpt_net/controller/ui/tabs.py +69 -90
  39. pygpt_net/controller/ui/ui.py +47 -56
  40. pygpt_net/controller/ui/vision.py +24 -23
  41. pygpt_net/core/agents/runner.py +15 -7
  42. pygpt_net/core/bridge/bridge.py +5 -5
  43. pygpt_net/core/command/command.py +149 -219
  44. pygpt_net/core/ctx/ctx.py +94 -146
  45. pygpt_net/core/debug/debug.py +48 -58
  46. pygpt_net/core/experts/experts.py +3 -3
  47. pygpt_net/core/models/models.py +74 -112
  48. pygpt_net/core/modes/modes.py +13 -21
  49. pygpt_net/core/plugins/plugins.py +154 -177
  50. pygpt_net/core/presets/presets.py +103 -176
  51. pygpt_net/core/render/web/body.py +217 -215
  52. pygpt_net/core/render/web/renderer.py +330 -474
  53. pygpt_net/core/text/utils.py +28 -44
  54. pygpt_net/core/tokens/tokens.py +104 -203
  55. pygpt_net/data/config/config.json +3 -3
  56. pygpt_net/data/config/models.json +3 -3
  57. pygpt_net/data/locale/locale.de.ini +2 -0
  58. pygpt_net/data/locale/locale.en.ini +2 -0
  59. pygpt_net/data/locale/locale.es.ini +2 -0
  60. pygpt_net/data/locale/locale.fr.ini +2 -0
  61. pygpt_net/data/locale/locale.it.ini +2 -0
  62. pygpt_net/data/locale/locale.pl.ini +3 -1
  63. pygpt_net/data/locale/locale.uk.ini +2 -0
  64. pygpt_net/data/locale/locale.zh.ini +2 -0
  65. pygpt_net/item/ctx.py +141 -139
  66. pygpt_net/plugin/agent/plugin.py +2 -1
  67. pygpt_net/plugin/audio_output/plugin.py +5 -2
  68. pygpt_net/plugin/base/plugin.py +101 -85
  69. pygpt_net/plugin/bitbucket/__init__.py +12 -0
  70. pygpt_net/plugin/bitbucket/config.py +267 -0
  71. pygpt_net/plugin/bitbucket/plugin.py +126 -0
  72. pygpt_net/plugin/bitbucket/worker.py +569 -0
  73. pygpt_net/plugin/cmd_code_interpreter/plugin.py +3 -2
  74. pygpt_net/plugin/cmd_custom/plugin.py +3 -2
  75. pygpt_net/plugin/cmd_files/plugin.py +3 -2
  76. pygpt_net/plugin/cmd_history/plugin.py +3 -2
  77. pygpt_net/plugin/cmd_mouse_control/plugin.py +5 -2
  78. pygpt_net/plugin/cmd_serial/plugin.py +3 -2
  79. pygpt_net/plugin/cmd_system/plugin.py +3 -6
  80. pygpt_net/plugin/cmd_web/plugin.py +3 -2
  81. pygpt_net/plugin/experts/plugin.py +2 -2
  82. pygpt_net/plugin/facebook/__init__.py +12 -0
  83. pygpt_net/plugin/facebook/config.py +359 -0
  84. pygpt_net/plugin/facebook/plugin.py +113 -0
  85. pygpt_net/plugin/facebook/worker.py +698 -0
  86. pygpt_net/plugin/github/__init__.py +12 -0
  87. pygpt_net/plugin/github/config.py +441 -0
  88. pygpt_net/plugin/github/plugin.py +126 -0
  89. pygpt_net/plugin/github/worker.py +674 -0
  90. pygpt_net/plugin/google/__init__.py +12 -0
  91. pygpt_net/plugin/google/config.py +367 -0
  92. pygpt_net/plugin/google/plugin.py +126 -0
  93. pygpt_net/plugin/google/worker.py +826 -0
  94. pygpt_net/plugin/idx_llama_index/plugin.py +3 -2
  95. pygpt_net/plugin/mailer/plugin.py +3 -5
  96. pygpt_net/plugin/openai_vision/plugin.py +3 -2
  97. pygpt_net/plugin/real_time/plugin.py +52 -60
  98. pygpt_net/plugin/slack/__init__.py +12 -0
  99. pygpt_net/plugin/slack/config.py +349 -0
  100. pygpt_net/plugin/slack/plugin.py +115 -0
  101. pygpt_net/plugin/slack/worker.py +639 -0
  102. pygpt_net/plugin/telegram/__init__.py +12 -0
  103. pygpt_net/plugin/telegram/config.py +308 -0
  104. pygpt_net/plugin/telegram/plugin.py +117 -0
  105. pygpt_net/plugin/telegram/worker.py +563 -0
  106. pygpt_net/plugin/twitter/__init__.py +12 -0
  107. pygpt_net/plugin/twitter/config.py +491 -0
  108. pygpt_net/plugin/twitter/plugin.py +125 -0
  109. pygpt_net/plugin/twitter/worker.py +837 -0
  110. pygpt_net/provider/agents/llama_index/legacy/openai_assistant.py +35 -3
  111. pygpt_net/tools/code_interpreter/tool.py +0 -1
  112. pygpt_net/tools/translator/tool.py +1 -1
  113. pygpt_net/ui/base/config_dialog.py +86 -100
  114. pygpt_net/ui/base/context_menu.py +48 -46
  115. pygpt_net/ui/dialog/preset.py +34 -77
  116. pygpt_net/ui/layout/ctx/ctx_list.py +10 -6
  117. pygpt_net/ui/layout/toolbox/presets.py +41 -41
  118. pygpt_net/ui/main.py +49 -31
  119. pygpt_net/ui/tray.py +61 -60
  120. pygpt_net/ui/widget/calendar/select.py +86 -70
  121. pygpt_net/ui/widget/lists/attachment.py +86 -44
  122. pygpt_net/ui/widget/lists/base_list_combo.py +85 -33
  123. pygpt_net/ui/widget/lists/context.py +135 -188
  124. pygpt_net/ui/widget/lists/preset.py +59 -61
  125. pygpt_net/ui/widget/textarea/web.py +161 -48
  126. pygpt_net/utils.py +8 -1
  127. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/METADATA +164 -2
  128. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/RECORD +131 -103
  129. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/LICENSE +0 -0
  130. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/WHEEL +0 -0
  131. {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,698 @@
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 base64
15
+ import hashlib
16
+ import http.server
17
+ import json
18
+ import mimetypes
19
+ import os
20
+ import random
21
+ import socket
22
+ import threading
23
+ import time
24
+
25
+ from typing import Any, Dict, List, Optional
26
+ from urllib.parse import urlencode, 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
+ Facebook (Meta Graph API) plugin worker:
41
+ OAuth2 (PKCE), Me, Pages, Posts, Media. Auto-authorization when required.
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"] == "fb_oauth_begin":
69
+ response = self.cmd_fb_oauth_begin(item)
70
+ elif item["cmd"] == "fb_oauth_exchange":
71
+ response = self.cmd_fb_oauth_exchange(item)
72
+ elif item["cmd"] == "fb_token_extend":
73
+ response = self.cmd_fb_token_extend(item)
74
+
75
+ # -------- Me --------
76
+ elif item["cmd"] == "fb_me":
77
+ response = self.cmd_fb_me(item)
78
+
79
+ # -------- Pages --------
80
+ elif item["cmd"] == "fb_pages_list":
81
+ response = self.cmd_fb_pages_list(item)
82
+ elif item["cmd"] == "fb_page_set_default":
83
+ response = self.cmd_fb_page_set_default(item)
84
+
85
+ # -------- Posts --------
86
+ elif item["cmd"] == "fb_page_posts":
87
+ response = self.cmd_fb_page_posts(item)
88
+ elif item["cmd"] == "fb_page_post_create":
89
+ response = self.cmd_fb_page_post_create(item)
90
+ elif item["cmd"] == "fb_page_post_delete":
91
+ response = self.cmd_fb_page_post_delete(item)
92
+
93
+ # -------- Media --------
94
+ elif item["cmd"] == "fb_page_photo_upload":
95
+ response = self.cmd_fb_page_photo_upload(item)
96
+
97
+ if response:
98
+ responses.append(response)
99
+
100
+ except Exception as e:
101
+ responses.append(self.make_response(item, self.throw_error(e)))
102
+
103
+ if responses:
104
+ self.reply_more(responses)
105
+ if self.msg is not None:
106
+ self.status(self.msg)
107
+ except Exception as e:
108
+ self.error(e)
109
+ finally:
110
+ self.cleanup()
111
+
112
+ # ---------------------- HTTP / helpers ----------------------
113
+
114
+ def _graph_version(self) -> str:
115
+ # expects like "v21.0"
116
+ v = (self.plugin.get_option_value("graph_version") or "v21.0").strip("/")
117
+ return v
118
+
119
+ def _api_base(self) -> str:
120
+ # final: https://graph.facebook.com/v21.0
121
+ base = (self.plugin.get_option_value("api_base") or "https://graph.facebook.com").rstrip("/")
122
+ return f"{base}/{self._graph_version()}"
123
+
124
+ def _auth_base(self) -> str:
125
+ # final: https://www.facebook.com/v21.0
126
+ base = (self.plugin.get_option_value("authorize_base") or "https://www.facebook.com").rstrip("/")
127
+ return f"{base}/{self._graph_version()}"
128
+
129
+ def _timeout(self) -> int:
130
+ try:
131
+ return int(self.plugin.get_option_value("http_timeout") or 30)
132
+ except Exception:
133
+ return 30
134
+
135
+ def _now(self) -> int:
136
+ return int(time.time())
137
+
138
+ def _auth_header(self, token: Optional[str] = None, user_context: bool = False) -> Dict[str, str]:
139
+ # prefer explicit token; else resolve user token (auto-run OAuth if enabled)
140
+ tok = (token or "").strip()
141
+ if not tok:
142
+ if user_context:
143
+ tok = self._ensure_user_token(optional=True)
144
+ if not tok and bool(self.plugin.get_option_value("oauth_auto_begin") or True):
145
+ self._auto_authorize_interactive()
146
+ tok = self._ensure_user_token(optional=False)
147
+ else:
148
+ tok = self._ensure_user_token(optional=True)
149
+ if not tok and bool(self.plugin.get_option_value("oauth_auto_begin") or True):
150
+ self._auto_authorize_interactive()
151
+ tok = self._ensure_user_token(optional=False)
152
+ if not tok:
153
+ raise RuntimeError("Missing access token. Complete Facebook OAuth first.")
154
+ return {
155
+ "Authorization": f"Bearer {tok}",
156
+ "User-Agent": "pygpt-net-facebook-plugin/1.0",
157
+ "Accept": "application/json",
158
+ }
159
+
160
+ def _handle_response(self, r: requests.Response) -> dict:
161
+ try:
162
+ data = r.json() if r.content else {}
163
+ except Exception:
164
+ data = {"raw": r.text}
165
+ if not (200 <= r.status_code < 300):
166
+ # Graph errors come in "error" object
167
+ try:
168
+ err = (data or {}).get("error")
169
+ if err:
170
+ raise RuntimeError(json.dumps({"status": r.status_code, "error": err}, ensure_ascii=False))
171
+ except Exception:
172
+ pass
173
+ raise RuntimeError(f"HTTP {r.status_code}: {data or r.text}")
174
+ # include rate meta if present
175
+ data["_meta"] = {"status": r.status_code}
176
+ return data
177
+
178
+ def _get(self, path: str, params: dict = None, token: Optional[str] = None, user_context: bool = False):
179
+ url = f"{self._api_base()}{path}"
180
+ headers = self._auth_header(token=token, user_context=user_context)
181
+ r = requests.get(url, headers=headers, params=params or {}, timeout=self._timeout())
182
+ return self._handle_response(r)
183
+
184
+ def _delete(self, path: str, params: dict = None, token: Optional[str] = None, user_context: bool = False):
185
+ url = f"{self._api_base()}{path}"
186
+ headers = self._auth_header(token=token, user_context=user_context)
187
+ r = requests.delete(url, headers=headers, params=params or {}, timeout=self._timeout())
188
+ return self._handle_response(r)
189
+
190
+ def _post_json(self, path: str, payload: dict, token: Optional[str] = None, user_context: bool = False):
191
+ url = f"{self._api_base()}{path}"
192
+ headers = self._auth_header(token=token, user_context=user_context)
193
+ headers["Content-Type"] = "application/json"
194
+ r = requests.post(url, headers=headers, json=payload or {}, timeout=self._timeout())
195
+ return self._handle_response(r)
196
+
197
+ def _post_form(self, path: str, form: dict, files: dict | None = None, token: Optional[str] = None, user_context: bool = False):
198
+ url = f"{self._api_base()}{path}"
199
+ headers = self._auth_header(token=token, user_context=user_context)
200
+ r = requests.post(url, headers=headers, data=form or {}, files=files, timeout=self._timeout())
201
+ return self._handle_response(r)
202
+
203
+ # ---------------------- Token storage ----------------------
204
+
205
+ def _ensure_user_token(self, optional: bool = False) -> Optional[str]:
206
+ access = (self.plugin.get_option_value("oauth2_access_token") or "").strip()
207
+ exp = int(self.plugin.get_option_value("oauth2_expires_at") or 0)
208
+ if access and exp and self._now() >= exp:
209
+ # Facebook does not provide a standard refresh_token. Use fb_token_extend manually if needed.
210
+ pass
211
+ if not access and not optional:
212
+ raise RuntimeError("User access token missing. Run OAuth first.")
213
+ return access or None
214
+
215
+ def _save_tokens(self, tok: dict):
216
+ access = tok.get("access_token")
217
+ expires_in = int(tok.get("expires_in") or 0)
218
+ expires_at = self._now() + expires_in - 60 if expires_in else 0
219
+ if access:
220
+ self.plugin.set_option_value("oauth2_access_token", access)
221
+ if expires_at:
222
+ self.plugin.set_option_value("oauth2_expires_at", str(expires_at))
223
+
224
+ # ---------------------- OAuth2 (PKCE) ----------------------
225
+
226
+ def _gen_code_verifier(self, n: int = 64) -> str:
227
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
228
+ return "".join(random.choice(alphabet) for _ in range(n))
229
+
230
+ def _code_challenge(self, verifier: str) -> str:
231
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
232
+ return base64.urlsafe_b64encode(digest).decode().rstrip("=")
233
+
234
+ def _redirect_is_local(self, redirect_uri: str) -> bool:
235
+ try:
236
+ u = urlparse(redirect_uri)
237
+ return u.scheme in ("http",) and (u.hostname in ("127.0.0.1", "localhost"))
238
+ except Exception:
239
+ return False
240
+
241
+ def _can_bind(self, host: str, port: int) -> bool:
242
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
243
+ try:
244
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
245
+ s.bind((host, port))
246
+ return True
247
+ except Exception:
248
+ return False
249
+ finally:
250
+ try:
251
+ s.close()
252
+ except Exception:
253
+ pass
254
+
255
+ def _pick_port(self, host: str, preferred: int) -> int:
256
+ base = preferred if preferred and preferred >= 1024 else 8732
257
+ for p in range(base, base + 50):
258
+ if self._can_bind(host, p):
259
+ return p
260
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
261
+ s.bind((host, 0))
262
+ free_port = s.getsockname()[1]
263
+ s.close()
264
+ return free_port
265
+
266
+ def _prepare_effective_redirect(self, redirect_uri: str) -> str:
267
+ u = urlparse(redirect_uri)
268
+ host = u.hostname or "127.0.0.1"
269
+ scheme = u.scheme or "http"
270
+ path = u.path or "/"
271
+ if not self._redirect_is_local(redirect_uri):
272
+ return redirect_uri
273
+ port = u.port
274
+ allow_fallback = bool(self.plugin.get_option_value("oauth_allow_port_fallback") or True)
275
+ if not port or port < 1024 or not self._can_bind(host, port):
276
+ if not allow_fallback and (not port or port < 1024):
277
+ raise RuntimeError("Configured redirect uses a privileged or unavailable port. Use port >1024.")
278
+ pref = int(self.plugin.get_option_value("oauth_local_port") or 8732)
279
+ new_port = self._pick_port(host, pref)
280
+ return f"{scheme}://{host}:{new_port}{path}"
281
+ return redirect_uri
282
+
283
+ def _build_auth_url(self, scopes: str, verifier: str, state: str, nonce: str, redirect_uri: Optional[str] = None) -> str:
284
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
285
+ redirect_uri = redirect_uri or (self.plugin.get_option_value("oauth2_redirect_uri") or "")
286
+ challenge = self._code_challenge(verifier)
287
+ # Facebook Login dialog with PKCE (OIDC-compatible)
288
+ return (
289
+ f"{self._auth_base()}/dialog/oauth?"
290
+ + urlencode({
291
+ "client_id": client_id,
292
+ "response_type": "code",
293
+ "redirect_uri": redirect_uri,
294
+ "scope": scopes,
295
+ "state": state,
296
+ "code_challenge": challenge,
297
+ "code_challenge_method": "S256",
298
+ "nonce": nonce,
299
+ })
300
+ )
301
+
302
+ def _run_local_callback_and_wait(self, auth_url: str, redirect_uri: str) -> (str, str):
303
+ u = urlparse(redirect_uri)
304
+ host = u.hostname or "127.0.0.1"
305
+ port = u.port or 8732
306
+ timeout_sec = int(self.plugin.get_option_value("oauth_local_timeout") or 180)
307
+ html_ok = (self.plugin.get_option_value("oauth_success_html")
308
+ or "<html><body><h3>Authorization complete. You can close this window.</h3></body></html>")
309
+ html_err = (self.plugin.get_option_value("oauth_fail_html")
310
+ or "<html><body><h3>Authorization failed.</h3></body></html>")
311
+
312
+ result = {"code": None, "state": None}
313
+ event = threading.Event()
314
+
315
+ class Handler(http.server.BaseHTTPRequestHandler):
316
+ def log_message(self, fmt, *args):
317
+ return
318
+
319
+ def do_GET(self):
320
+ try:
321
+ q = urlparse(self.path)
322
+ qs = parse_qs(q.query)
323
+ result["code"] = (qs.get("code") or [None])[0]
324
+ result["state"] = (qs.get("state") or [None])[0]
325
+ ok = result["code"] is not None
326
+ data = html_ok if ok else html_err
327
+ self.send_response(200 if ok else 400)
328
+ self.send_header("Content-Type", "text/html; charset=utf-8")
329
+ self.send_header("Content-Length", str(len(data.encode("utf-8"))))
330
+ self.end_headers()
331
+ self.wfile.write(data.encode("utf-8"))
332
+ finally:
333
+ event.set()
334
+
335
+ try:
336
+ httpd = http.server.HTTPServer((host, port), Handler)
337
+ except PermissionError:
338
+ raise RuntimeError(f"Cannot bind local callback on {host}:{port}. Use a port >1024.")
339
+ except OSError as e:
340
+ raise RuntimeError(f"Port {port} busy on {host}. Change oauth_local_port. ({e})")
341
+
342
+ srv_thr = threading.Thread(target=httpd.serve_forever, daemon=True)
343
+ srv_thr.start()
344
+
345
+ try:
346
+ if bool(self.plugin.get_option_value("oauth_open_browser") or True):
347
+ self.plugin.open_url(auth_url)
348
+ except Exception:
349
+ pass
350
+
351
+ got = event.wait(timeout=timeout_sec)
352
+ try:
353
+ httpd.shutdown()
354
+ except Exception:
355
+ pass
356
+ srv_thr.join(timeout=5)
357
+
358
+ if not got or not result["code"]:
359
+ raise RuntimeError("No OAuth code received (timeout). Check redirect URI in Meta App settings.")
360
+ return result["code"], result["state"]
361
+
362
+ def _exchange_code_for_token(self, code: str):
363
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
364
+ client_secret = self.plugin.get_option_value("oauth2_client_secret") or ""
365
+ redirect_uri = self.plugin.get_option_value("oauth2_redirect_uri") or ""
366
+ verifier = self.plugin.get_option_value("oauth2_code_verifier") or ""
367
+ if not (client_id and redirect_uri and code):
368
+ raise RuntimeError("Exchange failed: missing client_id/redirect_uri/code.")
369
+ # With PKCE we may omit client_secret and include code_verifier
370
+ params = {
371
+ "client_id": client_id,
372
+ "redirect_uri": redirect_uri,
373
+ "code": code,
374
+ }
375
+ if client_secret and bool(self.plugin.get_option_value("oauth2_confidential") or False):
376
+ params["client_secret"] = client_secret
377
+ else:
378
+ if not verifier:
379
+ raise RuntimeError("Missing code_verifier for PKCE exchange.")
380
+ params["code_verifier"] = verifier
381
+
382
+ url = f"{self._api_base()}/oauth/access_token"
383
+ r = requests.get(url, params=params, timeout=self._timeout())
384
+ res = self._handle_response(r)
385
+ self._save_tokens(res)
386
+
387
+ def _auto_authorize_interactive(self):
388
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
389
+ redirect_cfg = self.plugin.get_option_value("oauth2_redirect_uri") or ""
390
+ if not (client_id and redirect_cfg):
391
+ raise RuntimeError("OAuth auto-start: set oauth2_client_id and oauth2_redirect_uri first.")
392
+
393
+ scopes = (self.plugin.get_option_value("oauth2_scopes") or
394
+ "public_profile pages_show_list pages_read_engagement pages_manage_posts pages_read_user_content openid")
395
+ verifier = self._gen_code_verifier()
396
+ state = self._gen_code_verifier(32)
397
+ nonce = self._gen_code_verifier(32)
398
+ self.plugin.set_option_value("oauth2_code_verifier", verifier)
399
+ self.plugin.set_option_value("oauth2_state", state)
400
+ self.plugin.set_option_value("oauth2_nonce", nonce)
401
+
402
+ effective_redirect = self._prepare_effective_redirect(redirect_cfg)
403
+ auth_url = self._build_auth_url(scopes, verifier, state, nonce, redirect_uri=effective_redirect)
404
+
405
+ if bool(self.plugin.get_option_value("oauth_open_browser") or True):
406
+ try:
407
+ self.plugin.open_url(auth_url)
408
+ except Exception:
409
+ pass
410
+
411
+ if bool(self.plugin.get_option_value("oauth_local_server") or True) and self._redirect_is_local(effective_redirect):
412
+ code, st = self._run_local_callback_and_wait(auth_url, effective_redirect)
413
+ if (self.plugin.get_option_value("oauth2_state") or "") and st and st != self.plugin.get_option_value("oauth2_state"):
414
+ raise RuntimeError("OAuth state mismatch.")
415
+ self._exchange_code_for_token(code)
416
+ try:
417
+ me = self._get("/me", params={"fields": "id,name"}, user_context=True)
418
+ usr = (me or {})
419
+ if usr.get("id"):
420
+ self.plugin.set_option_value("user_id", usr["id"])
421
+ if usr.get("name"):
422
+ self.plugin.set_option_value("user_name", usr["name"])
423
+ except Exception:
424
+ pass
425
+ self.msg = f"Facebook: Authorization complete on {effective_redirect}."
426
+ return
427
+
428
+ self.msg = f"Authorize in browser and run fb_oauth_exchange with 'code'. URL: {auth_url}"
429
+
430
+ # ---------------------- Auth commands ----------------------
431
+
432
+ def cmd_fb_oauth_begin(self, item: dict) -> dict:
433
+ p = item.get("params", {}) or {}
434
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
435
+ redirect_cfg = self.plugin.get_option_value("oauth2_redirect_uri") or ""
436
+ scopes = p.get("scopes") or (self.plugin.get_option_value("oauth2_scopes") or
437
+ "public_profile pages_show_list pages_read_engagement pages_manage_posts pages_read_user_content openid")
438
+ if not (client_id and redirect_cfg):
439
+ return self.make_response(item, "Set oauth2_client_id and oauth2_redirect_uri in options first.")
440
+ verifier = self._gen_code_verifier()
441
+ state = p.get("state") or self._gen_code_verifier(32)
442
+ nonce = self._gen_code_verifier(32)
443
+ self.plugin.set_option_value("oauth2_code_verifier", verifier)
444
+ self.plugin.set_option_value("oauth2_state", state)
445
+ self.plugin.set_option_value("oauth2_nonce", nonce)
446
+ effective_redirect = self._prepare_effective_redirect(redirect_cfg)
447
+ auth_url = self._build_auth_url(scopes, verifier, state, nonce, redirect_uri=effective_redirect)
448
+ if bool(self.plugin.get_option_value("oauth_open_browser") or True):
449
+ try:
450
+ self.plugin.open_url(auth_url)
451
+ except Exception:
452
+ pass
453
+ return self.make_response(item, {"authorize_url": auth_url, "redirect_uri": effective_redirect})
454
+
455
+ def cmd_fb_oauth_exchange(self, item: dict) -> dict:
456
+ p = item.get("params", {}) or {}
457
+ code = p.get("code")
458
+ state = p.get("state")
459
+ expected_state = self.plugin.get_option_value("oauth2_state") or ""
460
+ if not code:
461
+ return self.make_response(item, "Param 'code' required.")
462
+ if expected_state and state and state != expected_state:
463
+ return self.make_response(item, "State mismatch.")
464
+ self._exchange_code_for_token(code)
465
+ # cache identity
466
+ try:
467
+ me = self._get("/me", params={"fields": "id,name"}, user_context=True)
468
+ if me.get("id"):
469
+ self.plugin.set_option_value("user_id", me["id"])
470
+ if me.get("name"):
471
+ self.plugin.set_option_value("user_name", me["name"])
472
+ except Exception:
473
+ pass
474
+ return self.make_response(item, {
475
+ "access_token": self.plugin.get_option_value("oauth2_access_token"),
476
+ "expires_at": self.plugin.get_option_value("oauth2_expires_at"),
477
+ })
478
+
479
+ def cmd_fb_token_extend(self, item: dict) -> dict:
480
+ # Exchange short-lived user token for long-lived user token
481
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
482
+ client_secret = self.plugin.get_option_value("oauth2_client_secret") or ""
483
+ if not (client_id and client_secret):
484
+ return self.make_response(item, "Set oauth2_client_id and oauth2_client_secret first.")
485
+ access = (self.plugin.get_option_value("oauth2_access_token") or "").strip()
486
+ if not access:
487
+ return self.make_response(item, "No user access token to extend.")
488
+ url = f"{self._api_base()}/oauth/access_token"
489
+ params = {
490
+ "grant_type": "fb_exchange_token",
491
+ "client_id": client_id,
492
+ "client_secret": client_secret,
493
+ "fb_exchange_token": access,
494
+ }
495
+ r = requests.get(url, params=params, timeout=self._timeout())
496
+ res = self._handle_response(r)
497
+ self._save_tokens(res)
498
+ return self.make_response(item, {"access_token": res.get("access_token"), "expires_in": res.get("expires_in")})
499
+
500
+ # ---------------------- Me ----------------------
501
+
502
+ def cmd_fb_me(self, item: dict) -> dict:
503
+ p = item.get("params", {}) or {}
504
+ fields = p.get("fields") or "id,name"
505
+ res = self._get("/me", params={"fields": fields}, user_context=True)
506
+ if res.get("id"):
507
+ self.plugin.set_option_value("user_id", res["id"])
508
+ if res.get("name"):
509
+ self.plugin.set_option_value("user_name", res["name"])
510
+ return self.make_response(item, res)
511
+
512
+ # ---------------------- Pages ----------------------
513
+
514
+ def cmd_fb_pages_list(self, item: dict) -> dict:
515
+ p = item.get("params", {}) or {}
516
+ fields = p.get("fields") or "id,name,access_token,tasks"
517
+ limit = int(p.get("limit") or 25)
518
+ params = {"fields": fields, "limit": limit}
519
+ if p.get("after"):
520
+ params["after"] = p["after"]
521
+ if p.get("before"):
522
+ params["before"] = p["before"]
523
+ res = self._get("/me/accounts", params=params, user_context=True)
524
+ return self.make_response(item, res)
525
+
526
+ def cmd_fb_page_set_default(self, item: dict) -> dict:
527
+ p = item.get("params", {}) or {}
528
+ page_id = p.get("page_id")
529
+ if not page_id:
530
+ return self.make_response(item, "Param 'page_id' required")
531
+ # fetch name + access_token
532
+ fetch_token = bool(p.get("fetch_token", True))
533
+ name = None
534
+ tok = None
535
+ fields = "id,name" + (",access_token" if fetch_token else "")
536
+ info = self._get(f"/{page_id}", params={"fields": fields}, user_context=True)
537
+ name = info.get("name")
538
+ tok = info.get("access_token")
539
+ self.plugin.set_option_value("fb_page_id", page_id)
540
+ if name:
541
+ self.plugin.set_option_value("fb_page_name", name)
542
+ if tok:
543
+ self.plugin.set_option_value("fb_page_access_token", tok)
544
+ return self.make_response(item, {"page_id": page_id, "page_name": name, "has_token": bool(tok)})
545
+
546
+ def _ensure_page_id(self) -> str:
547
+ pid = self.plugin.get_option_value("fb_page_id") or ""
548
+ if not pid:
549
+ raise RuntimeError("No default page_id set. Run fb_page_set_default or pass page_id in params.")
550
+ return pid
551
+
552
+ def _ensure_page_token(self, page_id: str) -> str:
553
+ # prefer cached token for default page
554
+ if page_id == (self.plugin.get_option_value("fb_page_id") or ""):
555
+ tok = (self.plugin.get_option_value("fb_page_access_token") or "").strip()
556
+ if tok:
557
+ return tok
558
+ # try to fetch fresh access_token via fields=access_token
559
+ info = self._get(f"/{page_id}", params={"fields": "access_token"}, user_context=True)
560
+ tok = info.get("access_token")
561
+ if not tok:
562
+ raise RuntimeError("Cannot resolve Page access token. Ensure permissions and roles are correct.")
563
+ if page_id == (self.plugin.get_option_value("fb_page_id") or ""):
564
+ self.plugin.set_option_value("fb_page_access_token", tok)
565
+ return tok
566
+
567
+ # ---------------------- Posts ----------------------
568
+
569
+ def cmd_fb_page_posts(self, item: dict) -> dict:
570
+ p = item.get("params", {}) or {}
571
+ page_id = p.get("page_id") or self._ensure_page_id()
572
+ token = self._ensure_page_token(page_id)
573
+ fields = p.get("fields") or "id,message,created_time,permalink_url,is_published"
574
+ limit = int(p.get("limit") or 25)
575
+ params: Dict[str, Any] = {"fields": fields, "limit": limit}
576
+ for k in ("since", "until", "after", "before"):
577
+ if p.get(k):
578
+ params[k] = p[k]
579
+ res = self._get(f"/{page_id}/feed", params=params, token=token, user_context=False)
580
+ return self.make_response(item, res)
581
+
582
+ def _upload_photo(self, page_id: str, token: str, path: Optional[str] = None, url: Optional[str] = None,
583
+ caption: Optional[str] = None, published: bool = True, temporary: bool = False) -> dict:
584
+ form: Dict[str, Any] = {"published": "true" if published else "false"}
585
+ if caption:
586
+ form["caption"] = caption
587
+ if temporary:
588
+ form["temporary"] = "true"
589
+ files = None
590
+ if path:
591
+ local = self.prepare_path(path)
592
+ if not os.path.exists(local):
593
+ raise RuntimeError(f"Local file not found: {local}")
594
+ mt, _ = mimetypes.guess_type(local)
595
+ mt = mt or "application/octet-stream"
596
+ files = {"source": (os.path.basename(local), open(local, "rb"), mt)}
597
+ elif url:
598
+ form["url"] = url
599
+ else:
600
+ raise RuntimeError("Provide 'path' or 'url' to upload photo.")
601
+ res = self._post_form(f"/{page_id}/photos", form=form, files=files, token=token, user_context=False)
602
+ return res
603
+
604
+ def cmd_fb_page_photo_upload(self, item: dict) -> dict:
605
+ p = item.get("params", {}) or {}
606
+ page_id = p.get("page_id") or self._ensure_page_id()
607
+ token = self._ensure_page_token(page_id)
608
+ res = self._upload_photo(
609
+ page_id=page_id,
610
+ token=token,
611
+ path=p.get("path"),
612
+ url=p.get("url"),
613
+ caption=p.get("caption"),
614
+ published=bool(p.get("published", True)),
615
+ temporary=bool(p.get("temporary", False)),
616
+ )
617
+ return self.make_response(item, res)
618
+
619
+ def _upload_photos_unpublished(self, page_id: str, token: str, photos: List[dict]) -> List[dict]:
620
+ # photos: [{"path": "..."}] or [{"url": "..."}]
621
+ media = []
622
+ for ph in photos:
623
+ res = self._upload_photo(
624
+ page_id=page_id,
625
+ token=token,
626
+ path=ph.get("path"),
627
+ url=ph.get("url"),
628
+ caption=ph.get("caption"),
629
+ published=False,
630
+ temporary=bool(ph.get("temporary", False)),
631
+ )
632
+ pid = res.get("id")
633
+ if pid:
634
+ media.append({"media_fbid": pid})
635
+ return media
636
+
637
+ def cmd_fb_page_post_create(self, item: dict) -> dict:
638
+ p = item.get("params", {}) or {}
639
+ page_id = p.get("page_id") or self._ensure_page_id()
640
+ token = self._ensure_page_token(page_id)
641
+
642
+ payload: Dict[str, Any] = {}
643
+ if p.get("message") is not None:
644
+ payload["message"] = p.get("message") or ""
645
+ if p.get("link"):
646
+ payload["link"] = p.get("link")
647
+ published = bool(p.get("published", True))
648
+ payload["published"] = published
649
+ if not published and p.get("scheduled_publish_time"):
650
+ payload["scheduled_publish_time"] = p.get("scheduled_publish_time")
651
+ payload["unpublished_content_type"] = "SCHEDULED"
652
+ if p.get("targeting"):
653
+ payload["targeting"] = p.get("targeting")
654
+
655
+ # attached_media via already uploaded photo ids or auto-upload URLs/paths
656
+ attached_media = []
657
+ media_fbids = p.get("media_fbids") or []
658
+ if media_fbids:
659
+ for mid in media_fbids:
660
+ attached_media.append({"media_fbid": str(mid)})
661
+ photo_urls = p.get("photo_urls") or []
662
+ photo_paths = p.get("photo_paths") or []
663
+ if photo_urls or photo_paths:
664
+ lst = ([{"url": u} for u in photo_urls] + [{"path": ph} for ph in photo_paths])
665
+ attached_media.extend(self._upload_photos_unpublished(page_id, token, lst))
666
+ if attached_media:
667
+ payload["attached_media"] = attached_media
668
+
669
+ res = self._post_json(f"/{page_id}/feed", payload=payload, token=token, user_context=False)
670
+ return self.make_response(item, res)
671
+
672
+ def cmd_fb_page_post_delete(self, item: dict) -> dict:
673
+ p = item.get("params", {}) or {}
674
+ post_id = p.get("post_id")
675
+ if not post_id:
676
+ return self.make_response(item, "Param 'post_id' required (e.g. {pageid}_{postid})")
677
+ # Page token required to delete
678
+ page_id = (self.plugin.get_option_value("fb_page_id") or "")
679
+ token = None
680
+ if page_id:
681
+ try:
682
+ token = self._ensure_page_token(page_id)
683
+ except Exception:
684
+ token = None
685
+ res = self._delete(f"/{post_id}", token=token, user_context=token is None)
686
+ return self.make_response(item, res)
687
+
688
+ # ---------------------- FS helpers ----------------------
689
+
690
+ def prepare_path(self, path: str) -> str:
691
+ if path in [".", "./"]:
692
+ return self.plugin.window.core.config.get_user_dir("data")
693
+ if self.is_absolute_path(path):
694
+ return path
695
+ return os.path.join(self.plugin.window.core.config.get_user_dir("data"), path)
696
+
697
+ def is_absolute_path(self, path: str) -> bool:
698
+ return os.path.isabs(path)