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.
Files changed (109) hide show
  1. package/package.json +1 -1
  2. package/skills/consensus/skill.json +20 -0
  3. package/skills/consensus/src/SKILL.md +93 -0
  4. package/skills/deepwiki/skill.json +20 -0
  5. package/skills/deepwiki/src/SKILL.md +45 -0
  6. package/skills/deepwiki/src/_meta.json +6 -0
  7. package/skills/deepwiki/src/scripts/deepwiki.js +135 -0
  8. package/skills/feishu-bitable/skill.json +20 -0
  9. package/skills/feishu-bitable/src/CHECKLIST.md +150 -0
  10. package/skills/feishu-bitable/src/README.md +178 -0
  11. package/skills/feishu-bitable/src/SKILL.md +113 -0
  12. package/skills/feishu-bitable/src/_meta.json +6 -0
  13. package/skills/feishu-bitable/src/api.js +381 -0
  14. package/skills/feishu-bitable/src/bin/cli.js +284 -0
  15. package/skills/feishu-bitable/src/description.md +143 -0
  16. package/skills/feishu-bitable/src/examples/create-records.json +52 -0
  17. package/skills/feishu-bitable/src/examples/create-table.json +64 -0
  18. package/skills/feishu-bitable/src/package-lock.json +324 -0
  19. package/skills/feishu-bitable/src/package.json +33 -0
  20. package/skills/feishu-bitable/src/publish-config.json +14 -0
  21. package/skills/feishu-bitable/src/test-simple.js +61 -0
  22. package/skills/feishu-bitable/src/utils.js +261 -0
  23. package/skills/flight-booking/skill.json +9 -2
  24. package/skills/flight-booking/src/scripts/flight_booking.py +2 -1
  25. package/skills/google-maps/skill.json +20 -0
  26. package/skills/google-maps/src/SKILL.md +237 -0
  27. package/skills/google-maps/src/_meta.json +6 -0
  28. package/skills/google-maps/src/lib/map_helper.py +912 -0
  29. package/skills/large-task-router/skill.json +20 -0
  30. package/skills/large-task-router/src/SKILL.md +79 -0
  31. package/skills/large-task-router/src/templates/plan.md +74 -0
  32. package/skills/skillhub/skill.json +11 -4
  33. package/skills/skillhub/src/SKILL.md +11 -1
  34. package/skills/sophnet-dailynews/skill.json +20 -0
  35. package/skills/sophnet-dailynews/src/SKILL.md +179 -0
  36. package/skills/sophnet-dailynews/src/cache.json +151 -0
  37. package/skills/sophnet-dailynews/src/sources.json +230 -0
  38. package/skills/sophnet-schedule/skill.json +20 -0
  39. package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -0
  40. package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -0
  41. package/skills/sophnet-schedule/src/SKILL.md +1050 -0
  42. package/skills/sophnet-schedule/src/_meta.json +6 -0
  43. package/skills/sophnet-schedule/src/api/__init__.py +0 -0
  44. package/skills/sophnet-schedule/src/api/models.py +245 -0
  45. package/skills/sophnet-schedule/src/apps/add_event.py +237 -0
  46. package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -0
  47. package/skills/sophnet-schedule/src/apps/check_roc.py +246 -0
  48. package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -0
  49. package/skills/sophnet-schedule/src/apps/import_events.py +216 -0
  50. package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -0
  51. package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -0
  52. package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -0
  53. package/skills/sophnet-schedule/src/compat.py +66 -0
  54. package/skills/sophnet-schedule/src/config/__init__.py +0 -0
  55. package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -0
  56. package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -0
  57. package/skills/sophnet-schedule/src/config/settings.py +133 -0
  58. package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -0
  59. package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -0
  60. package/skills/sophnet-schedule/src/gcal/__init__.py +0 -0
  61. package/skills/sophnet-schedule/src/gcal/client.py +374 -0
  62. package/skills/sophnet-schedule/src/gcal/models.py +91 -0
  63. package/skills/sophnet-schedule/src/requirements.txt +6 -0
  64. package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -0
  65. package/skills/sophnet-schedule/src/server.py +669 -0
  66. package/skills/sophnet-schedule/src/services/__init__.py +0 -0
  67. package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -0
  68. package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -0
  69. package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -0
  70. package/skills/sophnet-schedule/src/services/event_classifier.py +100 -0
  71. package/skills/sophnet-schedule/src/services/event_diff.py +160 -0
  72. package/skills/sophnet-schedule/src/services/google_integration.py +500 -0
  73. package/skills/sophnet-schedule/src/services/job_store.py +100 -0
  74. package/skills/sophnet-schedule/src/services/local_event_store.py +266 -0
  75. package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -0
  76. package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -0
  77. package/skills/sophnet-schedule/src/services/table_parser.py +286 -0
  78. package/skills/sophnet-schedule/src/services/task_builder.py +167 -0
  79. package/skills/sophnet-schedule/src/services/time_window.py +72 -0
  80. package/skills/sophnet-stock/skill.json +20 -0
  81. package/skills/sophnet-stock/src/App-Plan.md +442 -0
  82. package/skills/sophnet-stock/src/README.md +214 -0
  83. package/skills/sophnet-stock/src/SKILL.md +236 -0
  84. package/skills/sophnet-stock/src/TODO.md +394 -0
  85. package/skills/sophnet-stock/src/_meta.json +6 -0
  86. package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -0
  87. package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -0
  88. package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -0
  89. package/skills/sophnet-stock/src/docs/README.md +95 -0
  90. package/skills/sophnet-stock/src/docs/USAGE.md +465 -0
  91. package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -0
  92. package/skills/sophnet-stock/src/scripts/dividends.py +365 -0
  93. package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -0
  94. package/skills/sophnet-stock/src/scripts/portfolio.py +548 -0
  95. package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -0
  96. package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -0
  97. package/skills/sophnet-stock/src/scripts/watchlist.py +336 -0
  98. package/skills/xiaohongshu/skill.json +20 -0
  99. package/skills/xiaohongshu/src/SKILL.md +91 -0
  100. package/skills/xiaohongshu/src/_meta.json +6 -0
  101. package/skills/xiaohongshu/src/assets/card.html +216 -0
  102. package/skills/xiaohongshu/src/assets/cover.html +82 -0
  103. package/skills/xiaohongshu/src/assets/example.md +84 -0
  104. package/skills/xiaohongshu/src/assets/styles.css +318 -0
  105. package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -0
  106. package/skills/xiaohongshu/src/scripts/sign_server.py +158 -0
  107. package/skills/xiaohongshu/src/scripts/stealth.min.js +7 -0
  108. package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -0
  109. 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