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.
- pygpt_net/CHANGELOG.txt +23 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +20 -1
- pygpt_net/config.py +55 -65
- pygpt_net/controller/__init__.py +5 -2
- pygpt_net/controller/calendar/note.py +101 -126
- pygpt_net/controller/chat/chat.py +38 -35
- pygpt_net/controller/chat/render.py +154 -214
- pygpt_net/controller/chat/response.py +5 -3
- pygpt_net/controller/chat/stream.py +92 -27
- pygpt_net/controller/config/config.py +39 -42
- pygpt_net/controller/config/field/checkbox.py +16 -12
- pygpt_net/controller/config/field/checkbox_list.py +36 -31
- pygpt_net/controller/config/field/cmd.py +51 -57
- pygpt_net/controller/config/field/combo.py +33 -16
- pygpt_net/controller/config/field/dictionary.py +48 -55
- pygpt_net/controller/config/field/input.py +50 -32
- pygpt_net/controller/config/field/slider.py +40 -45
- pygpt_net/controller/config/field/textarea.py +20 -6
- pygpt_net/controller/config/placeholder.py +110 -231
- pygpt_net/controller/ctx/common.py +48 -48
- pygpt_net/controller/ctx/ctx.py +91 -132
- pygpt_net/controller/lang/mapping.py +57 -95
- pygpt_net/controller/lang/plugins.py +64 -55
- pygpt_net/controller/lang/settings.py +39 -38
- pygpt_net/controller/layout/layout.py +176 -109
- pygpt_net/controller/mode/mode.py +88 -85
- pygpt_net/controller/model/model.py +73 -73
- pygpt_net/controller/plugins/plugins.py +209 -223
- pygpt_net/controller/plugins/presets.py +54 -55
- pygpt_net/controller/plugins/settings.py +54 -69
- pygpt_net/controller/presets/editor.py +33 -88
- pygpt_net/controller/presets/experts.py +20 -1
- pygpt_net/controller/presets/presets.py +293 -298
- pygpt_net/controller/settings/profile.py +16 -4
- pygpt_net/controller/theme/theme.py +72 -81
- pygpt_net/controller/ui/mode.py +118 -186
- pygpt_net/controller/ui/tabs.py +69 -90
- pygpt_net/controller/ui/ui.py +47 -56
- pygpt_net/controller/ui/vision.py +24 -23
- pygpt_net/core/agents/runner.py +15 -7
- pygpt_net/core/bridge/bridge.py +5 -5
- pygpt_net/core/command/command.py +149 -219
- pygpt_net/core/ctx/ctx.py +94 -146
- pygpt_net/core/debug/debug.py +48 -58
- pygpt_net/core/experts/experts.py +3 -3
- pygpt_net/core/models/models.py +74 -112
- pygpt_net/core/modes/modes.py +13 -21
- pygpt_net/core/plugins/plugins.py +154 -177
- pygpt_net/core/presets/presets.py +103 -176
- pygpt_net/core/render/web/body.py +217 -215
- pygpt_net/core/render/web/renderer.py +330 -474
- pygpt_net/core/text/utils.py +28 -44
- pygpt_net/core/tokens/tokens.py +104 -203
- pygpt_net/data/config/config.json +3 -3
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/locale/locale.de.ini +2 -0
- pygpt_net/data/locale/locale.en.ini +2 -0
- pygpt_net/data/locale/locale.es.ini +2 -0
- pygpt_net/data/locale/locale.fr.ini +2 -0
- pygpt_net/data/locale/locale.it.ini +2 -0
- pygpt_net/data/locale/locale.pl.ini +3 -1
- pygpt_net/data/locale/locale.uk.ini +2 -0
- pygpt_net/data/locale/locale.zh.ini +2 -0
- pygpt_net/item/ctx.py +141 -139
- pygpt_net/plugin/agent/plugin.py +2 -1
- pygpt_net/plugin/audio_output/plugin.py +5 -2
- pygpt_net/plugin/base/plugin.py +101 -85
- pygpt_net/plugin/bitbucket/__init__.py +12 -0
- pygpt_net/plugin/bitbucket/config.py +267 -0
- pygpt_net/plugin/bitbucket/plugin.py +126 -0
- pygpt_net/plugin/bitbucket/worker.py +569 -0
- pygpt_net/plugin/cmd_code_interpreter/plugin.py +3 -2
- pygpt_net/plugin/cmd_custom/plugin.py +3 -2
- pygpt_net/plugin/cmd_files/plugin.py +3 -2
- pygpt_net/plugin/cmd_history/plugin.py +3 -2
- pygpt_net/plugin/cmd_mouse_control/plugin.py +5 -2
- pygpt_net/plugin/cmd_serial/plugin.py +3 -2
- pygpt_net/plugin/cmd_system/plugin.py +3 -6
- pygpt_net/plugin/cmd_web/plugin.py +3 -2
- pygpt_net/plugin/experts/plugin.py +2 -2
- pygpt_net/plugin/facebook/__init__.py +12 -0
- pygpt_net/plugin/facebook/config.py +359 -0
- pygpt_net/plugin/facebook/plugin.py +113 -0
- pygpt_net/plugin/facebook/worker.py +698 -0
- pygpt_net/plugin/github/__init__.py +12 -0
- pygpt_net/plugin/github/config.py +441 -0
- pygpt_net/plugin/github/plugin.py +126 -0
- pygpt_net/plugin/github/worker.py +674 -0
- pygpt_net/plugin/google/__init__.py +12 -0
- pygpt_net/plugin/google/config.py +367 -0
- pygpt_net/plugin/google/plugin.py +126 -0
- pygpt_net/plugin/google/worker.py +826 -0
- pygpt_net/plugin/idx_llama_index/plugin.py +3 -2
- pygpt_net/plugin/mailer/plugin.py +3 -5
- pygpt_net/plugin/openai_vision/plugin.py +3 -2
- pygpt_net/plugin/real_time/plugin.py +52 -60
- pygpt_net/plugin/slack/__init__.py +12 -0
- pygpt_net/plugin/slack/config.py +349 -0
- pygpt_net/plugin/slack/plugin.py +115 -0
- pygpt_net/plugin/slack/worker.py +639 -0
- pygpt_net/plugin/telegram/__init__.py +12 -0
- pygpt_net/plugin/telegram/config.py +308 -0
- pygpt_net/plugin/telegram/plugin.py +117 -0
- pygpt_net/plugin/telegram/worker.py +563 -0
- pygpt_net/plugin/twitter/__init__.py +12 -0
- pygpt_net/plugin/twitter/config.py +491 -0
- pygpt_net/plugin/twitter/plugin.py +125 -0
- pygpt_net/plugin/twitter/worker.py +837 -0
- pygpt_net/provider/agents/llama_index/legacy/openai_assistant.py +35 -3
- pygpt_net/tools/code_interpreter/tool.py +0 -1
- pygpt_net/tools/translator/tool.py +1 -1
- pygpt_net/ui/base/config_dialog.py +86 -100
- pygpt_net/ui/base/context_menu.py +48 -46
- pygpt_net/ui/dialog/preset.py +34 -77
- pygpt_net/ui/layout/ctx/ctx_list.py +10 -6
- pygpt_net/ui/layout/toolbox/presets.py +41 -41
- pygpt_net/ui/main.py +49 -31
- pygpt_net/ui/tray.py +61 -60
- pygpt_net/ui/widget/calendar/select.py +86 -70
- pygpt_net/ui/widget/lists/attachment.py +86 -44
- pygpt_net/ui/widget/lists/base_list_combo.py +85 -33
- pygpt_net/ui/widget/lists/context.py +135 -188
- pygpt_net/ui/widget/lists/preset.py +59 -61
- pygpt_net/ui/widget/textarea/web.py +161 -48
- pygpt_net/utils.py +8 -1
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/METADATA +164 -2
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/RECORD +131 -103
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.1.dist-info → pygpt_net-2.6.6.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 *
|