dsh-calendar 0.4.1 → 0.5.0

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/README.en.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
6
6
 
7
- DSH community plugin: read/write calendar events via CalDAV. Provides 5 model-facing tools (calendar_list / calendar_create / calendar_update / calendar_delete / calendar_search), supporting Google / iCloud / Nextcloud and any CalDAV server. This round is a node half-body with no settings-page UI; all configuration goes through the profile's cordis.patch.yml.
7
+ DSH community plugin: read/write calendar events via CalDAV. Provides 5 calendar tools (calendar_list / calendar_create / calendar_update / calendar_delete / calendar_search) plus the offline `calendar_health` configuration check. Google uses OAuth 2.0; iCloud / Nextcloud / custom servers retain Basic authentication by default. No settings-page UI; all configuration goes through the profile's cordis.patch.yml.
8
8
 
9
9
  ## Compatibility
10
10
 
11
- Verified against `@deepseek-ai/dsh@0.1.2-alpha.2` on 2026-08-31. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
11
+ Plugin contracts and Web-profile co-loading verified against the official source-run `@deepseek-ai/dsh@0.1.3-alpha.1` baseline on 2026-09-07. OAuth tests use mocked token and DAV responses, not a real Google account. Built for the cordis patch-bundle plugin model (`cordis.patch.yml` + `dsh.bundle.patch`). No runtime imports of `@deepseek-ai/*` internals.
12
12
 
13
13
  ## Installation
14
14
 
@@ -33,11 +33,13 @@ All configuration lives in your profile's cordis.patch.yml; override the `calend
33
33
 
34
34
  - `provider`: google | icloud | nextcloud | custom
35
35
  - `caldavUrl`: full calendar collection URL (required for custom / icloud; google / nextcloud can also override the preset manually)
36
- - `username`: CalDAV account (account email for Google / iCloud)
37
- - `password`: password; for Google / iCloud use an app-specific password. Recommended to use the `DSH_CALENDAR_PASSWORD` env var to avoid storing it in plaintext.
36
+ - `authMethod`: `basic` | `oauth`; Google defaults to and requires `oauth`, other providers default to `basic`. Opt in explicitly for another OAuth server. Google credentials in the environment never switch a Basic provider to OAuth automatically.
37
+ - `username` / `password`: required only for Basic authentication. Use an app-specific password for iCloud, preferably via `DSH_CALENDAR_PASSWORD`. **Google CalDAV rejects all Basic passwords, including app-specific passwords.**
38
+ - `clientId` / `clientSecret` / `refreshToken`: required for OAuth; also read from `DSH_CALENDAR_CLIENT_ID` / `DSH_CALENDAR_CLIENT_SECRET` / `DSH_CALENDAR_REFRESH_TOKEN`. Non-empty config values take precedence over environment variables. Never commit secrets or tokens to Git.
39
+ - `tokenUrl`: also read from `DSH_CALENDAR_TOKEN_URL`; defaults to `https://oauth2.googleapis.com/token` for Google and is required for other OAuth providers. OAuth tokenUrl and caldavUrl must use HTTPS without embedded credentials, query parameters or fragments.
38
40
  - `calendarId`: google-specific, the calendar ID (usually your email)
39
41
  - `host` / `user` / `calendar`: nextcloud-specific
40
- - `proxyUrl`: optional HTTP proxy (e.g. `http://127.0.0.1:7890`); routes only this plugin's CalDAV requests through it
42
+ - `proxyUrl`: optional HTTP proxy (e.g. `http://127.0.0.1:7890`); both OAuth token requests and CalDAV requests use this transport
41
43
 
42
44
  ### Google example
43
45
 
@@ -46,13 +48,25 @@ All configuration lives in your profile's cordis.patch.yml; override the `calend
46
48
  name: dsh-calendar
47
49
  config:
48
50
  provider: google
49
- username: you@gmail.com
50
51
  calendarId: you@gmail.com
51
- # password 推荐用环境变量 DSH_CALENDAR_PASSWORD
52
+ # authMethod: oauth # the default for Google
53
+ # Provide clientId / clientSecret / refreshToken via environment variables
52
54
  ```
53
55
 
54
56
  Google's CalDAV collection URL is assembled by the plugin: `https://apidata.googleusercontent.com/caldav/v2/<calendarId>/events`.
55
57
 
