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,826 @@
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 datetime as dt
16
+ import io
17
+ import json
18
+ import os
19
+ import re
20
+ from email.message import EmailMessage
21
+ from email.mime.base import MIMEBase
22
+ from email.mime.multipart import MIMEMultipart
23
+ from email.mime.text import MIMEText
24
+ from email import encoders
25
+ from pathlib import Path
26
+ from typing import Any, Dict, List, Optional, Tuple
27
+
28
+ from PySide6.QtCore import Slot
29
+
30
+ from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
31
+
32
+ # Google libs
33
+ from googleapiclient.discovery import build
34
+ from googleapiclient.errors import HttpError
35
+ from googleapiclient.http import MediaIoBaseDownload, MediaFileUpload
36
+ from google.oauth2.credentials import Credentials
37
+ from google.oauth2.service_account import Credentials as ServiceAccountCredentials
38
+ from google_auth_oauthlib.flow import InstalledAppFlow
39
+ from google.auth.transport.requests import Request
40
+
41
+ # Optional libs
42
+ try:
43
+ from youtube_transcript_api import YouTubeTranscriptApi # unofficial fallback
44
+ except Exception:
45
+ YouTubeTranscriptApi = None
46
+
47
+ try:
48
+ import gkeepapi # unofficial Keep fallback
49
+ except Exception:
50
+ gkeepapi = None
51
+
52
+
53
+ class WorkerSignals(BaseSignals):
54
+ pass
55
+
56
+
57
+ class Worker(BaseWorker):
58
+ """
59
+ Google plugin worker: Gmail, Calendar, Keep, Drive, YouTube, Contacts.
60
+ """
61
+ # Request a broad-but-reasonable union of scopes once, to avoid re-prompt churn
62
+ GMAIL_SCOPES = [
63
+ "https://www.googleapis.com/auth/gmail.readonly",
64
+ "https://www.googleapis.com/auth/gmail.send",
65
+ "https://www.googleapis.com/auth/gmail.modify",
66
+ ]
67
+ CAL_SCOPES = ["https://www.googleapis.com/auth/calendar"]
68
+ DRIVE_SCOPES = ["https://www.googleapis.com/auth/drive"]
69
+ PEOPLE_SCOPES = ["https://www.googleapis.com/auth/contacts"]
70
+ YT_SCOPES = ["https://www.googleapis.com/auth/youtube.readonly"]
71
+ KEEP_SCOPES = [
72
+ "https://www.googleapis.com/auth/keep",
73
+ "https://www.googleapis.com/auth/keep.readonly",
74
+ ]
75
+
76
+ ALL_SCOPES = sorted(
77
+ set(GMAIL_SCOPES + CAL_SCOPES + DRIVE_SCOPES + PEOPLE_SCOPES + YT_SCOPES)
78
+ )
79
+ ALL_SCOPES_WITH_KEEP = sorted(
80
+ set(GMAIL_SCOPES + CAL_SCOPES + DRIVE_SCOPES + PEOPLE_SCOPES + YT_SCOPES + KEEP_SCOPES)
81
+ )
82
+
83
+ def __init__(self, *args, **kwargs):
84
+ super(Worker, self).__init__()
85
+ self.signals = WorkerSignals()
86
+ self.args = args
87
+ self.kwargs = kwargs
88
+ self.plugin = None
89
+ self.cmds = None
90
+ self.ctx = None
91
+ self.msg = None
92
+
93
+ # -------------- Core runner --------------
94
+
95
+ @Slot()
96
+ def run(self):
97
+ try:
98
+ responses = []
99
+ for item in self.cmds:
100
+ if self.is_stopped():
101
+ break
102
+ try:
103
+ response = None
104
+ if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
105
+
106
+ # Gmail
107
+ if item["cmd"] == "gmail_list_recent":
108
+ response = self.cmd_gmail_list_recent(item)
109
+ elif item["cmd"] == "gmail_list_all":
110
+ response = self.cmd_gmail_list_all(item)
111
+ elif item["cmd"] == "gmail_search":
112
+ response = self.cmd_gmail_search(item)
113
+ elif item["cmd"] == "gmail_get_by_id":
114
+ response = self.cmd_gmail_get_by_id(item)
115
+ elif item["cmd"] == "gmail_send":
116
+ response = self.cmd_gmail_send(item)
117
+
118
+ # Calendar
119
+ elif item["cmd"] == "calendar_events_recent":
120
+ response = self.cmd_calendar_events_recent(item)
121
+ elif item["cmd"] == "calendar_events_today":
122
+ response = self.cmd_calendar_events_today(item)
123
+ elif item["cmd"] == "calendar_events_tomorrow":
124
+ response = self.cmd_calendar_events_tomorrow(item)
125
+ elif item["cmd"] == "calendar_events_all":
126
+ response = self.cmd_calendar_events_all(item)
127
+ elif item["cmd"] == "calendar_events_by_date":
128
+ response = self.cmd_calendar_events_by_date(item)
129
+ elif item["cmd"] == "calendar_add_event":
130
+ response = self.cmd_calendar_add_event(item)
131
+ elif item["cmd"] == "calendar_delete_event":
132
+ response = self.cmd_calendar_delete_event(item)
133
+
134
+ # Keep
135
+ elif item["cmd"] == "keep_list_notes":
136
+ response = self.cmd_keep_list_notes(item)
137
+ elif item["cmd"] == "keep_add_note":
138
+ response = self.cmd_keep_add_note(item)
139
+
140
+ # Drive
141
+ elif item["cmd"] == "drive_list_files":
142
+ response = self.cmd_drive_list_files(item)
143
+ elif item["cmd"] == "drive_find_by_path":
144
+ response = self.cmd_drive_find_by_path(item)
145
+ elif item["cmd"] == "drive_download_file":
146
+ response = self.cmd_drive_download_file(item)
147
+ elif item["cmd"] == "drive_upload_file":
148
+ response = self.cmd_drive_upload_file(item)
149
+
150
+ # YouTube
151
+ elif item["cmd"] == "youtube_video_info":
152
+ response = self.cmd_youtube_video_info(item)
153
+ elif item["cmd"] == "youtube_transcript":
154
+ response = self.cmd_youtube_transcript(item)
155
+
156
+ # Contacts
157
+ elif item["cmd"] == "contacts_list":
158
+ response = self.cmd_contacts_list(item)
159
+ elif item["cmd"] == "contacts_add":
160
+ response = self.cmd_contacts_add(item)
161
+
162
+ if response:
163
+ responses.append(response)
164
+
165
+ except Exception as e:
166
+ responses.append(self.make_response(item, self.throw_error(e)))
167
+
168
+ if responses:
169
+ self.reply_more(responses)
170
+ if self.msg is not None:
171
+ self.status(self.msg)
172
+ except Exception as e:
173
+ self.error(e)
174
+ finally:
175
+ self.cleanup()
176
+
177
+ # -------------- Auth / service helpers --------------
178
+
179
+ def _get_scopes(self) -> List[str]:
180
+ """
181
+ Determine scopes for the command based on its type.
182
+
183
+ If unofficial Keep is allowed, return all scopes including Keep.
184
+
185
+ :return: List of scopes
186
+ """
187
+ scopes_str = self.plugin.get_option_value("oauth_scopes") or ""
188
+ if scopes_str:
189
+ scopes = [s.strip() for s in scopes_str.split(" ") if s.strip()]
190
+ if not scopes:
191
+ raise RuntimeError("No valid scopes provided in 'oauth_scopes'")
192
+ return sorted(scopes)
193
+ return []
194
+
195
+ def _get_credentials(self, scopes: List[str]) -> Credentials:
196
+ """
197
+ Resolve credentials from plugin config. Supports:
198
+ - OAuth client secrets JSON (installed/web) + stored refresh token (oauth_token)
199
+ - Service account JSON (for Workspace scenarios like Keep DWD)
200
+
201
+ :param scopes: List of scopes to request
202
+ :return: Credentials object
203
+ """
204
+ creds_json_text = self.plugin.get_option_value("credentials") or ""
205
+ if not creds_json_text:
206
+ raise RuntimeError("Missing credentials JSON in plugin option 'credentials'")
207
+ creds_info = json.loads(creds_json_text)
208
+
209
+ # Token store in plugin option to avoid filesystem coupling
210
+ token_text = self.plugin.get_option_value("oauth_token") or None
211
+ creds: Optional[Credentials] = None
212
+
213
+ # If service account
214
+ if isinstance(creds_info, dict) and creds_info.get("type") == "service_account":
215
+ sa_scopes = list(set(scopes))
216
+ creds = ServiceAccountCredentials.from_service_account_info(creds_info, scopes=sa_scopes)
217
+
218
+ # Optional: impersonation for Workspace (domain-wide delegation)
219
+ subject = self.plugin.get_option_value("impersonate_user") or None
220
+ if subject:
221
+ creds = creds.with_subject(subject)
222
+ return creds
223
+
224
+ # OAuth installed/web
225
+ # Try to reuse saved token first
226
+ if token_text:
227
+ try:
228
+ creds = Credentials.from_authorized_user_info(json.loads(token_text), scopes=self._get_scopes())
229
+ except Exception:
230
+ creds = None
231
+
232
+ # Refresh if needed
233
+ if creds and creds.expired and creds.refresh_token:
234
+ creds.refresh(Request())
235
+
236
+ # If no creds or missing refresh, run flow
237
+ if not creds or not creds.valid:
238
+ # Always request the union of scopes to minimize re-prompt later
239
+ flow = InstalledAppFlow.from_client_config(creds_info, scopes=self._get_scopes())
240
+ use_local_server = bool(self.plugin.get_option_value("oauth_local_server") or True)
241
+ if use_local_server:
242
+ creds = flow.run_local_server(port=int(self.plugin.get_option_value("oauth_local_port") or 0))
243
+ else:
244
+ creds = flow.run_console()
245
+
246
+ # Persist token back into plugin options
247
+ self.plugin.set_option_value("oauth_token", creds.to_json())
248
+
249
+ return creds
250
+
251
+ def _service(self, api: str, version: str, scopes: List[str] | None = None, api_key: Optional[str] = None):
252
+ """
253
+ Build google api service. If api_key is provided, use key-only (no OAuth).
254
+
255
+ :param api: API name (e.g. 'gmail', 'calendar')
256
+ :param version: API version (e.g. 'v1', 'v3')
257
+ :param scopes: List of scopes to request (if OAuth)
258
+ :param api_key: Optional API key for key-only access (no OAuth)
259
+ :return: Google API service object
260
+ """
261
+ if api_key:
262
+ return build(api, version, developerKey=api_key, cache_discovery=False)
263
+
264
+ creds = self._get_credentials(scopes or self._get_scopes())
265
+ return build(api, version, credentials=creds, cache_discovery=False)
266
+
267
+ # -------------- Gmail --------------
268
+
269
+ def _gmail_message_summary(self, svc, msg_id: str) -> Dict[str, Any]:
270
+ msg = svc.users().messages().get(userId="me", id=msg_id, format="metadata",
271
+ metadataHeaders=["From", "To", "Subject", "Date"]).execute()
272
+ headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
273
+ return {
274
+ "id": msg["id"],
275
+ "threadId": msg.get("threadId"),
276
+ "snippet": msg.get("snippet"),
277
+ "internalDate": msg.get("internalDate"),
278
+ "headers": headers,
279
+ }
280
+
281
+ def cmd_gmail_list_recent(self, item: dict) -> dict:
282
+ params = item.get("params", {})
283
+ n = int(params.get("n", 10))
284
+ label_ids = params.get("labelIds") or ["INBOX"]
285
+ q = params.get("q") # optional search string
286
+
287
+ svc = self._service("gmail", "v1", scopes=self.GMAIL_SCOPES)
288
+ res = svc.users().messages().list(userId="me", maxResults=n, labelIds=label_ids, q=q).execute()
289
+ ids = [m["id"] for m in res.get("messages", [])]
290
+ out = [self._gmail_message_summary(svc, mid) for mid in ids]
291
+ return self.make_response(item, out)
292
+
293
+ def cmd_gmail_list_all(self, item: dict) -> dict:
294
+ params = item.get("params", {})
295
+ q = params.get("q")
296
+ label_ids = params.get("labelIds") or None
297
+ limit = params.get("limit") # to protect from huge mailboxes
298
+ limit = int(limit) if limit else None
299
+
300
+ svc = self._service("gmail", "v1", scopes=self.GMAIL_SCOPES)
301
+ all_ids: List[str] = []
302
+ page_token = None
303
+ while True:
304
+ res = svc.users().messages().list(userId="me", pageToken=page_token, q=q, labelIds=label_ids,
305
+ maxResults=500).execute()
306
+ batch = [m["id"] for m in res.get("messages", [])]
307
+ all_ids.extend(batch)
308
+ if limit and len(all_ids) >= limit:
309
+ all_ids = all_ids[:limit]
310
+ break
311
+ page_token = res.get("nextPageToken")
312
+ if not page_token:
313
+ break
314
+ svc_batch = [self._gmail_message_summary(svc, mid) for mid in all_ids]
315
+ return self.make_response(item, svc_batch)
316
+
317
+ def cmd_gmail_search(self, item: dict) -> dict:
318
+ params = item.get("params", {})
319
+ q = params.get("q")
320
+ if not q:
321
+ return self.make_response(item, "Query 'q' required")
322
+ svc = self._service("gmail", "v1", scopes=self.GMAIL_SCOPES)
323
+ res = svc.users().messages().list(userId="me", q=q, maxResults=int(params.get("max", 50))).execute()
324
+ ids = [m["id"] for m in res.get("messages", [])]
325
+ out = [self._gmail_message_summary(svc, mid) for mid in ids]
326
+ return self.make_response(item, out)
327
+
328
+ def _gmail_decode_parts(self, part, collected):
329
+ mime_type = part.get("mimeType", "")
330
+ body = part.get("body", {})
331
+ data = body.get("data")
332
+ if data:
333
+ try:
334
+ text = base64.urlsafe_b64decode(data.encode("utf-8")).decode("utf-8", errors="replace")
335
+ except Exception:
336
+ text = ""
337
+ if mime_type == "text/plain":
338
+ collected["text"] = (collected.get("text", "") + text).strip()
339
+ elif mime_type == "text/html":
340
+ collected["html"] = (collected.get("html", "") + text).strip()
341
+
342
+ for p in part.get("parts", []) or []:
343
+ self._gmail_decode_parts(p, collected)
344
+
345
+ def cmd_gmail_get_by_id(self, item: dict) -> dict:
346
+ params = item.get("params", {})
347
+ mid = params.get("id")
348
+ if not mid:
349
+ return self.make_response(item, "Message 'id' required")
350
+ svc = self._service("gmail", "v1", scopes=self.GMAIL_SCOPES)
351
+ msg = svc.users().messages().get(userId="me", id=mid, format="full").execute()
352
+ headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
353
+ content = {"text": "", "html": ""}
354
+ self._gmail_decode_parts(msg.get("payload", {}), content)
355
+ out = {
356
+ "id": msg.get("id"),
357
+ "threadId": msg.get("threadId"),
358
+ "labelIds": msg.get("labelIds"),
359
+ "snippet": msg.get("snippet"),
360
+ "headers": headers,
361
+ "content": content,
362
+ }
363
+ return self.make_response(item, out)
364
+
365
+ def cmd_gmail_send(self, item: dict) -> dict:
366
+ p = item.get("params", {})
367
+ to = p.get("to")
368
+ subject = p.get("subject", "")
369
+ body = p.get("body", "")
370
+ html = bool(p.get("html", False))
371
+ cc = p.get("cc")
372
+ bcc = p.get("bcc")
373
+ attachments = p.get("attachments") or [] # list of local paths
374
+
375
+ if not to:
376
+ return self.make_response(item, "Recipient 'to' required")
377
+
378
+ svc = self._service("gmail", "v1", scopes=self.GMAIL_SCOPES)
379
+
380
+ if attachments:
381
+ msg = MIMEMultipart()
382
+ if html:
383
+ msg.attach(MIMEText(body, "html", "utf-8"))
384
+ else:
385
+ msg.attach(MIMEText(body, "plain", "utf-8"))
386
+ for apath in attachments:
387
+ apath = self.prepare_path(apath)
388
+ if not os.path.exists(apath):
389
+ continue
390
+ with open(apath, "rb") as f:
391
+ part = MIMEBase("application", "octet-stream")
392
+ part.set_payload(f.read())
393
+ encoders.encode_base64(part)
394
+ part.add_header("Content-Disposition", f'attachment; filename="{os.path.basename(apath)}"')
395
+ msg.attach(part)
396
+ else:
397
+ msg = EmailMessage()
398
+ subtype = "html" if html else "plain"
399
+ msg.set_content(body, subtype=subtype, charset="utf-8")
400
+
401
+ msg["To"] = to
402
+ if cc:
403
+ msg["Cc"] = cc
404
+ if bcc:
405
+ msg["Bcc"] = bcc
406
+ msg["Subject"] = subject
407
+
408
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8")
409
+ sent = svc.users().messages().send(userId="me", body={"raw": raw}).execute()
410
+ return self.make_response(item, {"id": sent.get("id"), "labelIds": sent.get("labelIds")})
411
+
412
+ # -------------- Calendar --------------
413
+
414
+ def _utc_iso(self, dt_obj: dt.datetime) -> str:
415
+ if dt_obj.tzinfo is None:
416
+ dt_obj = dt_obj.replace(tzinfo=dt.timezone.utc)
417
+ return dt_obj.astimezone(dt.timezone.utc).isoformat()
418
+
419
+ def _day_bounds_utc(self, days_from_today: int = 0) -> Tuple[str, str]:
420
+ now = dt.datetime.utcnow()
421
+ day = now.date() + dt.timedelta(days=days_from_today)
422
+ start = dt.datetime(day.year, day.month, day.day, 0, 0, 0, tzinfo=dt.timezone.utc)
423
+ end = start + dt.timedelta(days=1)
424
+ return start.isoformat(), end.isoformat()
425
+
426
+ def _calendar_list(self, svc, time_min: Optional[str], time_max: Optional[str], limit: int) -> List[Dict[str, Any]]:
427
+ res = svc.events().list(
428
+ calendarId="primary",
429
+ timeMin=time_min,
430
+ timeMax=time_max,
431
+ singleEvents=True,
432
+ orderBy="startTime",
433
+ maxResults=limit,
434
+ ).execute()
435
+ return res.get("items", [])
436
+
437
+ def cmd_calendar_events_recent(self, item: dict) -> dict:
438
+ p = item.get("params", {})
439
+ limit = int(p.get("limit", 10))
440
+ now_iso = dt.datetime.utcnow().isoformat() + "Z"
441
+ svc = self._service("calendar", "v3", scopes=self.CAL_SCOPES)
442
+ items = self._calendar_list(svc, now_iso, None, limit)
443
+ return self.make_response(item, items)
444
+
445
+ def cmd_calendar_events_today(self, item: dict) -> dict:
446
+ start, end = self._day_bounds_utc(0)
447
+ svc = self._service("calendar", "v3", scopes=self.CAL_SCOPES)
448
+ items = self._calendar_list(svc, start, end, 250)
449
+ return self.make_response(item, items)
450
+
451
+ def cmd_calendar_events_tomorrow(self, item: dict) -> dict:
452
+ start, end = self._day_bounds_utc(1)
453
+ svc = self._service("calendar", "v3", scopes=self.CAL_SCOPES)
454
+ items = self._calendar_list(svc, start, end, 250)
455
+ return self.make_response(item, items)
456
+
457
+ def cmd_calendar_events_all(self, item: dict) -> dict:
458
+ p = item.get("params", {})
459
+ # Sensible default: 1y back and 1y forward
460
+ now = dt.datetime.utcnow()
461
+ time_min = p.get("timeMin") or (now - dt.timedelta(days=365)).isoformat() + "Z"
462
+ time_max = p.get("timeMax") or (now + dt.timedelta(days=365)).isoformat() + "Z"
463
+ svc = self._service("calendar", "v3", scopes=self.CAL_SCOPES)
464
+ items = self._calendar_list(svc, time_min, time_max, 2500)
465
+ return self.make_response(item, items)
466
+
467
+ def cmd_calendar_events_by_date(self, item: dict) -> dict:
468
+ p = item.get("params", {})
469
+ date_str = p.get("date") # YYYY-MM-DD or ISO range start|end
470
+ if not date_str:
471
+ return self.make_response(item, "Param 'date' (YYYY-MM-DD) or 'start'/'end' required")
472
+ if "|" in date_str:
473
+ start, end = date_str.split("|", 1)
474
+ else:
475
+ y, m, d = map(int, date_str.split("-"))
476
+ start_dt = dt.datetime(y, m, d, 0, 0, tzinfo=dt.timezone.utc)
477
+ end_dt = start_dt + dt.timedelta(days=1)
478
+ start, end = start_dt.isoformat(), end_dt.isoformat()
479
+
480
+ svc = self._service("calendar", "v3", scopes=self.CAL_SCOPES)
481
+ items = self._calendar_list(svc, start, end, 2500)
482
+ return self.make_response(item, items)
483
+
484
+ def cmd_calendar_add_event(self, item: dict) -> dict:
485
+ p = item.get("params", {})
486
+ summary = p.get("summary")
487
+ start = p.get("start") # RFC3339 or YYYY-MM-DDTHH:MM
488
+ end = p.get("end")
489
+ tz = p.get("timezone") or "UTC"
490
+ description = p.get("description")
491
+ location = p.get("location")
492
+ attendees = p.get("attendees") or [] # list of emails
493
+
494
+ if not (summary and start and end):
495
+ return self.make_response(item, "Params 'summary', 'start', 'end' are required")
496
+
497
+ def normalize(x: str) -> str:
498
+ if "T" in x:
499
+ if x.endswith("Z") or "+" in x:
500
+ return x
501
+ return f"{x}:00"
502
+ return f"{x}T00:00:00"
503
+
504
+ body = {
505
+ "summary": summary,
506
+ "description": description,
507
+ "location": location,
508
+ "start": {"dateTime": normalize(start), "timeZone": tz},
509
+ "end": {"dateTime": normalize(end), "timeZone": tz},
510
+ }
511
+ if attendees:
512
+ body["attendees"] = [{"email": a} for a in attendees]
513
+
514
+ svc = self._service("calendar", "v3", scopes=self.CAL_SCOPES)
515
+ created = svc.events().insert(calendarId="primary", body=body).execute()
516
+ return self.make_response(item, created)
517
+
518
+ def cmd_calendar_delete_event(self, item: dict) -> dict:
519
+ p = item.get("params", {})
520
+ eid = p.get("event_id")
521
+ if not eid:
522
+ return self.make_response(item, "Param 'event_id' required")
523
+ svc = self._service("calendar", "v3", scopes=self.CAL_SCOPES)
524
+ svc.events().delete(calendarId="primary", eventId=eid).execute()
525
+ return self.make_response(item, "OK")
526
+
527
+ # -------------- Keep --------------
528
+
529
+ def _keep_service(self):
530
+ # Official Keep API (Workspace-focused)
531
+ return self._service("keep", "v1", scopes=self.KEEP_SCOPES)
532
+
533
+ def cmd_keep_list_notes(self, item: dict) -> dict:
534
+ mode = (self.plugin.get_option_value("keep_mode") or "auto").lower()
535
+ if mode in ("official", "auto"):
536
+ try:
537
+ svc = self._keep_service()
538
+ # Official list
539
+ res = svc.notes().list(pageSize=int(item.get("params", {}).get("limit", 50))).execute()
540
+ return self.make_response(item, res.get("notes", []))
541
+ except HttpError as e:
542
+ if mode == "official":
543
+ return self.make_response(item, self.throw_error(e))
544
+ # fallthrough to unofficial if allowed
545
+
546
+ if (self.plugin.get_option_value("allow_unofficial_keep") or False) and gkeepapi:
547
+ # Unofficial fallback (not endorsed by Google)
548
+ email = self.plugin.get_option_value("keep_username")
549
+ token = self.plugin.get_option_value("keep_master_token")
550
+ if not (email and token):
551
+ return self.make_response(item, "Missing keep_username/keep_master_token for unofficial mode")
552
+ keep = gkeepapi.Keep()
553
+ keep.authenticate(email, token)
554
+ keep.sync()
555
+ notes = [{"id": n.id, "title": n.title, "text": getattr(n, "text", "")} for n in keep.all()]
556
+ return self.make_response(item, notes)
557
+ return self.make_response(item, "Keep unavailable (official failed and unofficial disabled)")
558
+
559
+ def cmd_keep_add_note(self, item: dict) -> dict:
560
+ p = item.get("params", {})
561
+ title = p.get("title", "")
562
+ text = p.get("text", "")
563
+ mode = (self.plugin.get_option_value("keep_mode") or "auto").lower()
564
+
565
+ if mode in ("official", "auto"):
566
+ try:
567
+ svc = self._keep_service()
568
+ body = {"title": title, "body": {"text": {"text": text}}}
569
+ created = svc.notes().create(body=body).execute()
570
+ return self.make_response(item, created)
571
+ except HttpError as e:
572
+ if mode == "official":
573
+ return self.make_response(item, self.throw_error(e))
574
+
575
+ if (self.plugin.get_option_value("allow_unofficial_keep") or False) and gkeepapi:
576
+ email = self.plugin.get_option_value("keep_username")
577
+ token = self.plugin.get_option_value("keep_master_token")
578
+ if not (email and token):
579
+ return self.make_response(item, "Missing keep_username/keep_master_token for unofficial mode")
580
+ keep = gkeepapi.Keep()
581
+ keep.authenticate(email, token)
582
+ note = keep.createNote(title, text)
583
+ keep.sync()
584
+ return self.make_response(item, {"id": note.id, "title": title, "text": text})
585
+
586
+ return self.make_response(item, "Keep unavailable (official failed and unofficial disabled)")
587
+
588
+ # -------------- Drive --------------
589
+
590
+ def cmd_drive_list_files(self, item: dict) -> dict:
591
+ p = item.get("params", {})
592
+ q = p.get("q", "trashed=false")
593
+ fields = p.get("fields", "nextPageToken, files(id, name, mimeType, parents)")
594
+ page_size = int(p.get("page_size", 100))
595
+ svc = self._service("drive", "v3", scopes=self.DRIVE_SCOPES)
596
+ res = svc.files().list(q=q, pageSize=page_size, fields=fields).execute()
597
+ return self.make_response(item, res.get("files", []))
598
+
599
+ def _drive_find_by_path(self, svc, path: str) -> Optional[Dict[str, Any]]:
600
+ # Simple path resolver: /Folder/Sub/file.ext in My Drive
601
+ parts = [p for p in Path(path).parts if p not in ("/", "\\")]
602
+ if not parts:
603
+ return None
604
+ parent = "root"
605
+ node = None
606
+ for i, name in enumerate(parts):
607
+ is_last = i == len(parts) - 1
608
+ mime_filter = "" if is_last else " and mimeType = 'application/vnd.google-apps.folder'"
609
+ name_replaced = name.replace("'", "\\'") # Escape single quotes for query
610
+ q = f"name = '{name_replaced}' and '{parent}' in parents and trashed = false{mime_filter}"
611
+ res = svc.files().list(q=q, fields="files(id, name, mimeType, parents)").execute()
612
+ files = res.get("files", [])
613
+ if not files:
614
+ return None
615
+ node = files[0]
616
+ parent = node["id"]
617
+ return node
618
+
619
+ def cmd_drive_find_by_path(self, item: dict) -> dict:
620
+ p = item.get("params", {})
621
+ path = p.get("path")
622
+ if not path:
623
+ return self.make_response(item, "Param 'path' required")
624
+ svc = self._service("drive", "v3", scopes=self.DRIVE_SCOPES)
625
+ node = self._drive_find_by_path(svc, path)
626
+ return self.make_response(item, node or {})
627
+
628
+ def cmd_drive_download_file(self, item: dict) -> dict:
629
+ p = item.get("params", {})
630
+ file_id = p.get("file_id")
631
+ path = p.get("path")
632
+ out_path = self.prepare_path(p.get("out") or "")
633
+ export_mime = p.get("export_mime") # for Google Docs types
634
+
635
+ svc = self._service("drive", "v3", scopes=self.DRIVE_SCOPES)
636
+
637
+ if not file_id and path:
638
+ node = self._drive_find_by_path(svc, path)
639
+ if not node:
640
+ return self.make_response(item, "File not found")
641
+ file_id = node["id"]
642
+
643
+ if not file_id:
644
+ return self.make_response(item, "Param 'file_id' or 'path' required")
645
+
646
+ meta = svc.files().get(fileId=file_id, fields="id, name, mimeType").execute()
647
+ target = out_path or self.prepare_path(meta["name"])
648
+
649
+ fh = io.FileIO(target, "wb")
650
+ try:
651
+ if meta["mimeType"].startswith("application/vnd.google-apps.") and export_mime:
652
+ req = svc.files().export_media(fileId=file_id, mimeType=export_mime)
653
+ else:
654
+ req = svc.files().get_media(fileId=file_id)
655
+ downloader = MediaIoBaseDownload(fh, req)
656
+ done = False
657
+ while not done:
658
+ status, done = downloader.next_chunk()
659
+ return self.make_response(item, {"path": target, "id": meta["id"], "name": meta["name"]})
660
+ finally:
661
+ fh.close()
662
+
663
+ def cmd_drive_upload_file(self, item: dict) -> dict:
664
+ p = item.get("params", {})
665
+ local = self.prepare_path(p.get("local"))
666
+ remote_parent_path = p.get("remote_parent_path") # optional folder path
667
+ name = p.get("name") or os.path.basename(local)
668
+ mime = p.get("mime")
669
+
670
+ if not os.path.exists(local):
671
+ return self.make_response(item, f"Local file not found: {local}")
672
+
673
+ svc = self._service("drive", "v3", scopes=self.DRIVE_SCOPES)
674
+
675
+ parents = None
676
+ if remote_parent_path:
677
+ node = self._drive_find_by_path(svc, remote_parent_path)
678
+ if not node:
679
+ return self.make_response(item, f"Remote parent path not found: {remote_parent_path}")
680
+ parents = [node["id"]]
681
+
682
+ media = MediaFileUpload(local, mimetype=mime, resumable=True)
683
+ body = {"name": name}
684
+ if parents:
685
+ body["parents"] = parents
686
+
687
+ file = svc.files().create(body=body, media_body=media, fields="id, name, mimeType, parents").execute()
688
+ return self.make_response(item, file)
689
+
690
+ # -------------- YouTube --------------
691
+
692
+ def _extract_video_id(self, text: str) -> str:
693
+ # Simple patterns for IDs/URLs
694
+ m = re.search(r"(?:v=|/shorts/|/v/|youtu\.be/)([A-Za-z0-9_-]{11})", text)
695
+ if m:
696
+ return m.group(1)
697
+ if re.match(r"^[A-Za-z0-9_-]{11}$", text):
698
+ return text
699
+ return ""
700
+
701
+ def cmd_youtube_video_info(self, item: dict) -> dict:
702
+ p = item.get("params", {})
703
+ vid = self._extract_video_id(p.get("video", ""))
704
+ if not vid:
705
+ return self.make_response(item, "Param 'video' must be video ID or URL")
706
+
707
+ api_key = self.plugin.get_option_value("youtube_api_key") or None
708
+ svc = self._service("youtube", "v3", scopes=self.YT_SCOPES if not api_key else None, api_key=api_key)
709
+ res = svc.videos().list(id=vid, part="snippet,contentDetails,statistics,status").execute()
710
+ items = res.get("items", [])
711
+ return self.make_response(item, items[0] if items else {})
712
+
713
+ def cmd_youtube_transcript(self, item: dict) -> dict:
714
+ p = item.get("params", {})
715
+ vid = self._extract_video_id(p.get("video", ""))
716
+ lang_pref = p.get("languages") or ["en"]
717
+ official_only = bool(p.get("official_only", False))
718
+ prefer_format = p.get("format") or "srt"
719
+
720
+ # Try official API first (requires ownership/permission)
721
+ try:
722
+ svc = self._service("youtube", "v3", scopes=["https://www.googleapis.com/auth/youtube.force-ssl"])
723
+ cap_list = svc.captions().list(part="id,snippet", videoId=vid).execute().get("items", [])
724
+ if cap_list:
725
+ cap_id = cap_list[0]["id"]
726
+ # download returns binary; google client supports download_media
727
+ req = svc.captions().download_media(id=cap_id, tfmt=prefer_format)
728
+ # Execute media download into memory
729
+ buf = io.BytesIO()
730
+ downloader = MediaIoBaseDownload(buf, req)
731
+ done = False
732
+ while not done:
733
+ status, done = downloader.next_chunk()
734
+ text = buf.getvalue().decode("utf-8", errors="ignore")
735
+ return self.make_response(item, {"videoId": vid, "format": prefer_format, "text": text})
736
+ if official_only:
737
+ return self.make_response(item, "No official captions available or insufficient permissions")
738
+ except HttpError as e:
739
+ if official_only:
740
+ return self.make_response(item, self.throw_error(e))
741
+
742
+ # Fallback (unofficial) – explicit opt-in
743
+ if not (self.plugin.get_option_value("allow_unofficial_youtube_transcript") or False):
744
+ return self.make_response(item, "Unofficial transcript disabled (set allow_unofficial_youtube_transcript)")
745
+
746
+ if YouTubeTranscriptApi is None:
747
+ return self.make_response(item, "youtube-transcript-api not installed")
748
+
749
+ transcripts = YouTubeTranscriptApi.list_transcripts(vid)
750
+ # try preferred languages sequence
751
+ for l in lang_pref:
752
+ try:
753
+ t = transcripts.find_transcript([l]).fetch()
754
+ text = "\n".join([seg["text"] for seg in t])
755
+ return self.make_response(item, {"videoId": vid, "language": l, "text": text})
756
+ except Exception:
757
+ continue
758
+ # try generated
759
+ try:
760
+ t = transcripts.find_generated_transcript(lang_pref).fetch()
761
+ text = "\n".join([seg["text"] for seg in t])
762
+ return self.make_response(item, {"videoId": vid, "language": t.language_code, "text": text})
763
+ except Exception as e:
764
+ return self.make_response(item, self.throw_error(e))
765
+
766
+ # -------------- Contacts (People API) --------------
767
+
768
+ def cmd_contacts_list(self, item: dict) -> dict:
769
+ p = item.get("params", {})
770
+ page_size = int(p.get("page_size", 200))
771
+ person_fields = p.get("person_fields", "names,emailAddresses,phoneNumbers")
772
+ svc = self._service("people", "v1", scopes=self.PEOPLE_SCOPES)
773
+ res = svc.people().connections().list(
774
+ resourceName="people/me",
775
+ pageSize=page_size,
776
+ personFields=person_fields
777
+ ).execute()
778
+ return self.make_response(item, res.get("connections", []))
779
+
780
+ def cmd_contacts_add(self, item: dict) -> dict:
781
+ p = item.get("params", {})
782
+ given = p.get("givenName")
783
+ family = p.get("familyName")
784
+ emails = p.get("emails") or []
785
+ phones = p.get("phones") or []
786
+ if not given and not family:
787
+ return self.make_response(item, "Provide at least 'givenName' or 'familyName'")
788
+
789
+ body = {}
790
+ if given or family:
791
+ body["names"] = [{"givenName": given, "familyName": family}]
792
+ if emails:
793
+ body["emailAddresses"] = [{"value": e} for e in emails]
794
+ if phones:
795
+ body["phoneNumbers"] = [{"value": ph} for ph in phones]
796
+
797
+ svc = self._service("people", "v1", scopes=self.PEOPLE_SCOPES)
798
+ created = svc.people().createContact(body=body).execute()
799
+ return self.make_response(item, created)
800
+
801
+ def prepare_path(self, path: str) -> str:
802
+ """
803
+ Prepare path
804
+
805
+ :param path: path to prepare
806
+ :return: prepared path
807
+ """
808
+ if path in [".", "./"]:
809
+ return self.plugin.window.core.config.get_user_dir('data')
810
+
811
+ if self.is_absolute_path(path):
812
+ return path
813
+ else:
814
+ return os.path.join(
815
+ self.plugin.window.core.config.get_user_dir('data'),
816
+ path,
817
+ )
818
+
819
+ def is_absolute_path(self, path: str) -> bool:
820
+ """
821
+ Check if path is absolute
822
+
823
+ :param path: path to check
824
+ :return: True if absolute
825
+ """
826
+ return os.path.isabs(path)