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 +30 -14
- package/README.md +33 -18
- package/cordis.patch.yml +6 -2
- package/lib/caldav.js +38 -23
- package/lib/config.d.ts +28 -2
- package/lib/config.js +55 -4
- package/lib/oauth.d.ts +7 -0
- package/lib/oauth.js +85 -0
- package/lib/tools.js +38 -24
- package/package.json +4 -3
package/README.en.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://awesome-dsh-plugin.com)
|
|
6
6
|
|
|
7
|
-
DSH community plugin: read/write calendar events via CalDAV. Provides 5
|
|
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
|
-
|
|
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
|
-
- `
|
|
37
|
-
- `password`:
|
|
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`);
|
|
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
|
-
#
|
|
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
|
-
##
|
|
112
|
+
## Authentication troubleshooting
|
|
99
113
|
|
|
100
|
-
Google:
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
-
|
|
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
|
|
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
|
[](https://awesome-dsh-plugin.com)
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
DSH 社区插件:通过 CalDAV 读写日历事件。提供 5
|
|
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
|
-
|
|
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
|
-
- `
|
|
33
|
-
- `
|
|
34
|
-
- `
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
-
|
|
181
|
+
- OAuth 凭据需要事先取得:支持刷新令牌认证,但不提供浏览器登录 UI / 登录 CLI,也不把运行时令牌写回配置文件。
|
|
167
182
|
- 时区规则:带 TZID(命名时区)的事件输出会转成 UTC(Z);全天边界、夏令时等复杂时区规则不做精细化处理。
|
|
168
183
|
- 无设置页 UI:本轮为 node 半身,配置只走 cordis.patch.yml,不提供 Web 设置页。
|
|
169
184
|
- 日历发现:iCloud 需手动填完整日历集合 URL;不做 principal 自动发现与多日历选择。
|
|
170
|
-
-
|
|
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
|
-
# #
|
|
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
|
-
|
|
27
|
-
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
|
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 +
|
|
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
|
-
/**
|
|
23
|
+
/** Basic 认证账号;OAuth 不要求此字段。 */
|
|
16
24
|
username?: string;
|
|
17
|
-
/**
|
|
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 账号(
|
|
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(
|
|
101
|
+
'或在 profile 的 cordis.patch.yml 覆盖 calendar 行的 password(iCloud 请用应用专用密码)后重启。');
|
|
50
102
|
}
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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: '
|
|
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.
|
|
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
|
-
"description": "
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "DeepSeek Harness 日历插件:CalDAV 日程查询、创建、修改、删除与搜索,支持 Google OAuth 2.0、iCloud、Nextcloud、自定义服务及离线配置自检。",
|
|
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",
|