58
+ Set these placeholder values in the same terminal that starts source-run `pnpm dsh` or ordinary `dsh`:
59
+
60
+ ```bash
61
+ export DSH_CALENDAR_CLIENT_ID='your OAuth client ID'
62
+ export DSH_CALENDAR_CLIENT_SECRET='your OAuth client secret'
63
+ export DSH_CALENDAR_REFRESH_TOKEN='your authorized refresh token'
64
+ ```
65
+
66
+ These credentials come from your own Google Cloud OAuth client and a user authorization, not an email app password. Follow the [Google CalDAV setup guide](https://developers.google.com/workspace/calendar/caldav/v2/guide) to enable the API and configure OAuth. Request `https://www.googleapis.com/auth/calendar` for calendar read/write and offline access (`access_type=offline`) to obtain a refresh token; see [Google's offline authorization documentation](https://developers.google.com/identity/protocols/oauth2/web-server#offline). The plugin does not provide a browser login UI or a separate login CLI; supply an already authorized refresh token.
67
+
68
+ Access tokens are cached in memory and checked before each DAV request, with refresh before expiry. Both token and DAV requests inherit the tool call's AbortSignal. A 401 invalidates the token for the next call; **writes are never replayed automatically**. OAuth requests do not follow redirects or send Bearer tokens to cross-origin object hrefs, so configure the final calendar collection URL. Runtime tokens are not written to configuration or logs. If another OAuth provider rotates refresh tokens, supply valid credentials again when restarting.
69
+
56
70
  ### iCloud example
57
71
 
58
72
  ```yaml
@@ -95,20 +109,21 @@ The plugin assembles: `https://cloud.example.com/remote.php/dav/calendars/alice/
95
109
  # password 推荐用环境变量 DSH_CALENDAR_PASSWORD
96
110
  ```
97
111
 
98
- ## App-specific password guidance
112
+ ## Authentication troubleshooting
99
113
 
100
- Google: sign in to myaccount.google.com Security 2-Step Verification (must be enabled first) App passwords, choose "Other" to generate a 16-character password, and fill it into `password` or `DSH_CALENDAR_PASSWORD`. You cannot use your Google login password.
114
+ Google: OAuth 2.0 only. On 401/403, check OAuth authorization, calendar scope and calendar permissions. On refresh failure, verify clientId/clientSecret/refreshToken and re-authorize if the grant was revoked or expired. **Generating another app password cannot fix Google CalDAV authentication.**
101
115
 
102
116
  iCloud: sign in to appleid.apple.com → Sign-In and Security → App-Specific Passwords, generate one and fill it into `password` or `DSH_CALENDAR_PASSWORD`. You cannot use your Apple ID password.
103
117
 
104
- If a call returns 401/403, it's usually the wrong password (login password used instead of an app-specific password); the plugin returns a Chinese hint.
118
+ Nextcloud / custom Basic servers: check the account, password or provider-issued app token and calendar permissions. `calendar_health` checks configuration only; it neither connects nor proves authorization. Use `calendar_list` to verify the real connection.
105
119
 
106
120
  ## Proxy
107
121
 
108
- If your CalDAV server is not directly reachable from your network (some regional or corporate networks block it), set `proxyUrl` in the plugin config, e.g. `http://127.0.0.1:7890`, and restart. The proxy only routes this plugin's CalDAV requests; it does not affect other plugins in the same process.
122
+ If your CalDAV server is not directly reachable from your network (some regional or corporate networks block it), set `proxyUrl` in the plugin config, e.g. `http://127.0.0.1:7890`, and restart. Both token refresh and DAV requests use that HTTP proxy; it does not affect other plugins in the same process.
109
123
 
110
124
  ## Tool reference
111
125
 
126
+ - `calendar_health`: offline provider, endpoint and Basic/OAuth credential-completeness checks; never displays secrets or connects to the server.
112
127
  - `calendar_list`: list events in a time range (start/end, ISO 8601; defaults to the next 7 days). Recurring events are expanded by default (`expand` defaults to true, `maxOccurrences` defaults to 30, clamped to 1-200): each occurrence is a separate row with `isOccurrence: true` and `seriesStart`; non-recurring events keep `isOccurrence: false`. With `expand=false`, recurring events are returned as a single original entry with `rrule`. Results are stably sorted by start time.
113
128
  - `calendar_create`: create an event (summary/start/end required; description/location/allDay/rrule optional). Validates real calendar dates and `end >= start`.
114
129
  - `calendar_update`: edit an event by uid (summary/start/end/description/location/allDay/rrule optional; omitted fields keep their original values, including the recurrence rule).
@@ -124,7 +139,8 @@ Input and output are uniformly ISO 8601. Timed events are output in UTC (e.g. `2
124
139
 
125
140
  ## Changelog
126
141
 
127
- - **0.4.0**: new `calendar_health` self-check (one-call CalDAV config health: endpoint/username/password/connection).
142
+ - **0.5.0 (2026-09-07)**: fix Google CalDAV #2 with OAuth configuration/environment credentials, request-time refresh, cancellation and proxy forwarding. Make health checks and error guidance authentication-aware; retain Basic authentication for other servers.
143
+ - **0.4.0**: new `calendar_health` self-check (offline endpoint and credential configuration checks, not a connection test).
128
144
  - **0.3.2**:
129
145
  - Fix `calendar_update` dropping `rrule` while updating other fields.
130
146
  - Validate `end >= start` and reject impossible dates such as `2025-02-30`.
@@ -135,11 +151,11 @@ Input and output are uniformly ISO 8601. Timed events are output in UTC (e.g. `2
135
151
 
136
152
  - Recurring event expansion: calendar_list expands RRULE by default via ICAL.RecurExpansion (`expand=true`), capped by `maxOccurrences`; calendar_search still returns the original series (not expanded).
137
153
  - No single-instance edit/delete: calendar_update / calendar_delete operate on the whole recurring series (by uid); you cannot modify or delete just one occurrence (no RECURRENCE-ID instance-level operations).
138
- - No OAuth: Basic auth only (app-specific password); Google / iCloud OAuth login flows are not supported.
154
+ - OAuth credentials must be obtained beforehand: refresh-token authentication is supported, but there is no browser login UI / login CLI and runtime tokens are not written back to configuration.
139
155
  - Timezone rules: events with TZID (named timezone) are output converted to UTC (Z); all-day boundaries, DST, and other complex timezone rules are not handled finely.
140
156
  - No settings-page UI: this round is a node half-body; config only via cordis.patch.yml, no Web settings page.
141
157
  - Calendar discovery: iCloud requires manually filling the full calendar collection URL; no principal auto-discovery or multi-calendar selection.
142
- - Cancellation/timeout: tools rely on `timeoutMs` (60 seconds) for overall timeout; AbortSignal is not propagated to individual network requests.
158
+ - Cancellation/timeout: tools use `timeoutMs` (60 seconds) and forward the host AbortSignal into token refresh and DAV network requests. Concurrent calls cancel independently.
143
159
 
144
160
  ## Development
145
161
 
package/README.md CHANGED
@@ -9,11 +9,11 @@
9
9
  [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
10
10
 
11
11
 
12
- DSH 社区插件:通过 CalDAV 读写日历事件。提供 5 个面向模型的工具(calendar_list / calendar_create / calendar_update / calendar_delete / calendar_search),支持 Google / iCloud / Nextcloud 及任意 CalDAV 服务器。本轮为 node 半身,不含设置页 UI,配置全部走 profile 的 cordis.patch.yml。
12
+ DSH 社区插件:通过 CalDAV 读写日历事件。提供 5 个日历操作工具(calendar_list / calendar_create / calendar_update / calendar_delete / calendar_search)和 `calendar_health` 配置自检。Google 使用 OAuth 2.0;iCloud / Nextcloud / 自定义服务器默认保留 Basic 认证。不含设置页 UI,配置全部走 profile 的 cordis.patch.yml。
13
13
 
14
14
  ## 兼容性
15
15
 
16
- `@deepseek-ai/dsh@0.1.2-alpha.2` 上验证(2026-08-31)。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
16
+ 已在 `@deepseek-ai/dsh@0.1.3-alpha.1` 官方源码基线上验证插件接口与 Web profile 同载(2026-09-07)。OAuth 使用离线模拟的令牌端点和 DAV 响应测试,未使用真实 Google 账号验证。遵循 cordis 组合包补丁模型(`cordis.patch.yml` + `dsh.bundle.patch`),运行时不 import 任何 `@deepseek-ai/*` 内部模块。
17
17
 
18
18
  ## 安装
19
19
 
@@ -29,9 +29,11 @@ dsh plugin --profile web add dsh-calendar
29
29
 
30
30
  - `provider`:google | icloud | nextcloud | custom
31
31
  - `caldavUrl`:完整日历集合 URL(custom / icloud 必填;google / nextcloud 也可手填覆盖预设)
32
- - `username`:CalDAV 账号(Google / iCloud 为账号邮箱)
33
- - `password`:密码;Google / iCloud 请用应用专用密码。推荐用环境变量 `DSH_CALENDAR_PASSWORD`,避免明文入库。
34
- - `proxyUrl`:本机代理地址(如 http://127.0.0.1:7890)。中国大陆访问 Google / iCloud 必填,详见上文「特殊代理配置」专节;国内可直连的 CalDAV 服务无需填写。
32
+ - `authMethod`:`basic` | `oauth`;Google 默认且必须为 `oauth`,其他 provider 默认 `basic`。其他 OAuth 服务器需显式设置 `oauth`,不会因环境中存在 Google 凭据而自动切换。
33
+ - `username` / `password`:仅 Basic 认证必需。iCloud 请用应用专用密码;密码推荐用环境变量 `DSH_CALENDAR_PASSWORD`。**Google CalDAV 不接受任何 Basic 密码,包括应用专用密码。**
34
+ - `clientId` / `clientSecret` / `refreshToken`:OAuth 必需;分别支持 `DSH_CALENDAR_CLIENT_ID` / `DSH_CALENDAR_CLIENT_SECRET` / `DSH_CALENDAR_REFRESH_TOKEN`。非空配置值优先于环境变量。请勿把密钥或令牌提交到 Git。
35
+ - `tokenUrl`:可用 `DSH_CALENDAR_TOKEN_URL`;Google 默认 `https://oauth2.googleapis.com/token`,其他 OAuth 服务必填。OAuth 的 tokenUrl 与 caldavUrl 必须为不含内嵌账号密码、查询参数或片段的 HTTPS 地址。
36
+ - `proxyUrl`:可选 HTTP 代理地址(如 http://127.0.0.1:7890);令牌刷新和 CalDAV 请求共用该代理。可直连时无需填写。
35
37
  - `calendarId`:google 专用,日历 ID(通常是你的邮箱)
36
38
  - `host` / `user` / `calendar`:nextcloud 专用
37
39
 
@@ -46,15 +48,14 @@ dsh plugin --profile web remove dsh-calendar
46
48
 
47
49
  ## 中国用户:特殊代理配置(Google / iCloud)
48
50
 
49
- Google iCloud 的 CalDAV 端点在中国大陆**不可直连**,需要配合你常用的梯子/特殊代理使用。插件内置 `proxyUrl` 配置:把 CalDAV 请求路由到**你本机代理客户端的端口**,不影响其他插件,也无需改任何系统设置。
51
+ 若本机网络无法直连 Google / iCloud,插件内置的 `proxyUrl` 可把 OAuth 令牌请求和 CalDAV 请求路由到**你本机 HTTP 代理客户端的端口**,不影响其他插件,也无需改任何系统设置。
50
52
 
51
53
  ```yaml
52
54
  - id: calendar
53
55
  config:
54
56
  provider: google
55
- username: you@gmail.com
56
57
  calendarId: you@gmail.com
57
- password: 你的应用专用密码
58
+ # OAuth 凭据通过下文三个环境变量提供
58
59
  proxyUrl: http://127.0.0.1:7890 # 改成你代理客户端的本地端口
59
60
  ```
60
61
 
@@ -75,13 +76,25 @@ Google 与 iCloud 的 CalDAV 端点在中国大陆**不可直连**,需要配
75
76
  name: dsh-calendar
76
77
  config:
77
78
  provider: google
78
- username: you@gmail.com
79
79
  calendarId: you@gmail.com
80
- # password 推荐用环境变量 DSH_CALENDAR_PASSWORD
80
+ # authMethod: oauth # Google 默认即为 oauth
81
+ # clientId / clientSecret / refreshToken 推荐用环境变量
81
82
  ```
82
83
 
83
84
  Google 的 CalDAV 集合 URL 由插件拼成:`https://apidata.googleusercontent.com/caldav/v2/<calendarId>/events`。
84
85
 
86
+ 在启动源码版 `pnpm dsh` 或普通 `dsh` 的同一个终端中设置(以下均为占位值):
87
+
88
+ ```bash
89
+ export DSH_CALENDAR_CLIENT_ID='你的 OAuth 客户端 ID'
90
+ export DSH_CALENDAR_CLIENT_SECRET='你的 OAuth 客户端密钥'
91
+ export DSH_CALENDAR_REFRESH_TOKEN='你授权后取得的 refresh token'
92
+ ```
93
+
94
+ 凭据必须来自你自己的 Google Cloud OAuth 客户端和一次用户授权,而不是邮箱应用专用密码。按 [Google CalDAV 官方设置说明](https://developers.google.com/workspace/calendar/caldav/v2/guide) 启用 API、配置 OAuth;申请日历读写范围 `https://www.googleapis.com/auth/calendar`,并请求离线访问(`access_type=offline`)以取得刷新令牌,参见 [Google 离线授权说明](https://developers.google.com/identity/protocols/oauth2/web-server#offline)。尚未提供浏览器一键登录或独立登录 CLI;已有 OAuth 配置的用户可直接填入刷新令牌。
95
+
96
+ 插件在内存中缓存访问令牌,并在每次 DAV 请求前检查有效期、提前刷新;令牌请求与 DAV 请求都会透传调用的取消信号。401 会使缓存失效,下一次调用重新刷新,**不会自动重放写请求**。OAuth 请求不跟随重定向、不向其它源的对象 href 发送 Bearer token,请填写最终日历集合地址。运行时令牌不会写入配置或日志;若其他 OAuth 提供方轮换 refresh token,重启时需要重新提供有效凭据。
97
+
85
98
  ### iCloud 示例
86
99
 
87
100
  ```yaml
@@ -124,16 +137,17 @@ iCloud 需要完整日历集合 URL(含你的用户 ID 与日历 ID),在 i
124
137
  # password 推荐用环境变量 DSH_CALENDAR_PASSWORD
125
138
  ```
126
139
 
127
- ## 应用专用密码指引
140
+ ## 认证失败排查
128
141
 
129
- Google:登录 myaccount.google.com 安全 两步验证(需先开启)→ 应用专用密码,选择「其他」生成 16 位密码,填到 `password` 或 `DSH_CALENDAR_PASSWORD`。不能用你的 Google 登录密码。
142
+ Google:仅支持 OAuth 2.0。401/403 时检查 OAuth 授权、日历范围与日历访问权限;令牌刷新失败时核对 clientId/clientSecret/refreshToken,授权被撤销或过期时重新授权。**重新生成应用专用密码不能解决 Google CalDAV 认证失败。**
130
143
 
131
144
  iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生成后填到 `password` 或 `DSH_CALENDAR_PASSWORD`。不能用你的 Apple ID 密码。
132
145
 
133
- 若调用报 401/403,多半是密码不对(用了登录密码而非应用专用密码),插件会返回中文提示。
146
+ Nextcloud / 自定义 Basic 服务:检查账号、密码或服务要求的应用令牌及日历权限。`calendar_health` 只检查配置完整性,不联网、不证明授权成功;请再用 `calendar_list` 验证真实连接。
134
147
 
135
148
  ## 工具清单
136
149
 
150
+ - `calendar_health`:离线检查服务商、日历集合地址与 Basic/OAuth 凭据完整性,不回显密钥、不发起网络连接。
137
151
  - `calendar_list`:列出某时间段事件(start/end,ISO 8601,缺省未来 7 天)。默认展开重复事件(`expand` 默认 true,`maxOccurrences` 默认 30、clamp 1-200):每个实例独立成行,带 `isOccurrence: true` 与 `seriesStart`;非重复事件保持 `isOccurrence: false`。`expand=false` 时重复事件按原始单条返回并带 `rrule`。结果按开始时间稳定排序
138
152
  - `calendar_create`:新建事件(summary/start/end 必填,description/location/allDay/rrule 可选)。严格校验真实日历日期与 `end >= start`
139
153
  - `calendar_update`:按 uid 改事件(summary/start/end/description/location/allDay/rrule 可选,未提供保留原值,重复规则不再丢失)
@@ -148,7 +162,8 @@ iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生
148
162
 
149
163
  ## 版本记录
150
164
 
151
- - **0.4.0**:新增 `calendar_health` 自检(CalDAV 配置完整性一键体检:端点/账号/密码/连接)。
165
+ - **0.5.0(2026-09-07)**:修复 Google CalDAV #2:新增 OAuth 凭据与环境变量配置、请求时刷新、取消与代理透传;健康检查区分 Basic/OAuth,修正误导的应用专用密码说明。保留其他服务的 Basic 认证。
166
+ - **0.4.0**:新增 `calendar_health` 自检(离线检查 CalDAV 端点与凭据配置,不验证连接)。
152
167
  - **0.3.2**:
153
168
  - 修复 `calendar_update` 更新其他字段时丢失 `rrule` 的问题。
154
169
  - 更新与新建都会校验 `end >= start`,并拒绝 `2025-02-30` 这类不存在的日期。
@@ -158,16 +173,16 @@ iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生
158
173
 
159
174
  ## 已知限制
160
175
 
161
- - **国内网络**:Google 与 iCloud 的 CalDAV 端点在中国大陆不可直连;可用 `proxyUrl` 走本机梯子/特殊代理,或改用国内可直连的 CalDAV 端点(自建 Nextcloud/Radicale)。
176
+ - **网络可达性**:若无法直连,可用 `proxyUrl` 指定本机 HTTP 代理,或改用可直连的 CalDAV 端点。
162
177
 
163
178
 
164
179
  - 重复事件展开:calendar_list 默认用 ICAL.RecurExpansion 展开 RRULE(`expand=true`),受 `maxOccurrences` 封顶;calendar_search 仍返回原始系列(不展开)。
165
180
  - 不支持单次实例的改/删:calendar_update / calendar_delete 针对整个重复系列(按 uid 操作),无法只修改或删除某一次发生(不支持 RECURRENCE-ID 实例级操作)。
166
- - 不做 OAuth:仅支持 Basic 认证(应用专用密码),不支持 Google / iCloud 的 OAuth 登录流程。
181
+ - OAuth 凭据需要事先取得:支持刷新令牌认证,但不提供浏览器登录 UI / 登录 CLI,也不把运行时令牌写回配置文件。
167
182
  - 时区规则:带 TZID(命名时区)的事件输出会转成 UTC(Z);全天边界、夏令时等复杂时区规则不做精细化处理。
168
183
  - 无设置页 UI:本轮为 node 半身,配置只走 cordis.patch.yml,不提供 Web 设置页。
169
184
  - 日历发现:iCloud 需手动填完整日历集合 URL;不做 principal 自动发现与多日历选择。
170
- - 取消/超时:工具依赖 timeoutMs(60 秒)做整体超时,不在单次网络请求上透传 AbortSignal
185
+ - 取消/超时:工具使用 timeoutMs(60 秒),并向令牌刷新与 DAV 网络请求透传宿主 AbortSignal;并发调用独立取消。
171
186
 
172
187
  ## 开发
173
188
 
@@ -182,4 +197,4 @@ pnpm test # 构建 + node --test
182
197
 
183
198
  - [dsh-calendar](https://github.com/STARDUSTLC666/dsh-calendar) — CalDAV 日历五件套
184
199
  - [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
185
- - [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
200
+ - [dsh-dingtalk](https://github.com/STARDUSTLC666/dsh-dingtalk) — 钉钉群通知(零依赖)
package/cordis.patch.yml CHANGED
@@ -8,9 +8,13 @@
8
8
  # name: 'dsh-calendar'
9
9
  # config:
10
10
  # provider: google # google | icloud | nextcloud | custom
11
- # username: you@gmail.com # CalDAV 账号(Google/iCloud 用账号邮箱)
12
11
  # calendarId: you@gmail.com # google:日历 ID(通常是你的邮箱地址)
13
- # # password: xxxx # 应用专用密码;推荐改用环境变量 DSH_CALENDAR_PASSWORD
12
+ # # authMethod: oauth # google 默认 oauth,不接受 Basic/应用专用密码
13
+ # # clientId: ... # 或 DSH_CALENDAR_CLIENT_ID
14
+ # # clientSecret: ... # 推荐用环境变量 DSH_CALENDAR_CLIENT_SECRET
15
+ # # refreshToken: ... # 推荐用环境变量 DSH_CALENDAR_REFRESH_TOKEN
16
+ # # proxyUrl: http://127.0.0.1:7890 # 若需要,令牌刷新与 CalDAV 共用此代理
17
+ # # iCloud/Nextcloud/custom 默认 Basic:配置 username 与 DSH_CALENDAR_PASSWORD
14
18
  #
15
19
  - insert:
16
20
  - id: calendar
package/lib/caldav.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { createDAVClient } from 'tsdav';
8
8
  import { createProxyFetch } from './proxy-fetch.js';
9
+ import { createOAuthFetch, OAuthError } from './oauth.js';
9
10
  import { buildICalString, expandEventFromICal, generateUid, parseEventFromICal, } from './ical.js';
10
11
  /** CalDAV 操作错误:带中文指引。 */
11
12
  export class CalDAVError extends Error {
@@ -23,14 +24,25 @@ function normalizeUrl(value) {
23
24
  const trimmed = value.trim();
24
25
  return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed;
25
26
  }
26
- /** 把底层错误翻译成中文指引;识别 401/403 提示应用专用密码。 */
27
- function translateError(error, action) {
27
+ function authenticationError(config, status) {
28
+ const guidance = config.oauth !== undefined
29
+ ? '请检查 OAuth 授权范围、日历访问权限及 refreshToken;授权失效时请重新授权。Google CalDAV 不接受应用专用密码。'
30
+ : '请检查 username 与 password / DSH_CALENDAR_PASSWORD;iCloud 必须使用应用专用密码,其他服务请确认账号权限。Google CalDAV 需改用 OAuth 2.0。';
31
+ return new CalDAVError('账号认证失败(' + status + '):' + guidance, status);
32
+ }
33
+ /** 把底层错误翻译成与当前认证方式匹配的指引。 */
34
+ function translateError(error, action, config) {
35
+ if (error instanceof CalDAVError)
36
+ return error;
37
+ if (error instanceof OAuthError)
38
+ return new CalDAVError(error.message, error.status);
28
39
  const message = error instanceof Error ? error.message : String(error);
29
40
  const status = error?.status;
30
41
  if (status === 401 || status === 403 || /401|403/.test(message)) {
31
- return new CalDAVError('账号认证失败(401/403):请检查应用专用密码是否正确生成——' +
32
- 'Google 需在账号安全里创建「应用专用密码」,iCloud 需在 appleid.apple.com 创建 app 专用密码,' +
33
- '不能用登录密码。请在 profile 的 cordis.patch.yml 覆盖 calendar 行或设置环境变量 DSH_CALENDAR_PASSWORD 后重启。', status ?? (/401/.test(message) ? 401 : 403));
42
+ return authenticationError(config, status ?? (/401/.test(message) ? 401 : 403));
43
+ }
44
+ if (config.oauth !== undefined) {
45
+ return new CalDAVError(action + ' 失败:CalDAV 响应或网络请求异常,请检查日历地址与服务权限。', status);
34
46
  }
35
47
  return new CalDAVError(action + ' 失败:' + message, status);
36
48
  }
@@ -45,11 +57,15 @@ export class CalendarService {
45
57
  }
46
58
  client() {
47
59
  if (this.clientPromise === undefined) {
60
+ const transport = this.config.proxyUrl !== '' ? createProxyFetch(this.config.proxyUrl) : globalThis.fetch;
61
+ const oauth = this.config.oauth;
48
62
  this.clientPromise = createDAVClient({
49
63
  serverUrl: this.config.caldavUrl,
50
- credentials: { username: this.config.username, password: this.config.password },
51
- authMethod: 'Basic',
52
- ...(this.config.proxyUrl !== '' ? { fetch: createProxyFetch(this.config.proxyUrl) } : {}),
64
+ credentials: oauth === undefined ? { username: this.config.username, password: this.config.password } : {},
65
+ // createDAVClient 的 Oauth 分支只在初始化时取头;改由 fetch 在每次请求时检查过期。
66
+ authMethod: oauth === undefined ? 'Basic' : 'Custom',
67
+ ...(oauth === undefined ? {} : { authFunction: async () => ({}) }),
68
+ fetch: oauth === undefined ? transport : createOAuthFetch(oauth, transport, this.config.caldavUrl),
53
69
  });
54
70
  }
55
71
  // 创建失败时清掉缓存,让下一次工具调用有机会重试,而不是永久复用 rejected promise。
@@ -82,7 +98,7 @@ export class CalendarService {
82
98
  }
83
99
  catch (error) {
84
100
  signal?.throwIfAborted();
85
- throw translateError(error, '读取日历');
101
+ throw translateError(error, '读取日历', this.config);
86
102
  }
87
103
  }
88
104
  /** 列出全部事件(客户端过滤用)。 */
@@ -101,7 +117,7 @@ export class CalendarService {
101
117
  }
102
118
  catch (error) {
103
119
  signal?.throwIfAborted();
104
- throw translateError(error, '读取日历');
120
+ throw translateError(error, '读取日历', this.config);
105
121
  }
106
122
  }
107
123
  toEvents(objects) {
@@ -151,13 +167,13 @@ export class CalendarService {
151
167
  ...(signal !== undefined ? { fetchOptions: { signal } } : {}),
152
168
  });
153
169
  signal?.throwIfAborted();
154
- assertOk(response, '新建事件');
170
+ assertOk(response, '新建事件', this.config);
155
171
  }
156
172
  catch (error) {
157
173
  signal?.throwIfAborted();
158
174
  if (error instanceof CalDAVError)
159
175
  throw error;
160
- throw translateError(error, '新建事件');
176
+ throw translateError(error, '新建事件', this.config);
161
177
  }
162
178
  const href = new URL(filename, this.collectionUrl).href;
163
179
  const event = parseEventFromICal(iCalString, href);
@@ -174,7 +190,7 @@ export class CalendarService {
174
190
  }
175
191
  catch (error) {
176
192
  signal?.throwIfAborted();
177
- throw translateError(error, '查找事件');
193
+ throw translateError(error, '查找事件', this.config);
178
194
  }
179
195
  if (object === undefined) {
180
196
  throw new CalDAVError('找不到 uid 对应的事件:请用 calendar_list 或 calendar_search 重新获取最新 uid,' +
@@ -210,13 +226,13 @@ export class CalendarService {
210
226
  ...(signal !== undefined ? { fetchOptions: { signal } } : {}),
211
227
  });
212
228
  signal?.throwIfAborted();
213
- assertOk(response, '更新事件');
229
+ assertOk(response, '更新事件', this.config);
214
230
  }
215
231
  catch (error) {
216
232
  signal?.throwIfAborted();
217
233
  if (error instanceof CalDAVError)
218
234
  throw error;
219
- throw translateError(error, '更新事件');
235
+ throw translateError(error, '更新事件', this.config);
220
236
  }
221
237
  const event = parseEventFromICal(iCalString, object.url, object.etag);
222
238
  if (event === null)
@@ -232,7 +248,7 @@ export class CalendarService {
232
248
  }
233
249
  catch (error) {
234
250
  signal?.throwIfAborted();
235
- throw translateError(error, '查找事件');
251
+ throw translateError(error, '查找事件', this.config);
236
252
  }
237
253
  if (object === undefined) {
238
254
  throw new CalDAVError('找不到 uid 对应的事件:请用 calendar_list 或 calendar_search 重新获取最新 uid,' +
@@ -246,25 +262,24 @@ export class CalendarService {
246
262
  ...(signal !== undefined ? { fetchOptions: { signal } } : {}),
247
263
  });
248
264
  signal?.throwIfAborted();
249
- assertOk(response, '删除事件');
265
+ assertOk(response, '删除事件', this.config);
250
266
  }
251
267
  catch (error) {
252
268
  signal?.throwIfAborted();
253
269
  if (error instanceof CalDAVError)
254
270
  throw error;
255
- throw translateError(error, '删除事件');
271
+ throw translateError(error, '删除事件', this.config);
256
272
  }
257
273
  return { uid: object.url, href: object.url };
258
274
  }
259
275
  }
260
276
  /** 校验 HTTP 响应,把 401/403 与其它非 2xx 转成中文错误。 */
261
- function assertOk(response, action) {
277
+ function assertOk(response, action, config) {
262
278
  if (response.status === 401 || response.status === 403) {
263
- throw new CalDAVError('账号认证失败(' + response.status + '):请检查应用专用密码是否正确生成——' +
264
- 'Google 需在账号安全里创建「应用专用密码」,iCloud 需在 appleid.apple.com 创建 app 专用密码,' +
265
- '不能用登录密码。请在 profile 的 cordis.patch.yml 覆盖 calendar 行或设置环境变量 DSH_CALENDAR_PASSWORD 后重启。', response.status);
279
+ throw authenticationError(config, response.status);
266
280
  }
267
281
  if (!response.ok) {
268
- throw new CalDAVError(action + ' 失败:服务器返回 ' + response.status + ' ' + response.statusText, response.status);
282
+ throw new CalDAVError(action + ' 失败:服务器返回 ' + response.status +
283
+ (config.oauth === undefined ? ' ' + response.statusText : ''), response.status);
269
284
  }
270
285
  }
package/lib/config.d.ts CHANGED
@@ -6,16 +6,34 @@
6
6
  */
7
7
  /** 支持的 provider 预设。 */
8
8
  export type CalendarProvider = 'google' | 'icloud' | 'nextcloud' | 'custom';
9
+ export type CalendarAuthMethod = 'basic' | 'oauth';
10
+ /** 长期 OAuth 凭据;短期 access token 只保留在运行时内存中。 */
11
+ export interface CalendarOAuthCredentials {
12
+ tokenUrl: string;
13
+ clientId: string;
14
+ clientSecret: string;
15
+ refreshToken: string;
16
+ }
9
17
  /** 插件配置:可在 profile 的 cordis.patch.yml 覆盖 calendar 行的整个 config。 */
10
18
  export interface CalendarConfig {
11
19
  /** provider 预设;默认 custom。 */
12
20
  provider?: CalendarProvider;
13
21
  /** 完整日历集合 URL;custom / icloud 必填,google / nextcloud 可由此手填覆盖预设。 */
14
22
  caldavUrl?: string;
15
- /** CalDAV 账号(Google / iCloud 用账号邮箱)。 */
23
+ /** Basic 认证账号;OAuth 不要求此字段。 */
16
24
  username?: string;
17
- /** 密码;支持环境变量 DSH_CALENDAR_PASSWORD。Google / iCloud 请用应用专用密码。 */
25
+ /** Basic 密码;支持 DSH_CALENDAR_PASSWORD。iCloud 请用应用专用密码;Google 不支持 Basic。 */
18
26
  password?: string;
27
+ /** google 默认 oauth,其他 provider 默认 basic;Google 不允许 basic。 */
28
+ authMethod?: CalendarAuthMethod;
29
+ /** OAuth 客户端 ID;也可用 DSH_CALENDAR_CLIENT_ID。 */
30
+ clientId?: string;
31
+ /** OAuth 客户端密钥;推荐用 DSH_CALENDAR_CLIENT_SECRET。 */
32
+ clientSecret?: string;
33
+ /** OAuth 离线授权的刷新令牌;推荐用 DSH_CALENDAR_REFRESH_TOKEN。 */
34
+ refreshToken?: string;
35
+ /** OAuth 令牌端点;Google 自动使用官方端点,其他 provider 的 OAuth 必填。 */
36
+ tokenUrl?: string;
19
37
  /** google:日历 ID(通常是你的邮箱地址)。 */
20
38
  calendarId?: string;
21
39
  /** nextcloud:主机,如 https://cloud.example.com。 */
@@ -37,10 +55,12 @@ export interface ResolvedConfig {
37
55
  caldavUrl: string;
38
56
  username: string;
39
57
  password: string;
58
+ oauth?: CalendarOAuthCredentials;
40
59
  proxyUrl: string;
41
60
  }
42
61
  /** 预设端点常量。 */
43
62
  export declare const GOOGLE_CALDAV_PREFIX = "https://apidata.googleusercontent.com/caldav/v2/";
63
+ export declare const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
44
64
  export declare const ICLOUD_CALDAV_URL = "https://caldav.icloud.com";
45
65
  export declare const NEXTCLOUD_PATH = "/remote.php/dav/calendars/";
46
66
  /** 配置错误:带中文指引,模型和用户都能读。 */
@@ -54,5 +74,11 @@ export declare const CALENDAR_PROVIDERS: readonly CalendarProvider[];
54
74
  * @param env - 环境变量来源(测试可注入)。
55
75
  */
56
76
  export declare function resolveConfig(config: CalendarConfig | undefined, env?: NodeJS.ProcessEnv): ResolvedConfig;
77
+ /** Google 必须使用 OAuth;其他 provider 不会被环境中的 Google 凭据切换认证方式。 */
78
+ export declare function resolveAuthMethod(config: CalendarConfig | undefined): CalendarAuthMethod;
79
+ /** OAuth 凭据与 Bearer token 仅允许发往无 URL 内嵌凭据的 HTTPS 端点。 */
80
+ export declare function validateOAuthUrl(value: string, field: string): void;
81
+ /** 独立解析认证,供离线健康检查复用;不会发起网络请求或回显凭据。 */
82
+ export declare function resolveCredentials(config: CalendarConfig | undefined, env?: NodeJS.ProcessEnv): Pick<ResolvedConfig, 'username' | 'password' | 'oauth'>;
57
83
  /** 依据 provider 预设或手填字段拼出日历集合 URL。 */
58
84
  export declare function buildCaldavUrl(config: CalendarConfig, provider: CalendarProvider): string;
package/lib/config.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  /** 预设端点常量。 */
8
8
  export const GOOGLE_CALDAV_PREFIX = 'https://apidata.googleusercontent.com/caldav/v2/';
9
+ export const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
9
10
  export const ICLOUD_CALDAV_URL = 'https://caldav.icloud.com';
10
11
  export const NEXTCLOUD_PATH = '/remote.php/dav/calendars/';
11
12
  /** 配置错误:带中文指引,模型和用户都能读。 */
@@ -38,18 +39,68 @@ function normalizeProvider(value) {
38
39
  */
39
40
  export function resolveConfig(config, env = process.env) {
40
41
  const provider = normalizeProvider(config?.provider);
42
+ const credentials = resolveCredentials(config, env);
43
+ const caldavUrl = buildCaldavUrl(config ?? {}, provider);
44
+ if (credentials.oauth !== undefined)
45
+ validateOAuthUrl(caldavUrl, 'caldavUrl');
46
+ return { provider, caldavUrl, ...credentials, proxyUrl: nonEmpty(config?.proxyUrl) ?? '' };
47
+ }
48
+ /** Google 必须使用 OAuth;其他 provider 不会被环境中的 Google 凭据切换认证方式。 */
49
+ export function resolveAuthMethod(config) {
50
+ const provider = normalizeProvider(config?.provider);
51
+ const method = config?.authMethod ?? (provider === 'google' ? 'oauth' : 'basic');
52
+ if (method !== 'basic' && method !== 'oauth') {
53
+ throw new ConfigError('dsh-calendar 的 authMethod 只支持 basic / oauth。');
54
+ }
55
+ if (provider === 'google' && method !== 'oauth') {
56
+ throw new ConfigError('Google CalDAV 仅支持 OAuth 2.0,不接受 Basic 或应用专用密码;请配置 clientId、clientSecret、refreshToken。');
57
+ }
58
+ return method;
59
+ }
60
+ /** OAuth 凭据与 Bearer token 仅允许发往无 URL 内嵌凭据的 HTTPS 端点。 */
61
+ export function validateOAuthUrl(value, field) {
62
+ try {
63
+ const url = new URL(value);
64
+ if (url.protocol === 'https:' && url.username === '' && url.password === '' && url.search === '' && url.hash === '')
65
+ return;
66
+ }
67
+ catch {
68
+ // 不回显 URL,避免错误配置把凭据带入日志。
69
+ }
70
+ throw new ConfigError('dsh-calendar 的 OAuth ' + field + ' 必须是有效的 HTTPS 地址,且不能包含账号、密码、查询参数或片段。');
71
+ }
72
+ /** 独立解析认证,供离线健康检查复用;不会发起网络请求或回显凭据。 */
73
+ export function resolveCredentials(config, env = process.env) {
74
+ if (resolveAuthMethod(config) === 'oauth') {
75
+ const clientId = nonEmpty(config?.clientId) ?? nonEmpty(env.DSH_CALENDAR_CLIENT_ID);
76
+ const clientSecret = nonEmpty(config?.clientSecret) ?? nonEmpty(env.DSH_CALENDAR_CLIENT_SECRET);
77
+ const refreshToken = nonEmpty(config?.refreshToken) ?? nonEmpty(env.DSH_CALENDAR_REFRESH_TOKEN);
78
+ const tokenUrl = nonEmpty(config?.tokenUrl) ?? nonEmpty(env.DSH_CALENDAR_TOKEN_URL)
79
+ ?? (config?.provider === 'google' ? GOOGLE_TOKEN_URL : undefined);
80
+ const missing = [
81
+ [clientId, 'clientId / DSH_CALENDAR_CLIENT_ID'],
82
+ [clientSecret, 'clientSecret / DSH_CALENDAR_CLIENT_SECRET'],
83
+ [refreshToken, 'refreshToken / DSH_CALENDAR_REFRESH_TOKEN'],
84
+ [tokenUrl, 'tokenUrl / DSH_CALENDAR_TOKEN_URL'],
85
+ ].filter(([value]) => value === undefined).map(([, field]) => field);
86
+ if (clientId === undefined || clientSecret === undefined || refreshToken === undefined || tokenUrl === undefined) {
87
+ throw new ConfigError('dsh-calendar 的 OAuth 凭据不完整:请配置 ' + missing.join('、') +
88
+ ',然后重启。Google CalDAV 仅支持 OAuth 2.0,应用专用密码无法替代刷新令牌。');
89
+ }
90
+ validateOAuthUrl(tokenUrl, 'tokenUrl');
91
+ return { username: nonEmpty(config?.username) ?? '', password: '', oauth: { tokenUrl, clientId, clientSecret, refreshToken } };
92
+ }
41
93
  const username = nonEmpty(config?.username);
42
94
  const password = nonEmpty(config?.password) ?? nonEmpty(env.DSH_CALENDAR_PASSWORD);
43
95
  if (username === undefined) {
44
96
  throw new ConfigError('dsh-calendar 未配置 username:请在 profile 的 cordis.patch.yml 覆盖 calendar 行的 config,' +
45
- '填上 CalDAV 账号(Google / iCloud 为账号邮箱)后重启。');
97
+ '填上 CalDAV 账号(iCloud 为账号邮箱)后重启。');
46
98
  }
47
99
  if (password === undefined) {
48
100
  throw new ConfigError('dsh-calendar 未配置密码:请设置环境变量 DSH_CALENDAR_PASSWORD,' +
49
- '或在 profile 的 cordis.patch.yml 覆盖 calendar 行的 password(Google / iCloud 请用应用专用密码)后重启。');
101
+ '或在 profile 的 cordis.patch.yml 覆盖 calendar 行的 password(iCloud 请用应用专用密码)后重启。');
50
102
  }
51
- const caldavUrl = buildCaldavUrl(config ?? {}, provider);
52
- return { provider, caldavUrl, username, password, proxyUrl: nonEmpty(config?.proxyUrl) ?? '' };
103
+ return { username, password };
53
104
  }
54
105
  /** 依据 provider 预设或手填字段拼出日历集合 URL。 */
55
106
  export function buildCaldavUrl(config, provider) {
package/lib/oauth.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { CalendarOAuthCredentials } from './config.js';
2
+ export declare class OAuthError extends Error {
3
+ readonly status?: number | undefined;
4
+ constructor(message: string, status?: number | undefined);
5
+ }
6
+ /** tsdav 管理 token/expiry;此层补上失败校验、取消、代理与不泄露凭据的错误。 */
7
+ export declare function createOAuthFetch(config: CalendarOAuthCredentials, transport: typeof fetch, calendarUrl: string): typeof fetch;
package/lib/oauth.js ADDED
@@ -0,0 +1,85 @@
1
+ /** 请求时刷新 OAuth 头;不把 Bearer token 固定到长期缓存的 DAV 客户端。 */
2
+ import { getOauthHeaders } from 'tsdav';
3
+ export class OAuthError extends Error {
4
+ status;
5
+ constructor(message, status) {
6
+ super(message);
7
+ this.status = status;
8
+ this.name = 'OAuthError';
9
+ }
10
+ }
11
+ /** tsdav 管理 token/expiry;此层补上失败校验、取消、代理与不泄露凭据的错误。 */
12
+ export function createOAuthFetch(config, transport, calendarUrl) {
13
+ const calendarOrigin = new URL(calendarUrl).origin;
14
+ let credentials = { ...config };
15
+ const tokenFetch = async (input, init) => {
16
+ const response = await transport(input, { ...init, redirect: 'error' });
17
+ if (!response.ok) {
18
+ // 不读取/回显响应正文:服务端可能原样回显 client secret 或 refresh token。
19
+ throw new OAuthError('OAuth 令牌刷新失败(HTTP ' + response.status +
20
+ '):请检查 clientId、clientSecret、refreshToken;授权撤销或过期时请重新授权。', response.status);
21
+ }
22
+ let body;
23
+ try {
24
+ body = await response.clone().json();
25
+ }
26
+ catch {
27
+ throw new OAuthError('OAuth 令牌端点返回了无效 JSON;未发送日历请求。');
28
+ }
29
+ if (body === null || typeof body !== 'object' || typeof body.access_token !== 'string' || !/^[A-Za-z0-9._~+\/-]+=*$/.test(body.access_token)
30
+ || (body.token_type !== undefined && String(body.token_type).toLowerCase() !== 'bearer')
31
+ || (body.expires_in !== undefined && (typeof body.expires_in !== 'number' || !Number.isFinite(body.expires_in) || body.expires_in <= 0))
32
+ || (body.refresh_token !== undefined && (typeof body.refresh_token !== 'string' || body.refresh_token.trim() === ''))) {
33
+ throw new OAuthError('OAuth 令牌响应缺少有效的 Bearer access_token 或有效期;未发送日历请求。');
34
+ }
35
+ return response;
36
+ };
37
+ return async (input, init) => {
38
+ const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined);
39
+ signal?.throwIfAborted();
40
+ // 日历对象 href 属于服务端数据;不可用它把 Bearer token 带到其它源或 HTTP。
41
+ const requestUrl = new URL(input instanceof Request ? input.url : String(input));
42
+ if (requestUrl.origin !== calendarOrigin || requestUrl.username !== '' || requestUrl.password !== '') {
43
+ throw new OAuthError('OAuth 日历请求拒绝跨源或带内嵌凭据的对象地址;请检查 caldavUrl 与服务器返回的 href。');
44
+ }
45
+ // 每次请求用自己的快照与 signal;并发调用不会共享某一次调用的取消信号。
46
+ const previous = credentials;
47
+ const snapshot = { ...previous };
48
+ // 提前 30 秒刷新,避免传输过程中到期。
49
+ if ((snapshot.expiration ?? 0) <= Date.now() + 30_000)
50
+ snapshot.expiration = 0;
51
+ let headers;
52
+ try {
53
+ const result = await getOauthHeaders(snapshot, { signal }, tokenFetch);
54
+ signal?.throwIfAborted();
55
+ if (!result.headers.authorization)
56
+ throw new OAuthError('OAuth 未取得访问令牌;请重新授权。');
57
+ headers = result.headers;
58
+ }
59
+ catch (error) {
60
+ signal?.throwIfAborted();
61
+ if (error instanceof OAuthError)
62
+ throw error;
63
+ throw new OAuthError('OAuth 令牌请求失败:请检查网络、proxyUrl 与 tokenUrl,然后重试。');
64
+ }
65
+ // 只有实际刷新的调用才更新缓存,避免使用旧快照的并发读取覆盖新令牌。
66
+ if (snapshot.accessToken !== previous.accessToken || snapshot.expiration !== previous.expiration || snapshot.refreshToken !== previous.refreshToken) {
67
+ credentials = snapshot;
68
+ }
69
+ const requestHeaders = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
70
+ for (const [name, value] of Object.entries(headers))
71
+ requestHeaders.set(name, value);
72
+ let response;
73
+ try {
74
+ response = await transport(input, { ...init, headers: requestHeaders, redirect: 'error' });
75
+ }
76
+ catch {
77
+ signal?.throwIfAborted();
78
+ throw new OAuthError('OAuth 日历请求失败:请检查网络、proxyUrl 与日历地址;请求不允许重定向。');
79
+ }
80
+ // 不自动重放写请求;下一次调用会刷新,不复用已被服务端拒绝的 token。
81
+ if (response.status === 401 && credentials.accessToken === snapshot.accessToken)
82
+ credentials = { ...credentials, expiration: 0 };
83
+ return response;
84
+ };
85
+ }
package/lib/tools.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * @module dsh-calendar/tools
6
6
  */
7
7
  import { CalendarService } from './caldav.js';
8
- import { CALENDAR_PROVIDERS, resolveConfig } from './config.js';
8
+ import { CALENDAR_PROVIDERS, buildCaldavUrl, resolveAuthMethod, resolveConfig, resolveCredentials, validateOAuthUrl, } from './config.js';
9
9
  import { compileParameters } from './parameters.js';
10
10
  const EVENT_SCHEMA = { type: 'object', additionalProperties: true };
11
11
  const TIMEOUT_MS = 60000;
@@ -104,7 +104,18 @@ function buildSearchFilter(query) {
104
104
  }
105
105
  /** 构建六个工具定义;每个 execute 惰性解析配置,缺失时抛出中文指引。 */
106
106
  export function buildCalendarTools(config, env = process.env) {
107
- const service = () => new CalendarService(resolveConfig(config, env));
107
+ let cachedKey;
108
+ let cachedService;
109
+ const service = () => {
110
+ const resolved = resolveConfig(config, env);
111
+ // 闭包内比较,不写日志或磁盘;凭据/端点变化时不复用旧 token。
112
+ const key = JSON.stringify(resolved);
113
+ if (cachedService === undefined || cachedKey !== key) {
114
+ cachedService = new CalendarService(resolved);
115
+ cachedKey = key;
116
+ }
117
+ return cachedService;
118
+ };
108
119
  const list = {
109
120
  name: 'calendar_list',
110
121
  description: '列出某时间段内的日历事件(默认未来 7 天)。start/end 为 ISO 8601 时间(含时区偏移,如 2025-01-01T09:00:00+08:00),全天事件返回 YYYY-MM-DD。默认展开重复事件(RRULE):每个实例作为独立行返回,start/end 为该次发生时间,并带 isOccurrence=true 与 seriesStart(系列原开始时间);非重复事件保持单行且 isOccurrence=false。expand=false 时重复事件按原始单条返回并带 rrule 字段。maxOccurrences 为每个重复事件的展开次数上限。返回每个事件的稳定标识 uid,供 calendar_update / calendar_delete 使用。',
@@ -327,7 +338,7 @@ export function buildCalendarTools(config, env = process.env) {
327
338
  };
328
339
  const health = {
329
340
  name: 'calendar_health',
330
- description: 'dsh-calendar 自检:检查 CalDAV 配置完整性(服务商/日历地址/账号/密码),不发起网络连接。遇到问题时先运行本工具定位。',
341
+ description: 'dsh-calendar 自检:检查 CalDAV 配置完整性(服务商/日历地址/Basic 或 OAuth 凭据),不发起网络连接或验证账号授权。遇到问题时先运行本工具定位。',
331
342
  parameters: compileParameters({}),
332
343
  output: {
333
344
  schema: { type: 'object', additionalProperties: true },
@@ -349,34 +360,37 @@ export function buildCalendarTools(config, env = process.env) {
349
360
  const provider = rawProvider === undefined || rawProvider === null || rawProvider === '' ? 'custom' : String(rawProvider);
350
361
  const providerOk = CALENDAR_PROVIDERS.includes(provider);
351
362
  checks.push({ name: '服务商', ok: providerOk, detail: providerOk ? provider : '不支持 ' + provider + ';只支持 ' + CALENDAR_PROVIDERS.join(' / ') });
352
- let resolved;
353
363
  try {
354
- resolved = resolveConfig(config, env);
364
+ const endpoint = buildCaldavUrl(config ?? {}, provider);
365
+ if (resolveAuthMethod(config) === 'oauth')
366
+ validateOAuthUrl(endpoint, 'caldavUrl');
367
+ checks.push({ name: '日历地址', ok: true, detail: '已解析为 ' + endpoint });
355
368
  }
356
- catch {
357
- // Each individual check below provides the actionable detail.
369
+ catch (error) {
370
+ checks.push({ name: '日历地址', ok: false, detail: error instanceof Error ? error.message : String(error) });
358
371
  }
359
- const hasUser = typeof config?.username === 'string' && config.username.trim() !== '';
360
- const configuredPassword = typeof config?.password === 'string' && config.password.trim() !== '';
361
- const envPassword = typeof env.DSH_CALENDAR_PASSWORD === 'string' && env.DSH_CALENDAR_PASSWORD.trim() !== '';
362
372
  try {
363
- const endpoint = resolved ?? resolveConfig({
364
- ...config,
365
- username: hasUser ? config.username : '__health_check__',
366
- password: configuredPassword ? config.password : (envPassword ? env.DSH_CALENDAR_PASSWORD : '__health_check__'),
367
- }, env);
368
- checks.push({ name: '日历地址', ok: true, detail: '已解析为 ' + endpoint.caldavUrl });
373
+ if (resolveAuthMethod(config) === 'oauth') {
374
+ checks.push({ name: '认证方式', ok: true, detail: 'OAuth 2.0(无需 username/password)' });
375
+ }
376
+ else {
377
+ const hasUser = typeof config?.username === 'string' && config.username.trim() !== '';
378
+ checks.push({ name: '账号', ok: hasUser, detail: hasUser ? '已配置 Basic 账号' : '未配置:请填 username(iCloud 为账号邮箱)' });
379
+ }
369
380
  }
370
381
  catch (error) {
371
- checks.push({ name: '日历地址', ok: false, detail: error instanceof Error ? error.message : String(error) });
382
+ checks.push({ name: '认证方式', ok: false, detail: error instanceof Error ? error.message : String(error) });
383
+ }
384
+ try {
385
+ const credentials = resolveCredentials(config, env);
386
+ checks.push({ name: '认证凭据', ok: true, detail: credentials.oauth === undefined
387
+ ? '已配置 Basic 凭据;仅检查配置,未联网验证'
388
+ : '已配置 clientId、clientSecret、refreshToken 与 tokenUrl;仅检查配置,未联网验证' });
389
+ }
390
+ catch (error) {
391
+ checks.push({ name: '认证凭据', ok: false, detail: error instanceof Error ? error.message : String(error) });
372
392
  }
373
- checks.push({ name: '账号', ok: hasUser, detail: hasUser ? '已配置' : '未配置:请填 username(Google/iCloud 为账号邮箱)' });
374
- checks.push({
375
- name: '密码',
376
- ok: configuredPassword || envPassword,
377
- detail: configuredPassword ? '已在插件配置中提供' : envPassword ? '已通过环境变量 DSH_CALENDAR_PASSWORD 提供' : '未配置:请填 password 或环境变量 DSH_CALENDAR_PASSWORD(Google/iCloud 用应用专用密码)',
378
- });
379
- return { ok: resolved !== undefined, plugin: 'dsh-calendar', checks };
393
+ return { ok: checks.every((check) => check.ok === true), plugin: 'dsh-calendar', checks };
380
394
  },
381
395
  timeoutMs: TIMEOUT_MS,
382
396
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-calendar",
3
- "version": "0.4.1",
4
- "description": "DSH 日历工具插件:通过 CalDAV 读写日历事件(Google / iCloud / Nextcloud / 自定义)",
3
+ "version": "0.5.0",
4
+ "description": "DeepSeek Harness 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth 2.0、iCloudNextcloud、自定义服务及离线配置自检。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "lib",
18
18
  "cordis.patch.yml",
19
- "README.md"
19
+ "README.md",
20
+ "README.en.md"
20
21
  ],
21
22
  "scripts": {
22
23
  "build": "tsc -p tsconfig.json",