sophhub 0.2.2 → 0.2.4
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.
- package/package.json +1 -1
- package/skills/consensus/skill.json +20 -0
- package/skills/consensus/src/SKILL.md +93 -0
- package/skills/deepwiki/skill.json +20 -0
- package/skills/deepwiki/src/SKILL.md +45 -0
- package/skills/deepwiki/src/_meta.json +6 -0
- package/skills/deepwiki/src/scripts/deepwiki.js +135 -0
- package/skills/feishu-bitable/skill.json +20 -0
- package/skills/feishu-bitable/src/CHECKLIST.md +150 -0
- package/skills/feishu-bitable/src/README.md +178 -0
- package/skills/feishu-bitable/src/SKILL.md +113 -0
- package/skills/feishu-bitable/src/_meta.json +6 -0
- package/skills/feishu-bitable/src/api.js +381 -0
- package/skills/feishu-bitable/src/bin/cli.js +284 -0
- package/skills/feishu-bitable/src/description.md +143 -0
- package/skills/feishu-bitable/src/examples/create-records.json +52 -0
- package/skills/feishu-bitable/src/examples/create-table.json +64 -0
- package/skills/feishu-bitable/src/package-lock.json +324 -0
- package/skills/feishu-bitable/src/package.json +33 -0
- package/skills/feishu-bitable/src/publish-config.json +14 -0
- package/skills/feishu-bitable/src/test-simple.js +61 -0
- package/skills/feishu-bitable/src/utils.js +261 -0
- package/skills/flight-booking/skill.json +9 -2
- package/skills/flight-booking/src/scripts/flight_booking.py +2 -1
- package/skills/google-maps/skill.json +20 -0
- package/skills/google-maps/src/SKILL.md +237 -0
- package/skills/google-maps/src/_meta.json +6 -0
- package/skills/google-maps/src/lib/map_helper.py +912 -0
- package/skills/large-task-router/skill.json +20 -0
- package/skills/large-task-router/src/SKILL.md +79 -0
- package/skills/large-task-router/src/templates/plan.md +74 -0
- package/skills/skillhub/skill.json +11 -4
- package/skills/skillhub/src/SKILL.md +11 -1
- package/skills/sophnet-dailynews/skill.json +20 -0
- package/skills/sophnet-dailynews/src/SKILL.md +179 -0
- package/skills/sophnet-dailynews/src/cache.json +151 -0
- package/skills/sophnet-dailynews/src/sources.json +230 -0
- package/skills/sophnet-schedule/skill.json +20 -0
- package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -0
- package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -0
- package/skills/sophnet-schedule/src/SKILL.md +1050 -0
- package/skills/sophnet-schedule/src/_meta.json +6 -0
- package/skills/sophnet-schedule/src/api/__init__.py +0 -0
- package/skills/sophnet-schedule/src/api/models.py +245 -0
- package/skills/sophnet-schedule/src/apps/add_event.py +237 -0
- package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -0
- package/skills/sophnet-schedule/src/apps/check_roc.py +246 -0
- package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -0
- package/skills/sophnet-schedule/src/apps/import_events.py +216 -0
- package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -0
- package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -0
- package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -0
- package/skills/sophnet-schedule/src/compat.py +66 -0
- package/skills/sophnet-schedule/src/config/__init__.py +0 -0
- package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -0
- package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -0
- package/skills/sophnet-schedule/src/config/settings.py +133 -0
- package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -0
- package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -0
- package/skills/sophnet-schedule/src/gcal/__init__.py +0 -0
- package/skills/sophnet-schedule/src/gcal/client.py +374 -0
- package/skills/sophnet-schedule/src/gcal/models.py +91 -0
- package/skills/sophnet-schedule/src/requirements.txt +6 -0
- package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -0
- package/skills/sophnet-schedule/src/server.py +669 -0
- package/skills/sophnet-schedule/src/services/__init__.py +0 -0
- package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -0
- package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -0
- package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -0
- package/skills/sophnet-schedule/src/services/event_classifier.py +100 -0
- package/skills/sophnet-schedule/src/services/event_diff.py +160 -0
- package/skills/sophnet-schedule/src/services/google_integration.py +500 -0
- package/skills/sophnet-schedule/src/services/job_store.py +100 -0
- package/skills/sophnet-schedule/src/services/local_event_store.py +266 -0
- package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -0
- package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -0
- package/skills/sophnet-schedule/src/services/table_parser.py +286 -0
- package/skills/sophnet-schedule/src/services/task_builder.py +167 -0
- package/skills/sophnet-schedule/src/services/time_window.py +72 -0
- package/skills/sophnet-stock/skill.json +20 -0
- package/skills/sophnet-stock/src/App-Plan.md +442 -0
- package/skills/sophnet-stock/src/README.md +214 -0
- package/skills/sophnet-stock/src/SKILL.md +236 -0
- package/skills/sophnet-stock/src/TODO.md +394 -0
- package/skills/sophnet-stock/src/_meta.json +6 -0
- package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -0
- package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -0
- package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -0
- package/skills/sophnet-stock/src/docs/README.md +95 -0
- package/skills/sophnet-stock/src/docs/USAGE.md +465 -0
- package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -0
- package/skills/sophnet-stock/src/scripts/dividends.py +365 -0
- package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -0
- package/skills/sophnet-stock/src/scripts/portfolio.py +548 -0
- package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -0
- package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -0
- package/skills/sophnet-stock/src/scripts/watchlist.py +336 -0
- package/skills/xiaohongshu/skill.json +20 -0
- package/skills/xiaohongshu/src/SKILL.md +91 -0
- package/skills/xiaohongshu/src/_meta.json +6 -0
- package/skills/xiaohongshu/src/assets/card.html +216 -0
- package/skills/xiaohongshu/src/assets/cover.html +82 -0
- package/skills/xiaohongshu/src/assets/example.md +84 -0
- package/skills/xiaohongshu/src/assets/styles.css +318 -0
- package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -0
- package/skills/xiaohongshu/src/scripts/sign_server.py +158 -0
- package/skills/xiaohongshu/src/scripts/stealth.min.js +7 -0
- package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -0
- package/skills/xiaohongshu/src/workflow.py +185 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google 接入状态与 OAuth 流程。
|
|
3
|
+
|
|
4
|
+
设计目标:
|
|
5
|
+
- 保持文件式单用户实现
|
|
6
|
+
- 兼容已有 token.json 直连模式
|
|
7
|
+
- 以最小改动补齐状态接口、授权向导和首次同步
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import secrets
|
|
15
|
+
from contextlib import contextmanager
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from tempfile import NamedTemporaryFile
|
|
19
|
+
from typing import Iterator
|
|
20
|
+
from urllib.parse import urlencode
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import requests
|
|
24
|
+
except ImportError: # pragma: no cover - handled lazily for local mode
|
|
25
|
+
requests = None
|
|
26
|
+
|
|
27
|
+
from config.settings import (
|
|
28
|
+
GCAL_CALENDAR_ID,
|
|
29
|
+
GCAL_MAX_RETRIES,
|
|
30
|
+
GCAL_SSL_VERIFY,
|
|
31
|
+
GCAL_TIMEOUT,
|
|
32
|
+
GCAL_TOKEN_PATH,
|
|
33
|
+
GOOGLE_OAUTH_SESSION_TTL_MINUTES,
|
|
34
|
+
GOOGLE_STATE_FILE,
|
|
35
|
+
PROXIES,
|
|
36
|
+
TIMEZONE,
|
|
37
|
+
)
|
|
38
|
+
from gcal.client import CalendarAuthError, CalendarClient
|
|
39
|
+
from services.local_event_store import list_syncable_records, update_sync_status
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import fcntl
|
|
43
|
+
except ImportError: # pragma: no cover - Windows fallback
|
|
44
|
+
fcntl = None
|
|
45
|
+
|
|
46
|
+
_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
47
|
+
_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
48
|
+
_SCOPES = ["https://www.googleapis.com/auth/calendar"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _default_sync_summary() -> dict:
|
|
52
|
+
return {
|
|
53
|
+
"status": "idle",
|
|
54
|
+
"total": 0,
|
|
55
|
+
"succeeded": 0,
|
|
56
|
+
"failed": 0,
|
|
57
|
+
"skipped": 0,
|
|
58
|
+
"started_at": "",
|
|
59
|
+
"finished_at": "",
|
|
60
|
+
"last_error": "",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _has_valid_token_file() -> bool:
|
|
65
|
+
try:
|
|
66
|
+
with open(GCAL_TOKEN_PATH, encoding="utf-8") as f:
|
|
67
|
+
data = json.load(f)
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
required = {"client_id", "client_secret", "refresh_token", "token_uri"}
|
|
71
|
+
return required.issubset(data.keys())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_token_data() -> dict:
|
|
75
|
+
with open(GCAL_TOKEN_PATH, encoding="utf-8") as f:
|
|
76
|
+
return json.load(f)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _default_state() -> dict:
|
|
80
|
+
if _has_valid_token_file():
|
|
81
|
+
return {
|
|
82
|
+
"active_mode": "google",
|
|
83
|
+
"google_state": "ready",
|
|
84
|
+
"last_error": "",
|
|
85
|
+
"last_sync_summary": _default_sync_summary(),
|
|
86
|
+
"last_sync_time": "",
|
|
87
|
+
"oauth_session": {},
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
"active_mode": "local",
|
|
91
|
+
"google_state": "not_connected",
|
|
92
|
+
"last_error": "",
|
|
93
|
+
"last_sync_summary": _default_sync_summary(),
|
|
94
|
+
"last_sync_time": "",
|
|
95
|
+
"oauth_session": {},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
tmp_name = None
|
|
102
|
+
try:
|
|
103
|
+
with NamedTemporaryFile(
|
|
104
|
+
"w",
|
|
105
|
+
dir=path.parent,
|
|
106
|
+
delete=False,
|
|
107
|
+
encoding="utf-8",
|
|
108
|
+
) as tmp:
|
|
109
|
+
json.dump(data, tmp, indent=2, ensure_ascii=False)
|
|
110
|
+
tmp.flush()
|
|
111
|
+
os.fsync(tmp.fileno())
|
|
112
|
+
tmp_name = tmp.name
|
|
113
|
+
os.replace(tmp_name, path)
|
|
114
|
+
finally:
|
|
115
|
+
if tmp_name and os.path.exists(tmp_name):
|
|
116
|
+
os.unlink(tmp_name)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _read_state(path: Path) -> dict:
|
|
120
|
+
try:
|
|
121
|
+
with open(path, encoding="utf-8") as f:
|
|
122
|
+
data = json.load(f)
|
|
123
|
+
except FileNotFoundError:
|
|
124
|
+
return _default_state()
|
|
125
|
+
if not isinstance(data, dict):
|
|
126
|
+
return _default_state()
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _normalize_state(data: dict) -> dict:
|
|
131
|
+
state = dict(_default_state())
|
|
132
|
+
state.update(data or {})
|
|
133
|
+
state.setdefault("last_sync_summary", _default_sync_summary())
|
|
134
|
+
state.setdefault("oauth_session", {})
|
|
135
|
+
sync_summary = dict(_default_sync_summary())
|
|
136
|
+
sync_summary.update(state.get("last_sync_summary") or {})
|
|
137
|
+
state["last_sync_summary"] = sync_summary
|
|
138
|
+
|
|
139
|
+
session = dict(state.get("oauth_session") or {})
|
|
140
|
+
expires_at = session.get("expires_at", "")
|
|
141
|
+
if expires_at:
|
|
142
|
+
try:
|
|
143
|
+
expired = datetime.fromisoformat(expires_at) <= datetime.now(timezone.utc)
|
|
144
|
+
except ValueError:
|
|
145
|
+
expired = True
|
|
146
|
+
if expired:
|
|
147
|
+
session = {}
|
|
148
|
+
if state.get("google_state") == "authorizing":
|
|
149
|
+
state["google_state"] = "ready" if _has_valid_token_file() else "not_connected"
|
|
150
|
+
state["oauth_session"] = session
|
|
151
|
+
|
|
152
|
+
if not GOOGLE_STATE_FILE.exists() and _has_valid_token_file():
|
|
153
|
+
state["active_mode"] = "google"
|
|
154
|
+
state["google_state"] = "ready"
|
|
155
|
+
|
|
156
|
+
if state.get("active_mode") == "google" and not _has_valid_token_file():
|
|
157
|
+
state["active_mode"] = "local"
|
|
158
|
+
if state.get("google_state") == "ready":
|
|
159
|
+
state["google_state"] = "not_connected"
|
|
160
|
+
|
|
161
|
+
if state.get("google_state") == "not_connected" and _has_valid_token_file():
|
|
162
|
+
state["google_state"] = "ready"
|
|
163
|
+
if state.get("active_mode") == "local" and not state.get("last_sync_time"):
|
|
164
|
+
state["active_mode"] = "google"
|
|
165
|
+
|
|
166
|
+
return state
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@contextmanager
|
|
170
|
+
def _locked_state(path: str | Path | None = None) -> Iterator[tuple[Path, dict]]:
|
|
171
|
+
state_path = Path(path) if path is not None else GOOGLE_STATE_FILE
|
|
172
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
lock_path = state_path.with_suffix(state_path.suffix + ".lock")
|
|
174
|
+
with open(lock_path, "a+", encoding="utf-8") as lock_file:
|
|
175
|
+
if fcntl is not None:
|
|
176
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
177
|
+
yield state_path, _normalize_state(_read_state(state_path))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load_state(path: str | Path | None = None) -> dict:
|
|
181
|
+
with _locked_state(path) as (_, data):
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def save_state(data: dict, path: str | Path | None = None) -> dict:
|
|
186
|
+
with _locked_state(path) as (state_path, _):
|
|
187
|
+
normalized = _normalize_state(data)
|
|
188
|
+
_atomic_write_json(state_path, normalized)
|
|
189
|
+
return normalized
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def has_token() -> bool:
|
|
193
|
+
return _has_valid_token_file()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_active_mode() -> str:
|
|
197
|
+
return load_state().get("active_mode", "local")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def mask_client_id(client_id: str) -> str:
|
|
201
|
+
if not client_id:
|
|
202
|
+
return ""
|
|
203
|
+
if len(client_id) <= 10:
|
|
204
|
+
return client_id[:2] + "***"
|
|
205
|
+
return f"{client_id[:4]}***{client_id[-4:]}"
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _current_step(state: dict) -> str:
|
|
209
|
+
google_state = state.get("google_state", "not_connected")
|
|
210
|
+
if google_state in {"not_connected", "error"}:
|
|
211
|
+
return "credentials"
|
|
212
|
+
if google_state == "authorizing":
|
|
213
|
+
return "authorize"
|
|
214
|
+
if google_state in {"connected_pending_sync", "syncing"}:
|
|
215
|
+
return "sync"
|
|
216
|
+
return "done"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_status_payload() -> dict:
|
|
220
|
+
state = load_state()
|
|
221
|
+
token_client_id = ""
|
|
222
|
+
if has_token():
|
|
223
|
+
try:
|
|
224
|
+
token_client_id = _load_token_data().get("client_id", "")
|
|
225
|
+
except Exception:
|
|
226
|
+
token_client_id = ""
|
|
227
|
+
pending_session = state.get("oauth_session") or {}
|
|
228
|
+
client_id = pending_session.get("client_id") or token_client_id
|
|
229
|
+
return {
|
|
230
|
+
"active_mode": state.get("active_mode", "local"),
|
|
231
|
+
"google_state": state.get("google_state", "not_connected"),
|
|
232
|
+
"has_token": has_token(),
|
|
233
|
+
"masked_client_id": mask_client_id(client_id),
|
|
234
|
+
"needs_initial_sync": state.get("active_mode") != "google" and has_token(),
|
|
235
|
+
"current_step": _current_step(state),
|
|
236
|
+
"last_error": state.get("last_error", ""),
|
|
237
|
+
"last_sync_summary": state.get("last_sync_summary", _default_sync_summary()),
|
|
238
|
+
"last_sync_time": state.get("last_sync_time", ""),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def get_guide_payload(callback_url: str) -> dict:
|
|
243
|
+
status = get_status_payload()
|
|
244
|
+
return {
|
|
245
|
+
"current_step": status["current_step"],
|
|
246
|
+
"active_mode": status["active_mode"],
|
|
247
|
+
"google_state": status["google_state"],
|
|
248
|
+
"callback_url": callback_url,
|
|
249
|
+
"required_fields": ["client_id", "client_secret"],
|
|
250
|
+
"steps": [
|
|
251
|
+
{
|
|
252
|
+
"key": "credentials",
|
|
253
|
+
"title": "填写 Google OAuth 凭据",
|
|
254
|
+
"description": "用户提供自己的 Google Client ID 和 Client Secret。",
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
"key": "authorize",
|
|
258
|
+
"title": "跳转 Google 授权",
|
|
259
|
+
"description": "后端生成授权链接,用户在浏览器完成授权。",
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"key": "sync",
|
|
263
|
+
"title": "同步本地事件",
|
|
264
|
+
"description": "将本地模式下的事件补推到 Google Calendar。",
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"key": "done",
|
|
268
|
+
"title": "切换到 Google 模式",
|
|
269
|
+
"description": "首次同步成功后,系统正式改用 Google 作为事件源。",
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_authorize_url(client_id: str, redirect_uri: str, state_token: str) -> str:
|
|
276
|
+
params = {
|
|
277
|
+
"client_id": client_id,
|
|
278
|
+
"redirect_uri": redirect_uri,
|
|
279
|
+
"response_type": "code",
|
|
280
|
+
"scope": " ".join(_SCOPES),
|
|
281
|
+
"access_type": "offline",
|
|
282
|
+
"prompt": "consent",
|
|
283
|
+
"state": state_token,
|
|
284
|
+
}
|
|
285
|
+
return f"{_AUTH_URL}?{urlencode(params)}"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def start_oauth_session(client_id: str, client_secret: str, redirect_uri: str) -> dict:
|
|
289
|
+
state_token = secrets.token_urlsafe(24)
|
|
290
|
+
expires_at = datetime.now(timezone.utc) + timedelta(
|
|
291
|
+
minutes=GOOGLE_OAUTH_SESSION_TTL_MINUTES
|
|
292
|
+
)
|
|
293
|
+
with _locked_state() as (state_path, data):
|
|
294
|
+
data["google_state"] = "authorizing"
|
|
295
|
+
data["active_mode"] = "local"
|
|
296
|
+
data["last_error"] = ""
|
|
297
|
+
data["oauth_session"] = {
|
|
298
|
+
"state": state_token,
|
|
299
|
+
"client_id": client_id,
|
|
300
|
+
"client_secret": client_secret,
|
|
301
|
+
"redirect_uri": redirect_uri,
|
|
302
|
+
"status": "pending",
|
|
303
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
304
|
+
"expires_at": expires_at.isoformat(),
|
|
305
|
+
}
|
|
306
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"ok": True,
|
|
310
|
+
"authorize_url": _build_authorize_url(client_id, redirect_uri, state_token),
|
|
311
|
+
"expires_at": expires_at.isoformat(),
|
|
312
|
+
"google_state": "authorizing",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _write_token_file(token_data: dict) -> None:
|
|
317
|
+
GCAL_TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
_atomic_write_json(GCAL_TOKEN_PATH, token_data)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def complete_oauth_callback(
|
|
322
|
+
state_token: str,
|
|
323
|
+
code: str | None,
|
|
324
|
+
error: str | None,
|
|
325
|
+
error_description: str | None,
|
|
326
|
+
) -> dict:
|
|
327
|
+
with _locked_state() as (state_path, data):
|
|
328
|
+
session = dict(data.get("oauth_session") or {})
|
|
329
|
+
if not session:
|
|
330
|
+
data["google_state"] = "error"
|
|
331
|
+
data["last_error"] = "未找到进行中的授权会话"
|
|
332
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
333
|
+
raise CalendarAuthError(data["last_error"])
|
|
334
|
+
|
|
335
|
+
if state_token != session.get("state"):
|
|
336
|
+
data["google_state"] = "error"
|
|
337
|
+
data["last_error"] = "OAuth state 不匹配"
|
|
338
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
339
|
+
raise CalendarAuthError(data["last_error"])
|
|
340
|
+
|
|
341
|
+
if error:
|
|
342
|
+
message = error_description or error
|
|
343
|
+
data["google_state"] = "error"
|
|
344
|
+
data["last_error"] = message
|
|
345
|
+
data["oauth_session"] = {}
|
|
346
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
347
|
+
raise CalendarAuthError(message)
|
|
348
|
+
|
|
349
|
+
if not code:
|
|
350
|
+
data["google_state"] = "error"
|
|
351
|
+
data["last_error"] = "授权回调缺少 code"
|
|
352
|
+
data["oauth_session"] = {}
|
|
353
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
354
|
+
raise CalendarAuthError(data["last_error"])
|
|
355
|
+
|
|
356
|
+
redirect_uri = session.get("redirect_uri", "")
|
|
357
|
+
req = _require_requests()
|
|
358
|
+
resp = req.post(
|
|
359
|
+
_TOKEN_URL,
|
|
360
|
+
data={
|
|
361
|
+
"code": code,
|
|
362
|
+
"client_id": session.get("client_id", ""),
|
|
363
|
+
"client_secret": session.get("client_secret", ""),
|
|
364
|
+
"redirect_uri": redirect_uri,
|
|
365
|
+
"grant_type": "authorization_code",
|
|
366
|
+
},
|
|
367
|
+
proxies=PROXIES,
|
|
368
|
+
verify=GCAL_SSL_VERIFY,
|
|
369
|
+
timeout=GCAL_TIMEOUT,
|
|
370
|
+
)
|
|
371
|
+
if resp.status_code != 200:
|
|
372
|
+
data["google_state"] = "error"
|
|
373
|
+
data["last_error"] = f"Token 交换失败 {resp.status_code}: {resp.text[:200]}"
|
|
374
|
+
data["oauth_session"] = {}
|
|
375
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
376
|
+
raise CalendarAuthError(data["last_error"])
|
|
377
|
+
|
|
378
|
+
token_resp = resp.json()
|
|
379
|
+
refresh_token = token_resp.get("refresh_token")
|
|
380
|
+
access_token = token_resp.get("access_token", "")
|
|
381
|
+
if not refresh_token:
|
|
382
|
+
data["google_state"] = "error"
|
|
383
|
+
data["last_error"] = "Google 未返回 refresh_token,请确认使用 consent 授权"
|
|
384
|
+
data["oauth_session"] = {}
|
|
385
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
386
|
+
raise CalendarAuthError(data["last_error"])
|
|
387
|
+
|
|
388
|
+
_write_token_file({
|
|
389
|
+
"client_id": session.get("client_id", ""),
|
|
390
|
+
"client_secret": session.get("client_secret", ""),
|
|
391
|
+
"refresh_token": refresh_token,
|
|
392
|
+
"token": access_token,
|
|
393
|
+
"token_uri": _TOKEN_URL,
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
data["active_mode"] = "local"
|
|
397
|
+
data["google_state"] = "connected_pending_sync"
|
|
398
|
+
data["last_error"] = ""
|
|
399
|
+
data["oauth_session"] = {}
|
|
400
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
401
|
+
return _normalize_state(data)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def get_last_sync_status() -> dict:
|
|
405
|
+
return load_state().get("last_sync_summary", _default_sync_summary())
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def sync_local_events_to_google() -> dict:
|
|
409
|
+
if not has_token():
|
|
410
|
+
raise CalendarAuthError(f"Token 文件不存在: {GCAL_TOKEN_PATH}")
|
|
411
|
+
|
|
412
|
+
with _locked_state() as (state_path, data):
|
|
413
|
+
sync_summary = _default_sync_summary()
|
|
414
|
+
sync_summary["status"] = "running"
|
|
415
|
+
sync_summary["started_at"] = datetime.now(timezone.utc).isoformat()
|
|
416
|
+
data["google_state"] = "syncing"
|
|
417
|
+
data["last_error"] = ""
|
|
418
|
+
data["last_sync_summary"] = sync_summary
|
|
419
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
420
|
+
|
|
421
|
+
client = CalendarClient(
|
|
422
|
+
token_path=GCAL_TOKEN_PATH,
|
|
423
|
+
proxies=PROXIES,
|
|
424
|
+
calendar_id=GCAL_CALENDAR_ID,
|
|
425
|
+
timeout=GCAL_TIMEOUT,
|
|
426
|
+
max_retries=GCAL_MAX_RETRIES,
|
|
427
|
+
verify=GCAL_SSL_VERIFY,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
summary = _default_sync_summary()
|
|
431
|
+
summary["status"] = "finished"
|
|
432
|
+
summary["started_at"] = datetime.now(timezone.utc).isoformat()
|
|
433
|
+
errors: list[str] = []
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
client.list_calendars()
|
|
437
|
+
except Exception as e:
|
|
438
|
+
with _locked_state() as (state_path, data):
|
|
439
|
+
data["active_mode"] = "local"
|
|
440
|
+
data["google_state"] = "error"
|
|
441
|
+
data["last_error"] = str(e)
|
|
442
|
+
summary["status"] = "failed"
|
|
443
|
+
summary["last_error"] = str(e)
|
|
444
|
+
summary["finished_at"] = datetime.now(timezone.utc).isoformat()
|
|
445
|
+
data["last_sync_summary"] = summary
|
|
446
|
+
data["last_sync_time"] = summary["finished_at"]
|
|
447
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
448
|
+
raise
|
|
449
|
+
|
|
450
|
+
records = list_syncable_records()
|
|
451
|
+
summary["total"] = len(records)
|
|
452
|
+
for record in records:
|
|
453
|
+
try:
|
|
454
|
+
google_event_id = client.create_event(
|
|
455
|
+
summary=record.get("summary", "无标题"),
|
|
456
|
+
start=datetime.fromisoformat(record["start"]),
|
|
457
|
+
end=datetime.fromisoformat(record["end"]),
|
|
458
|
+
location=record.get("location", ""),
|
|
459
|
+
description=record.get("description", ""),
|
|
460
|
+
tz=TIMEZONE,
|
|
461
|
+
)
|
|
462
|
+
update_sync_status(
|
|
463
|
+
record["id"],
|
|
464
|
+
sync_state="synced",
|
|
465
|
+
google_event_id=google_event_id,
|
|
466
|
+
last_sync_error="",
|
|
467
|
+
)
|
|
468
|
+
summary["succeeded"] += 1
|
|
469
|
+
except Exception as e:
|
|
470
|
+
update_sync_status(
|
|
471
|
+
record["id"],
|
|
472
|
+
sync_state="failed",
|
|
473
|
+
google_event_id="",
|
|
474
|
+
last_sync_error=str(e)[:300],
|
|
475
|
+
)
|
|
476
|
+
summary["failed"] += 1
|
|
477
|
+
errors.append(f"{record.get('summary', record['id'])}: {e}")
|
|
478
|
+
|
|
479
|
+
summary["finished_at"] = datetime.now(timezone.utc).isoformat()
|
|
480
|
+
summary["last_error"] = errors[0] if errors else ""
|
|
481
|
+
|
|
482
|
+
with _locked_state() as (state_path, data):
|
|
483
|
+
data["last_sync_summary"] = summary
|
|
484
|
+
data["last_sync_time"] = summary["finished_at"]
|
|
485
|
+
if summary["failed"] == 0:
|
|
486
|
+
data["active_mode"] = "google"
|
|
487
|
+
data["google_state"] = "ready"
|
|
488
|
+
data["last_error"] = ""
|
|
489
|
+
else:
|
|
490
|
+
data["active_mode"] = "local"
|
|
491
|
+
data["google_state"] = "connected_pending_sync"
|
|
492
|
+
data["last_error"] = summary["last_error"]
|
|
493
|
+
_atomic_write_json(state_path, _normalize_state(data))
|
|
494
|
+
return summary
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _require_requests():
|
|
498
|
+
if requests is None:
|
|
499
|
+
raise CalendarAuthError("缺少 requests 依赖,请先安装 requirements.txt")
|
|
500
|
+
return requests
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenClaw jobs.json 安全读写。
|
|
3
|
+
|
|
4
|
+
目标:
|
|
5
|
+
- 单一入口,避免多处无锁读改写
|
|
6
|
+
- 追加写入使用文件锁
|
|
7
|
+
- 落盘使用原子替换
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from tempfile import NamedTemporaryFile
|
|
17
|
+
from typing import Iterator
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import fcntl
|
|
21
|
+
except ImportError: # pragma: no cover - Windows fallback
|
|
22
|
+
fcntl = None
|
|
23
|
+
|
|
24
|
+
_DEFAULT_JOBS_PATH = Path.home() / ".openclaw" / "cron" / "jobs.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _default_jobs() -> dict:
|
|
28
|
+
return {"version": 1, "jobs": []}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_jobs_path(path: str | Path | None = None) -> Path:
|
|
32
|
+
return Path(path) if path is not None else _DEFAULT_JOBS_PATH
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_jobs(path: Path) -> dict:
|
|
36
|
+
try:
|
|
37
|
+
with open(path, encoding="utf-8") as f:
|
|
38
|
+
data = json.load(f)
|
|
39
|
+
except FileNotFoundError:
|
|
40
|
+
return _default_jobs()
|
|
41
|
+
if not isinstance(data, dict):
|
|
42
|
+
raise ValueError(f"jobs.json 格式错误:根节点应为对象,收到 {type(data).__name__}")
|
|
43
|
+
data.setdefault("version", 1)
|
|
44
|
+
data.setdefault("jobs", [])
|
|
45
|
+
if not isinstance(data["jobs"], list):
|
|
46
|
+
raise ValueError("jobs.json 格式错误:jobs 字段应为数组")
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _atomic_write_json(path: Path, data: dict) -> None:
|
|
51
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
tmp_name = None
|
|
53
|
+
try:
|
|
54
|
+
with NamedTemporaryFile(
|
|
55
|
+
"w",
|
|
56
|
+
dir=path.parent,
|
|
57
|
+
delete=False,
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
) as tmp:
|
|
60
|
+
json.dump(data, tmp, indent=2, ensure_ascii=False)
|
|
61
|
+
tmp.flush()
|
|
62
|
+
os.fsync(tmp.fileno())
|
|
63
|
+
tmp_name = tmp.name
|
|
64
|
+
os.replace(tmp_name, path)
|
|
65
|
+
finally:
|
|
66
|
+
if tmp_name and os.path.exists(tmp_name):
|
|
67
|
+
os.unlink(tmp_name)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@contextmanager
|
|
71
|
+
def _locked_jobs(path: str | Path | None = None) -> Iterator[tuple[Path, dict]]:
|
|
72
|
+
jobs_path = resolve_jobs_path(path)
|
|
73
|
+
jobs_path.parent.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
lock_path = jobs_path.with_suffix(jobs_path.suffix + ".lock")
|
|
75
|
+
with open(lock_path, "a+", encoding="utf-8") as lock_file:
|
|
76
|
+
if fcntl is not None:
|
|
77
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
78
|
+
yield jobs_path, _read_jobs(jobs_path)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_jobs(path: str | Path | None = None) -> dict:
|
|
82
|
+
with _locked_jobs(path) as (_, data):
|
|
83
|
+
return data
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def list_job_names(path: str | Path | None = None) -> set[str]:
|
|
87
|
+
jobs = load_jobs(path).get("jobs", [])
|
|
88
|
+
return {job.get("name", "") for job in jobs if job.get("name")}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def append_job_if_absent(payload: dict, path: str | Path | None = None) -> bool:
|
|
92
|
+
name = payload.get("name", "")
|
|
93
|
+
with _locked_jobs(path) as (jobs_path, data):
|
|
94
|
+
jobs = data.setdefault("jobs", [])
|
|
95
|
+
existing = {job.get("name", "") for job in jobs}
|
|
96
|
+
if name and name in existing:
|
|
97
|
+
return False
|
|
98
|
+
jobs.append(payload)
|
|
99
|
+
_atomic_write_json(jobs_path, data)
|
|
100
|
+
return True
|