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,639 @@
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 http.server
16
+ import json
17
+ import mimetypes
18
+ import os
19
+ import random
20
+ import socket
21
+ import threading
22
+ import time
23
+
24
+ from typing import Any, Dict, List, Optional
25
+ from urllib.parse import urlencode, urlparse, parse_qs
26
+
27
+ import requests
28
+ from PySide6.QtCore import Slot
29
+
30
+ from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
31
+
32
+
33
+ class WorkerSignals(BaseSignals):
34
+ pass
35
+
36
+
37
+ class Worker(BaseWorker):
38
+ """
39
+ Slack plugin worker: OAuth v2 (auto), Auth test, Users, Conversations, Chat, Files (External upload).
40
+ """
41
+
42
+ def __init__(self, *args, **kwargs):
43
+ super(Worker, self).__init__()
44
+ self.signals = WorkerSignals()
45
+ self.args = args
46
+ self.kwargs = kwargs
47
+ self.plugin = None
48
+ self.cmds = None
49
+ self.ctx = None
50
+ self.msg = None
51
+
52
+ # ---------------------- Core runner ----------------------
53
+
54
+ @Slot()
55
+ def run(self):
56
+ try:
57
+ responses = []
58
+ for item in self.cmds:
59
+ if self.is_stopped():
60
+ break
61
+ try:
62
+ response = None
63
+ if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
64
+
65
+ # ---- Auth ----
66
+ if item["cmd"] == "slack_oauth_begin":
67
+ response = self.cmd_slack_oauth_begin(item)
68
+ elif item["cmd"] == "slack_oauth_exchange":
69
+ response = self.cmd_slack_oauth_exchange(item)
70
+ elif item["cmd"] == "slack_oauth_refresh":
71
+ response = self.cmd_slack_oauth_refresh(item)
72
+ elif item["cmd"] == "slack_auth_test":
73
+ response = self.cmd_slack_auth_test(item)
74
+
75
+ # ---- Users / Contacts ----
76
+ elif item["cmd"] == "slack_users_list":
77
+ response = self.cmd_slack_users_list(item)
78
+
79
+ # ---- Conversations (channels/DMs) ----
80
+ elif item["cmd"] == "slack_conversations_list":
81
+ response = self.cmd_slack_conversations_list(item)
82
+ elif item["cmd"] == "slack_conversations_history":
83
+ response = self.cmd_slack_conversations_history(item)
84
+ elif item["cmd"] == "slack_conversations_replies":
85
+ response = self.cmd_slack_conversations_replies(item)
86
+ elif item["cmd"] == "slack_conversations_open":
87
+ response = self.cmd_slack_conversations_open(item)
88
+
89
+ # ---- Chat ----
90
+ elif item["cmd"] == "slack_chat_post_message":
91
+ response = self.cmd_slack_chat_post_message(item)
92
+ elif item["cmd"] == "slack_chat_delete":
93
+ response = self.cmd_slack_chat_delete(item)
94
+
95
+ # ---- Files ----
96
+ elif item["cmd"] == "slack_files_upload":
97
+ response = self.cmd_slack_files_upload(item)
98
+
99
+ if response:
100
+ responses.append(response)
101
+
102
+ except Exception as e:
103
+ responses.append(self.make_response(item, self.throw_error(e)))
104
+
105
+ if responses:
106
+ self.reply_more(responses)
107
+ if self.msg is not None:
108
+ self.status(self.msg)
109
+ except Exception as e:
110
+ self.error(e)
111
+ finally:
112
+ self.cleanup()
113
+
114
+ # ---------------------- HTTP / Helpers ----------------------
115
+
116
+ def _api_base(self) -> str:
117
+ return (self.plugin.get_option_value("api_base") or "https://slack.com/api").rstrip("/")
118
+
119
+ def _oauth_base(self) -> str:
120
+ return (self.plugin.get_option_value("oauth_base") or "https://slack.com").rstrip("/")
121
+
122
+ def _timeout(self) -> int:
123
+ try:
124
+ return int(self.plugin.get_option_value("http_timeout") or 30)
125
+ except Exception:
126
+ return 30
127
+
128
+ def _now(self) -> int:
129
+ return int(time.time())
130
+
131
+ def _auth_header(self) -> Dict[str, str]:
132
+ # Prefer bot token; fallback to user token; autostart OAuth if enabled.
133
+ token = (self.plugin.get_option_value("bot_token") or "").strip()
134
+ if not token:
135
+ token = (self.plugin.get_option_value("user_token") or "").strip()
136
+ if not token and bool(self.plugin.get_option_value("oauth_auto_begin") or True):
137
+ self._auto_authorize_interactive()
138
+ token = (self.plugin.get_option_value("bot_token") or "").strip() or \
139
+ (self.plugin.get_option_value("user_token") or "").strip()
140
+ if not token:
141
+ raise RuntimeError("Missing Slack token. Provide bot_token/user_token or complete OAuth.")
142
+ return {
143
+ "Authorization": f"Bearer {token}",
144
+ "Accept": "application/json",
145
+ "User-Agent": "pygpt-net-slack-plugin/1.0",
146
+ }
147
+
148
+ def _handle_response(self, r: requests.Response) -> dict:
149
+ try:
150
+ data = r.json() if r.content else {}
151
+ except Exception:
152
+ data = {"raw": r.text}
153
+ if r.status_code == 429:
154
+ ra = r.headers.get("Retry-After")
155
+ raise RuntimeError(json.dumps({"status": 429, "error": "ratelimited", "retry_after": ra}, ensure_ascii=False))
156
+ if not (200 <= r.status_code < 300):
157
+ raise RuntimeError(f"HTTP {r.status_code}: {data or r.text}")
158
+ if isinstance(data, dict) and not data.get("ok", True):
159
+ # Slack-style error envelope
160
+ raise RuntimeError(json.dumps({"status": r.status_code, "error": data.get("error"), "data": data}, ensure_ascii=False))
161
+ data["_meta"] = {
162
+ "status": r.status_code,
163
+ "ratelimit-retry-after": r.headers.get("Retry-After"),
164
+ }
165
+ return data
166
+
167
+ def _get(self, method: str, params: dict = None):
168
+ url = f"{self._api_base()}/{method.lstrip('/')}"
169
+ headers = self._auth_header()
170
+ r = requests.get(url, headers=headers, params=params or {}, timeout=self._timeout())
171
+ return self._handle_response(r)
172
+
173
+ def _post_form(self, method: str, form: dict = None):
174
+ url = f"{self._api_base()}/{method.lstrip('/')}"
175
+ headers = self._auth_header()
176
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
177
+ r = requests.post(url, headers=headers, data=form or {}, timeout=self._timeout())
178
+ return self._handle_response(r)
179
+
180
+ def _post_json(self, method: str, payload: dict):
181
+ url = f"{self._api_base()}/{method.lstrip('/')}"
182
+ headers = self._auth_header()
183
+ headers["Content-Type"] = "application/json"
184
+ r = requests.post(url, headers=headers, json=payload or {}, timeout=self._timeout())
185
+ return self._handle_response(r)
186
+
187
+ # ---------------------- OAuth2 (Slack, no-PKCE) ----------------------
188
+
189
+ def _gen_state(self, n: int = 32) -> str:
190
+ alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
191
+ return "".join(random.choice(alphabet) for _ in range(n))
192
+
193
+ def _redirect_is_local(self, redirect_uri: str) -> bool:
194
+ try:
195
+ u = urlparse(redirect_uri)
196
+ return u.scheme in ("http",) and (u.hostname in ("127.0.0.1", "localhost"))
197
+ except Exception:
198
+ return False
199
+
200
+ def _can_bind(self, host: str, port: int) -> bool:
201
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
202
+ try:
203
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
204
+ s.bind((host, port))
205
+ return True
206
+ except Exception:
207
+ return False
208
+ finally:
209
+ try:
210
+ s.close()
211
+ except Exception:
212
+ pass
213
+
214
+ def _pick_port(self, host: str, preferred: int) -> int:
215
+ base = preferred if preferred and preferred >= 1024 else 8733
216
+ for p in range(base, base + 40):
217
+ if self._can_bind(host, p):
218
+ return p
219
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
220
+ s.bind((host, 0))
221
+ free_port = s.getsockname()[1]
222
+ s.close()
223
+ return free_port
224
+
225
+ def _prepare_effective_redirect(self, redirect_uri: str) -> str:
226
+ u = urlparse(redirect_uri)
227
+ host = u.hostname or "127.0.0.1"
228
+ scheme = u.scheme or "http"
229
+ path = u.path or "/"
230
+ if not self._redirect_is_local(redirect_uri):
231
+ return redirect_uri
232
+ port = u.port
233
+ allow_fallback = bool(self.plugin.get_option_value("oauth_allow_port_fallback") or True)
234
+ if not port or port < 1024 or not self._can_bind(host, port):
235
+ if not allow_fallback and (not port or port < 1024):
236
+ raise RuntimeError("Configured redirect_uri uses a privileged/unavailable port. Use port >1024.")
237
+ pref = int(self.plugin.get_option_value("oauth_local_port") or 8733)
238
+ new_port = self._pick_port(host, pref)
239
+ return f"{scheme}://{host}:{new_port}{path}"
240
+ return redirect_uri
241
+
242
+ def _build_auth_url(self, state: str, redirect_uri: str) -> str:
243
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
244
+ scope = (self.plugin.get_option_value("bot_scopes") or "").replace(" ", "")
245
+ user_scope = (self.plugin.get_option_value("user_scopes") or "").replace(" ", "")
246
+ q = {
247
+ "client_id": client_id,
248
+ "scope": scope,
249
+ "redirect_uri": redirect_uri,
250
+ "state": state,
251
+ }
252
+ if user_scope:
253
+ q["user_scope"] = user_scope
254
+ return f"{self._oauth_base()}/oauth/v2/authorize?{urlencode(q)}"
255
+
256
+ def _oauth_exchange_code(self, code: str, redirect_uri: str) -> dict:
257
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
258
+ client_secret = self.plugin.get_option_value("oauth2_client_secret") or ""
259
+ token_url = f"{self._api_base()}/oauth.v2.access"
260
+ data = {
261
+ "grant_type": "authorization_code",
262
+ "code": code,
263
+ "redirect_uri": redirect_uri,
264
+ }
265
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
266
+ # Slack recommends HTTP Basic for client auth
267
+ if client_id and client_secret:
268
+ basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
269
+ headers["Authorization"] = f"Basic {basic}"
270
+ else:
271
+ data["client_id"] = client_id
272
+ data["client_secret"] = client_secret
273
+ r = requests.post(token_url, headers=headers, data=data, timeout=self._timeout())
274
+ res = self._handle_response(r)
275
+ self._save_oauth_tokens(res)
276
+ return res
277
+
278
+ def _oauth_refresh_token(self):
279
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
280
+ client_secret = self.plugin.get_option_value("oauth2_client_secret") or ""
281
+ refresh = self.plugin.get_option_value("oauth2_refresh_token") or ""
282
+ if not (client_id and client_secret and refresh):
283
+ raise RuntimeError("Cannot refresh: missing client_id/client_secret/refresh_token")
284
+ token_url = f"{self._api_base()}/oauth.v2.access"
285
+ data = {
286
+ "grant_type": "refresh_token",
287
+ "refresh_token": refresh,
288
+ }
289
+ headers = {"Content-Type": "application/x-www-form-urlencoded"}
290
+ basic = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
291
+ headers["Authorization"] = f"Basic {basic}"
292
+ r = requests.post(token_url, headers=headers, data=data, timeout=self._timeout())
293
+ res = self._handle_response(r)
294
+ self._save_oauth_tokens(res)
295
+ return res
296
+
297
+ def _save_oauth_tokens(self, res: dict):
298
+ # Slack returns bot access_token (xoxb-...) and authed_user with optional access_token
299
+ bot_token = res.get("access_token") or ""
300
+ authed_user = (res.get("authed_user") or {})
301
+ user_token = authed_user.get("access_token") or ""
302
+ refresh_bot = res.get("refresh_token") or ""
303
+ refresh_user = authed_user.get("refresh_token") or ""
304
+ expires_in = int(res.get("expires_in") or 0)
305
+ expires_at = self._now() + expires_in - 60 if expires_in else 0
306
+
307
+ if bot_token:
308
+ self.plugin.set_option_value("bot_token", bot_token)
309
+ if user_token:
310
+ self.plugin.set_option_value("user_token", user_token)
311
+ if refresh_bot:
312
+ self.plugin.set_option_value("oauth2_refresh_token", refresh_bot)
313
+ elif refresh_user:
314
+ self.plugin.set_option_value("oauth2_refresh_token", refresh_user)
315
+ if expires_at:
316
+ self.plugin.set_option_value("oauth2_expires_at", str(expires_at))
317
+
318
+ if res.get("team"):
319
+ team = res.get("team") or {}
320
+ if isinstance(team, dict) and team.get("id"):
321
+ self.plugin.set_option_value("team_id", team.get("id"))
322
+ if res.get("bot_user_id"):
323
+ self.plugin.set_option_value("bot_user_id", res.get("bot_user_id"))
324
+ if authed_user.get("id"):
325
+ self.plugin.set_option_value("authed_user_id", authed_user.get("id"))
326
+
327
+ def _run_local_callback_and_wait(self, expected_state: str, auth_url: str, redirect_uri: str) -> (str, str):
328
+ u = urlparse(redirect_uri)
329
+ host = u.hostname or "127.0.0.1"
330
+ port = u.port or 8733
331
+ timeout_sec = int(self.plugin.get_option_value("oauth_local_timeout") or 180)
332
+ html_ok = (self.plugin.get_option_value("oauth_success_html")
333
+ or "<html><body><h3>Authorization complete. You can close this window.</h3></body></html>")
334
+ html_err = (self.plugin.get_option_value("oauth_fail_html")
335
+ or "<html><body><h3>Authorization failed.</h3></body></html>")
336
+
337
+ result = {"code": None, "state": None}
338
+ event = threading.Event()
339
+
340
+ class Handler(http.server.BaseHTTPRequestHandler):
341
+ def log_message(self, fmt, *args):
342
+ return
343
+
344
+ def do_GET(self):
345
+ try:
346
+ q = urlparse(self.path)
347
+ qs = parse_qs(q.query)
348
+ result["code"] = (qs.get("code") or [None])[0]
349
+ result["state"] = (qs.get("state") or [None])[0]
350
+ ok = result["code"] is not None
351
+ data = html_ok if ok else html_err
352
+ self.send_response(200 if ok else 400)
353
+ self.send_header("Content-Type", "text/html; charset=utf-8")
354
+ self.send_header("Content-Length", str(len(data.encode("utf-8"))))
355
+ self.end_headers()
356
+ self.wfile.write(data.encode("utf-8"))
357
+ finally:
358
+ event.set()
359
+
360
+ try:
361
+ httpd = http.server.HTTPServer((host, port), Handler)
362
+ except Exception as e:
363
+ raise RuntimeError(f"Cannot bind local callback on {host}:{port}: {e}")
364
+
365
+ thr = threading.Thread(target=httpd.serve_forever, daemon=True)
366
+ thr.start()
367
+
368
+ try:
369
+ if bool(self.plugin.get_option_value("oauth_open_browser") or True):
370
+ self.plugin.open_url(auth_url)
371
+ except Exception:
372
+ pass
373
+
374
+ got = event.wait(timeout=timeout_sec)
375
+ try:
376
+ httpd.shutdown()
377
+ except Exception:
378
+ pass
379
+ thr.join(timeout=5)
380
+
381
+ if not got or not result["code"]:
382
+ raise RuntimeError("No OAuth code received (timeout). Check callback URL in Slack App settings.")
383
+ if expected_state and result["state"] and expected_state != result["state"]:
384
+ raise RuntimeError("OAuth state mismatch")
385
+ return result["code"], result["state"]
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
+ state = self._gen_state()
394
+ self.plugin.set_option_value("oauth2_state", state)
395
+ effective_redirect = self._prepare_effective_redirect(redirect_cfg)
396
+ auth_url = self._build_auth_url(state, effective_redirect)
397
+
398
+ if bool(self.plugin.get_option_value("oauth_open_browser") or True):
399
+ try:
400
+ self.plugin.open_url(auth_url)
401
+ except Exception:
402
+ pass
403
+
404
+ if bool(self.plugin.get_option_value("oauth_local_server") or True) and self._redirect_is_local(effective_redirect):
405
+ code, _ = self._run_local_callback_and_wait(state, auth_url, effective_redirect)
406
+ self._oauth_exchange_code(code, effective_redirect)
407
+ self.msg = f"Slack: Authorization complete on {effective_redirect}."
408
+ return
409
+
410
+ self.msg = f"Authorize in browser and run slack_oauth_exchange with 'code'. URL: {auth_url}"
411
+
412
+ # ---------------------- Auth commands ----------------------
413
+
414
+ def cmd_slack_oauth_begin(self, item: dict) -> dict:
415
+ client_id = self.plugin.get_option_value("oauth2_client_id") or ""
416
+ redirect_cfg = self.plugin.get_option_value("oauth2_redirect_uri") or ""
417
+ if not (client_id and redirect_cfg):
418
+ return self.make_response(item, "Set oauth2_client_id and oauth2_redirect_uri in options first.")
419
+ state = self._gen_state()
420
+ self.plugin.set_option_value("oauth2_state", state)
421
+ effective_redirect = self._prepare_effective_redirect(redirect_cfg)
422
+ auth_url = self._build_auth_url(state, effective_redirect)
423
+ if bool(self.plugin.get_option_value("oauth_open_browser") or True):
424
+ try:
425
+ self.plugin.open_url(auth_url)
426
+ except Exception:
427
+ pass
428
+ return self.make_response(item, {"authorize_url": auth_url, "redirect_uri": effective_redirect, "state": state})
429
+
430
+ def cmd_slack_oauth_exchange(self, item: dict) -> dict:
431
+ p = item.get("params", {}) or {}
432
+ code = p.get("code")
433
+ state = p.get("state")
434
+ expected_state = (self.plugin.get_option_value("oauth2_state") or "")
435
+ if not code:
436
+ return self.make_response(item, "Param 'code' required.")
437
+ if expected_state and state and expected_state != state:
438
+ return self.make_response(item, "State mismatch.")
439
+ redirect_uri = self.plugin.get_option_value("oauth2_redirect_uri") or ""
440
+ res = self._oauth_exchange_code(code, redirect_uri)
441
+ return self.make_response(item, {
442
+ "bot_token": self.plugin.get_option_value("bot_token"),
443
+ "user_token": self.plugin.get_option_value("user_token"),
444
+ "team_id": self.plugin.get_option_value("team_id"),
445
+ "bot_user_id": self.plugin.get_option_value("bot_user_id"),
446
+ "authed_user_id": self.plugin.get_option_value("authed_user_id"),
447
+ "expires_at": self.plugin.get_option_value("oauth2_expires_at"),
448
+ "raw": res,
449
+ })
450
+
451
+ def cmd_slack_oauth_refresh(self, item: dict) -> dict:
452
+ res = self._oauth_refresh_token()
453
+ return self.make_response(item, {
454
+ "bot_token": self.plugin.get_option_value("bot_token"),
455
+ "user_token": self.plugin.get_option_value("user_token"),
456
+ "expires_at": self.plugin.get_option_value("oauth2_expires_at"),
457
+ "raw": res,
458
+ })
459
+
460
+ def cmd_slack_auth_test(self, item: dict) -> dict:
461
+ res = self._post_form("auth.test", {})
462
+ # cache ids
463
+ if res.get("team_id"):
464
+ self.plugin.set_option_value("team_id", res.get("team_id"))
465
+ if res.get("user_id"):
466
+ self.plugin.set_option_value("authed_user_id", res.get("user_id"))
467
+ return self.make_response(item, res)
468
+
469
+ # ---------------------- Users ----------------------
470
+
471
+ def cmd_slack_users_list(self, item: dict) -> dict:
472
+ p = item.get("params", {}) or {}
473
+ params = {}
474
+ if p.get("limit"):
475
+ params["limit"] = int(p.get("limit"))
476
+ if p.get("cursor"):
477
+ params["cursor"] = p.get("cursor")
478
+ if p.get("include_locale"):
479
+ params["include_locale"] = "true" if bool(p.get("include_locale")) else "false"
480
+ res = self._get("users.list", params=params)
481
+ return self.make_response(item, res)
482
+
483
+ # ---------------------- Conversations ----------------------
484
+
485
+ def cmd_slack_conversations_list(self, item: dict) -> dict:
486
+ p = item.get("params", {}) or {}
487
+ params = {}
488
+ params["types"] = p.get("types") or "public_channel,private_channel,im,mpim"
489
+ params["exclude_archived"] = "true" if p.get("exclude_archived", True) else "false"
490
+ if p.get("limit"):
491
+ params["limit"] = int(p.get("limit"))
492
+ if p.get("cursor"):
493
+ params["cursor"] = p.get("cursor")
494
+ res = self._get("conversations.list", params=params)
495
+ return self.make_response(item, res)
496
+
497
+ def cmd_slack_conversations_history(self, item: dict) -> dict:
498
+ p = item.get("params", {}) or {}
499
+ channel = p.get("channel")
500
+ if not channel:
501
+ return self.make_response(item, "Param 'channel' required (channel ID).")
502
+ params = {"channel": channel}
503
+ for k in ("limit", "cursor", "oldest", "latest"):
504
+ if p.get(k) is not None:
505
+ params[k] = p.get(k)
506
+ if p.get("inclusive") is not None:
507
+ params["inclusive"] = "true" if bool(p.get("inclusive")) else "false"
508
+ res = self._get("conversations.history", params=params)
509
+ return self.make_response(item, res)
510
+
511
+ def cmd_slack_conversations_replies(self, item: dict) -> dict:
512
+ p = item.get("params", {}) or {}
513
+ channel = p.get("channel")
514
+ ts = p.get("ts")
515
+ if not channel or not ts:
516
+ return self.make_response(item, "Params 'channel' and 'ts' (thread root) are required.")
517
+ params = {"channel": channel, "ts": ts}
518
+ if p.get("limit"):
519
+ params["limit"] = int(p.get("limit"))
520
+ if p.get("cursor"):
521
+ params["cursor"] = p.get("cursor")
522
+ res = self._get("conversations.replies", params=params)
523
+ return self.make_response(item, res)
524
+
525
+ def cmd_slack_conversations_open(self, item: dict) -> dict:
526
+ p = item.get("params", {}) or {}
527
+ form: Dict[str, Any] = {}
528
+ if p.get("users"):
529
+ # users can be str (comma separated) or list
530
+ if isinstance(p["users"], list):
531
+ form["users"] = ",".join(p["users"])
532
+ else:
533
+ form["users"] = str(p["users"])
534
+ if p.get("channel"):
535
+ form["channel"] = p.get("channel")
536
+ form["return_im"] = "true" if p.get("return_im", True) else "false"
537
+ res = self._post_form("conversations.open", form)
538
+ return self.make_response(item, res)
539
+
540
+ # ---------------------- Chat (messages) ----------------------
541
+
542
+ def cmd_slack_chat_post_message(self, item: dict) -> dict:
543
+ p = item.get("params", {}) or {}
544
+ channel = p.get("channel")
545
+ if not channel:
546
+ return self.make_response(item, "Param 'channel' required (ID).")
547
+ payload: Dict[str, Any] = {"channel": channel}
548
+ if p.get("text") is not None:
549
+ payload["text"] = p.get("text")
550
+ if p.get("thread_ts"):
551
+ payload["thread_ts"] = p.get("thread_ts")
552
+ if p.get("reply_broadcast") is not None:
553
+ payload["reply_broadcast"] = bool(p.get("reply_broadcast"))
554
+ if p.get("mrkdwn") is not None:
555
+ payload["mrkdwn"] = bool(p.get("mrkdwn"))
556
+ if p.get("unfurl_links") is not None:
557
+ payload["unfurl_links"] = bool(p.get("unfurl_links"))
558
+ if p.get("unfurl_media") is not None:
559
+ payload["unfurl_media"] = bool(p.get("unfurl_media"))
560
+ if p.get("blocks"):
561
+ payload["blocks"] = p.get("blocks")
562
+ if p.get("attachments"):
563
+ payload["attachments"] = p.get("attachments")
564
+ res = self._post_json("chat.postMessage", payload)
565
+ return self.make_response(item, res)
566
+
567
+ def cmd_slack_chat_delete(self, item: dict) -> dict:
568
+ p = item.get("params", {}) or {}
569
+ channel = p.get("channel")
570
+ ts = p.get("ts")
571
+ if not channel or not ts:
572
+ return self.make_response(item, "Params 'channel' and 'ts' required.")
573
+ res = self._post_form("chat.delete", {"channel": channel, "ts": ts})
574
+ return self.make_response(item, res)
575
+
576
+ # ---------------------- Files (Upload via External flow) ----------------------
577
+
578
+ def _guess_mime(self, path: str) -> str:
579
+ mt, _ = mimetypes.guess_type(path)
580
+ return mt or "application/octet-stream"
581
+
582
+ def cmd_slack_files_upload(self, item: dict) -> dict:
583
+ """
584
+ Upload file using files.getUploadURLExternal -> upload bytes -> files.completeUploadExternal.
585
+ """
586
+ p = item.get("params", {}) or {}
587
+ local = self.prepare_path(p.get("path") or "")
588
+ if not os.path.exists(local):
589
+ return self.make_response(item, f"Local file not found: {local}")
590
+
591
+ filename = os.path.basename(local)
592
+ size = os.path.getsize(local)
593
+ title = p.get("title") or filename
594
+ channel = p.get("channel") # single channel id preferred
595
+ initial_comment = p.get("initial_comment")
596
+ thread_ts = p.get("thread_ts")
597
+ alt_text = p.get("alt_text")
598
+
599
+ # 1) get upload URL
600
+ form = {"filename": filename, "length": str(size)}
601
+ if alt_text:
602
+ form["alt_txt"] = alt_text
603
+ step1 = self._post_form("files.getUploadURLExternal", form)
604
+ upload_url = (step1.get("upload_url") or step1.get("data", {}).get("upload_url"))
605
+ file_id = (step1.get("file_id") or step1.get("data", {}).get("file_id"))
606
+ if not (upload_url and file_id):
607
+ return self.make_response(item, "Failed to get upload URL")
608
+
609
+ # 2) upload bytes to given URL
610
+ with open(local, "rb") as fh:
611
+ data = fh.read()
612
+ # raw bytes POST; Slack accepts raw or multipart for this URL
613
+ r = requests.post(upload_url, data=data, headers={"Content-Type": self._guess_mime(local)}, timeout=self._timeout())
614
+ if not (200 <= r.status_code < 300):
615
+ return self.make_response(item, f"Upload transport failed: HTTP {r.status_code}: {r.text}")
616
+
617
+ # 3) complete upload + share
618
+ files_arr = [{"id": file_id, "title": title}]
619
+ complete_form: Dict[str, Any] = {"files": json.dumps(files_arr)}
620
+ if channel:
621
+ complete_form["channel_id"] = channel
622
+ if initial_comment:
623
+ complete_form["initial_comment"] = initial_comment
624
+ if thread_ts:
625
+ complete_form["thread_ts"] = thread_ts
626
+ step3 = self._post_form("files.completeUploadExternal", complete_form)
627
+ return self.make_response(item, {"file_id": file_id, "result": step3})
628
+
629
+ # ---------------------- FS helpers ----------------------
630
+
631
+ def prepare_path(self, path: str) -> str:
632
+ if path in [".", "./"]:
633
+ return self.plugin.window.core.config.get_user_dir("data")
634
+ if self.is_absolute_path(path):
635
+ return path
636
+ return os.path.join(self.plugin.window.core.config.get_user_dir("data"), path)
637
+
638
+ def is_absolute_path(self, path: str) -> bool:
639
+ return os.path.isabs(path)
@@ -0,0 +1,12 @@
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.06.30 02:00:00 #
10
+ # ================================================== #
11
+
12
+ from .plugin import *