dsh-calendar 0.4.0 → 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 +39 -19
- package/README.md +39 -22
- package/cordis.patch.yml +6 -2
- package/lib/caldav.d.ts +5 -5
- package/lib/caldav.js +80 -33
- package/lib/config.d.ts +29 -2
- package/lib/config.js +57 -6
- package/lib/oauth.d.ts +7 -0
- package/lib/oauth.js +85 -0
- package/lib/tools.d.ts +1 -1
- package/lib/tools.js +66 -34
- package/package.json +4 -3
package/README.en.md
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# dsh-calendar
|
|
2
2
|
|
|
3
|
+
   
|
|
4
|
+
|
|
3
5
|
[](https://awesome-dsh-plugin.com)
|
|
4
6
|
|
|
5
|
-
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.
|
|
6
8
|
|
|
7
9
|
## Compatibility
|
|
8
10
|
|
|
9
|
-
|
|
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.
|
|
10
12
|
|
|
11
13
|
## Installation
|
|
12
14
|
|
|
@@ -31,11 +33,13 @@ All configuration lives in your profile's cordis.patch.yml; override the `calend
|
|
|
31
33
|
|
|
32
34
|
- `provider`: google | icloud | nextcloud | custom
|
|
33
35
|
- `caldavUrl`: full calendar collection URL (required for custom / icloud; google / nextcloud can also override the preset manually)
|
|
34
|
-
- `
|
|
35
|
-
- `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.
|
|
36
40
|
- `calendarId`: google-specific, the calendar ID (usually your email)
|
|
37
41
|
- `host` / `user` / `calendar`: nextcloud-specific
|
|
38
|
-
- `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
|
|
39
43
|
|
|
40
44
|
### Google example
|
|
41
45
|
|
|
@@ -44,13 +48,25 @@ All configuration lives in your profile's cordis.patch.yml; override the `calend
|
|
|
44
48
|
name: dsh-calendar
|
|
45
49
|
config:
|
|
46
50
|
provider: google
|
|
47
|
-
username: you@gmail.com
|
|
48
51
|
calendarId: you@gmail.com
|
|
49
|
-
#
|
|
52
|
+
# authMethod: oauth # the default for Google
|
|
53
|
+
# Provide clientId / clientSecret / refreshToken via environment variables
|
|
50
54
|
```
|
|
51
55
|
|
|
52
56
|
Google's CalDAV collection URL is assembled by the plugin: `https://apidata.googleusercontent.com/caldav/v2/<calendarId>/events`.
|
|
53
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
|
+
|
|
54
70
|
### iCloud example
|
|
55
71
|
|
|
56
72
|
```yaml
|
|
@@ -93,20 +109,21 @@ The plugin assembles: `https://cloud.example.com/remote.php/dav/calendars/alice/
|
|
|
93
109
|
# password 推荐用环境变量 DSH_CALENDAR_PASSWORD
|
|
94
110
|
```
|
|
95
111
|
|
|
96
|
-
##
|
|
112
|
+
## Authentication troubleshooting
|
|
97
113
|
|
|
98
|
-
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.**
|
|
99
115
|
|
|
100
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.
|
|
101
117
|
|
|
102
|
-
|
|
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.
|
|
103
119
|
|
|
104
120
|
## Proxy
|
|
105
121
|
|
|
106
|
-
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.
|
|
107
123
|
|
|
108
124
|
## Tool reference
|
|
109
125
|
|
|
126
|
+
- `calendar_health`: offline provider, endpoint and Basic/OAuth credential-completeness checks; never displays secrets or connects to the server.
|
|
110
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.
|
|
111
128
|
- `calendar_create`: create an event (summary/start/end required; description/location/allDay/rrule optional). Validates real calendar dates and `end >= start`.
|
|
112
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).
|
|
@@ -120,22 +137,25 @@ The stable event identifier `uid` is the CalDAV href (full object URL); `calenda
|
|
|
120
137
|
Input and output are uniformly ISO 8601. Timed events are output in UTC (e.g. `2025-01-15T01:00:00Z`); all-day events output `YYYY-MM-DD`. Input may carry a timezone offset (e.g. `2025-01-15T09:00:00+08:00`); the plugin converts to UTC internally for storage.
|
|
121
138
|
|
|
122
139
|
|
|
123
|
-
##
|
|
140
|
+
## Changelog
|
|
124
141
|
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
128
|
-
-
|
|
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).
|
|
144
|
+
- **0.3.2**:
|
|
145
|
+
- Fix `calendar_update` dropping `rrule` while updating other fields.
|
|
146
|
+
- Validate `end >= start` and reject impossible dates such as `2025-02-30`.
|
|
147
|
+
- Sort `calendar_list` / `calendar_search` output by start time and clamp search `limit` to 1-200.
|
|
148
|
+
- Reset the cached CalDAV client after creation failure so the next tool call can retry.
|
|
129
149
|
|
|
130
150
|
## Known limitations
|
|
131
151
|
|
|
132
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).
|
|
133
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).
|
|
134
|
-
-
|
|
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.
|
|
135
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.
|
|
136
156
|
- No settings-page UI: this round is a node half-body; config only via cordis.patch.yml, no Web settings page.
|
|
137
157
|
- Calendar discovery: iCloud requires manually filling the full calendar collection URL; no principal auto-discovery or multi-calendar selection.
|
|
138
|
-
- 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.
|
|
139
159
|
|
|
140
160
|
## Development
|
|
141
161
|
|
|
@@ -144,4 +164,4 @@ pnpm install
|
|
|
144
164
|
pnpm test # 构建 + node --test
|
|
145
165
|
```
|
|
146
166
|
|
|
147
|
-
Build output in `lib/`; tests in `test/*.test.mjs` (no real account needed).
|
|
167
|
+
Build output in `lib/`; tests in `test/*.test.mjs` (no real account needed).
|
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 可选,未提供保留原值,重复规则不再丢失)
|
|
@@ -146,26 +160,29 @@ iCloud:登录 appleid.apple.com → 登录与安全 → App 专用密码,生
|
|
|
146
160
|
|
|
147
161
|
输入输出统一 ISO 8601。定时事件输出为 UTC(如 `2025-01-15T01:00:00Z`),全天事件输出 `YYYY-MM-DD`。输入可带时区偏移(如 `2025-01-15T09:00:00+08:00`),插件内部转 UTC 存储。
|
|
148
162
|
|
|
149
|
-
##
|
|
163
|
+
## 版本记录
|
|
150
164
|
|
|
151
|
-
-
|
|
152
|
-
-
|
|
153
|
-
-
|
|
154
|
-
-
|
|
165
|
+
- **0.5.0(2026-09-07)**:修复 Google CalDAV #2:新增 OAuth 凭据与环境变量配置、请求时刷新、取消与代理透传;健康检查区分 Basic/OAuth,修正误导的应用专用密码说明。保留其他服务的 Basic 认证。
|
|
166
|
+
- **0.4.0**:新增 `calendar_health` 自检(离线检查 CalDAV 端点与凭据配置,不验证连接)。
|
|
167
|
+
- **0.3.2**:
|
|
168
|
+
- 修复 `calendar_update` 更新其他字段时丢失 `rrule` 的问题。
|
|
169
|
+
- 更新与新建都会校验 `end >= start`,并拒绝 `2025-02-30` 这类不存在的日期。
|
|
170
|
+
- `calendar_list` / `calendar_search` 输出按开始时间稳定排序;搜索 `limit` clamp 到 1-200。
|
|
171
|
+
- CalDAV 客户端创建失败后清空缓存,下一次调用可自动重试,不再永久复用 rejected promise。
|
|
155
172
|
|
|
156
173
|
|
|
157
174
|
## 已知限制
|
|
158
175
|
|
|
159
|
-
-
|
|
176
|
+
- **网络可达性**:若无法直连,可用 `proxyUrl` 指定本机 HTTP 代理,或改用可直连的 CalDAV 端点。
|
|
160
177
|
|
|
161
178
|
|
|
162
179
|
- 重复事件展开:calendar_list 默认用 ICAL.RecurExpansion 展开 RRULE(`expand=true`),受 `maxOccurrences` 封顶;calendar_search 仍返回原始系列(不展开)。
|
|
163
180
|
- 不支持单次实例的改/删:calendar_update / calendar_delete 针对整个重复系列(按 uid 操作),无法只修改或删除某一次发生(不支持 RECURRENCE-ID 实例级操作)。
|
|
164
|
-
-
|
|
181
|
+
- OAuth 凭据需要事先取得:支持刷新令牌认证,但不提供浏览器登录 UI / 登录 CLI,也不把运行时令牌写回配置文件。
|
|
165
182
|
- 时区规则:带 TZID(命名时区)的事件输出会转成 UTC(Z);全天边界、夏令时等复杂时区规则不做精细化处理。
|
|
166
183
|
- 无设置页 UI:本轮为 node 半身,配置只走 cordis.patch.yml,不提供 Web 设置页。
|
|
167
184
|
- 日历发现:iCloud 需手动填完整日历集合 URL;不做 principal 自动发现与多日历选择。
|
|
168
|
-
-
|
|
185
|
+
- 取消/超时:工具使用 timeoutMs(60 秒),并向令牌刷新与 DAV 网络请求透传宿主 AbortSignal;并发调用独立取消。
|
|
169
186
|
|
|
170
187
|
## 开发
|
|
171
188
|
|
|
@@ -180,4 +197,4 @@ pnpm test # 构建 + node --test
|
|
|
180
197
|
|
|
181
198
|
- [dsh-calendar](https://github.com/STARDUSTLC666/dsh-calendar) — CalDAV 日历五件套
|
|
182
199
|
- [dsh-slack](https://github.com/STARDUSTLC666/dsh-slack) — Slack 通知/收件箱
|
|
183
|
-
- [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.d.ts
CHANGED
|
@@ -23,20 +23,20 @@ export declare class CalendarService {
|
|
|
23
23
|
list(startIso: string, endIso: string, options?: {
|
|
24
24
|
expand?: boolean;
|
|
25
25
|
maxOccurrences?: number;
|
|
26
|
-
}): Promise<CalendarEvent[]>;
|
|
26
|
+
}, signal?: AbortSignal): Promise<CalendarEvent[]>;
|
|
27
27
|
/** 列出全部事件(客户端过滤用)。 */
|
|
28
|
-
all(): Promise<CalendarEvent[]>;
|
|
28
|
+
all(signal?: AbortSignal): Promise<CalendarEvent[]>;
|
|
29
29
|
private toEvents;
|
|
30
30
|
/** 列出并展开:每个对象经 expandEventFromICal 展开为若干实例行。 */
|
|
31
31
|
private toExpandedEvents;
|
|
32
32
|
/** 按 uid(href)找到服务器对象(含 etag 与原始 data)。 */
|
|
33
33
|
private findObject;
|
|
34
34
|
/** 新建事件,返回带 href/uid 的事件。 */
|
|
35
|
-
create(fields: EventFields): Promise<CalendarEvent>;
|
|
35
|
+
create(fields: EventFields, signal?: AbortSignal): Promise<CalendarEvent>;
|
|
36
36
|
/** 按 uid 更新事件;未提供的字段保留原值。 */
|
|
37
|
-
update(uid: string, changes: Partial<EventFields
|
|
37
|
+
update(uid: string, changes: Partial<EventFields>, signal?: AbortSignal): Promise<CalendarEvent>;
|
|
38
38
|
/** 按 uid 删除事件。 */
|
|
39
|
-
delete(uid: string): Promise<{
|
|
39
|
+
delete(uid: string, signal?: AbortSignal): Promise<{
|
|
40
40
|
uid: string;
|
|
41
41
|
href: string;
|
|
42
42
|
}>;
|
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。
|
|
@@ -62,36 +78,46 @@ export class CalendarService {
|
|
|
62
78
|
return { url: this.collectionUrl };
|
|
63
79
|
}
|
|
64
80
|
/** 列出某时间段内的事件;expand 为 true 时在窗口内展开 RRULE。 */
|
|
65
|
-
async list(startIso, endIso, options) {
|
|
81
|
+
async list(startIso, endIso, options, signal) {
|
|
66
82
|
const expand = options?.expand !== false;
|
|
67
83
|
const maxOccurrences = options?.maxOccurrences ?? 30;
|
|
84
|
+
signal?.throwIfAborted();
|
|
68
85
|
try {
|
|
69
86
|
const client = await this.client();
|
|
87
|
+
signal?.throwIfAborted();
|
|
70
88
|
const objects = await client.fetchCalendarObjects({
|
|
71
89
|
calendar: this.calendar(),
|
|
72
90
|
timeRange: { start: startIso, end: endIso },
|
|
73
91
|
urlFilter: (url) => typeof url === 'string' && url.length > 0,
|
|
92
|
+
...(signal !== undefined ? { fetchOptions: { signal } } : {}),
|
|
74
93
|
});
|
|
94
|
+
signal?.throwIfAborted();
|
|
75
95
|
return expand
|
|
76
96
|
? this.toExpandedEvents(objects, startIso, endIso, maxOccurrences)
|
|
77
97
|
: this.toEvents(objects);
|
|
78
98
|
}
|
|
79
99
|
catch (error) {
|
|
80
|
-
|
|
100
|
+
signal?.throwIfAborted();
|
|
101
|
+
throw translateError(error, '读取日历', this.config);
|
|
81
102
|
}
|
|
82
103
|
}
|
|
83
104
|
/** 列出全部事件(客户端过滤用)。 */
|
|
84
|
-
async all() {
|
|
105
|
+
async all(signal) {
|
|
106
|
+
signal?.throwIfAborted();
|
|
85
107
|
try {
|
|
86
108
|
const client = await this.client();
|
|
109
|
+
signal?.throwIfAborted();
|
|
87
110
|
const objects = await client.fetchCalendarObjects({
|
|
88
111
|
calendar: this.calendar(),
|
|
89
112
|
urlFilter: (url) => typeof url === 'string' && url.length > 0,
|
|
113
|
+
...(signal !== undefined ? { fetchOptions: { signal } } : {}),
|
|
90
114
|
});
|
|
115
|
+
signal?.throwIfAborted();
|
|
91
116
|
return this.toEvents(objects);
|
|
92
117
|
}
|
|
93
118
|
catch (error) {
|
|
94
|
-
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
throw translateError(error, '读取日历', this.config);
|
|
95
121
|
}
|
|
96
122
|
}
|
|
97
123
|
toEvents(objects) {
|
|
@@ -112,32 +138,42 @@ export class CalendarService {
|
|
|
112
138
|
return events;
|
|
113
139
|
}
|
|
114
140
|
/** 按 uid(href)找到服务器对象(含 etag 与原始 data)。 */
|
|
115
|
-
async findObject(uid) {
|
|
141
|
+
async findObject(uid, signal) {
|
|
142
|
+
signal?.throwIfAborted();
|
|
116
143
|
const client = await this.client();
|
|
144
|
+
signal?.throwIfAborted();
|
|
117
145
|
const target = normalizeUrl(uid);
|
|
118
146
|
const objects = await client.fetchCalendarObjects({
|
|
119
147
|
calendar: this.calendar(),
|
|
120
148
|
urlFilter: (url) => normalizeUrl(url) === target,
|
|
149
|
+
...(signal !== undefined ? { fetchOptions: { signal } } : {}),
|
|
121
150
|
});
|
|
151
|
+
signal?.throwIfAborted();
|
|
122
152
|
return objects.find((object) => normalizeUrl(object.url) === target);
|
|
123
153
|
}
|
|
124
154
|
/** 新建事件,返回带 href/uid 的事件。 */
|
|
125
|
-
async create(fields) {
|
|
126
|
-
|
|
127
|
-
const
|
|
155
|
+
async create(fields, signal) {
|
|
156
|
+
signal?.throwIfAborted();
|
|
157
|
+
const icalUid = fields.icalUid ?? generateUid();
|
|
158
|
+
const iCalString = buildICalString({ ...fields, icalUid });
|
|
159
|
+
const filename = icalUid + '.ics';
|
|
128
160
|
try {
|
|
129
161
|
const client = await this.client();
|
|
162
|
+
signal?.throwIfAborted();
|
|
130
163
|
const response = await client.createCalendarObject({
|
|
131
164
|
calendar: this.calendar(),
|
|
132
165
|
iCalString,
|
|
133
166
|
filename,
|
|
167
|
+
...(signal !== undefined ? { fetchOptions: { signal } } : {}),
|
|
134
168
|
});
|
|
135
|
-
|
|
169
|
+
signal?.throwIfAborted();
|
|
170
|
+
assertOk(response, '新建事件', this.config);
|
|
136
171
|
}
|
|
137
172
|
catch (error) {
|
|
173
|
+
signal?.throwIfAborted();
|
|
138
174
|
if (error instanceof CalDAVError)
|
|
139
175
|
throw error;
|
|
140
|
-
throw translateError(error, '新建事件');
|
|
176
|
+
throw translateError(error, '新建事件', this.config);
|
|
141
177
|
}
|
|
142
178
|
const href = new URL(filename, this.collectionUrl).href;
|
|
143
179
|
const event = parseEventFromICal(iCalString, href);
|
|
@@ -146,13 +182,15 @@ export class CalendarService {
|
|
|
146
182
|
return event;
|
|
147
183
|
}
|
|
148
184
|
/** 按 uid 更新事件;未提供的字段保留原值。 */
|
|
149
|
-
async update(uid, changes) {
|
|
185
|
+
async update(uid, changes, signal) {
|
|
186
|
+
signal?.throwIfAborted();
|
|
150
187
|
let object;
|
|
151
188
|
try {
|
|
152
|
-
object = await this.findObject(uid);
|
|
189
|
+
object = await this.findObject(uid, signal);
|
|
153
190
|
}
|
|
154
191
|
catch (error) {
|
|
155
|
-
|
|
192
|
+
signal?.throwIfAborted();
|
|
193
|
+
throw translateError(error, '查找事件', this.config);
|
|
156
194
|
}
|
|
157
195
|
if (object === undefined) {
|
|
158
196
|
throw new CalDAVError('找不到 uid 对应的事件:请用 calendar_list 或 calendar_search 重新获取最新 uid,' +
|
|
@@ -182,15 +220,19 @@ export class CalendarService {
|
|
|
182
220
|
const iCalString = buildICalString(merged);
|
|
183
221
|
try {
|
|
184
222
|
const client = await this.client();
|
|
223
|
+
signal?.throwIfAborted();
|
|
185
224
|
const response = await client.updateCalendarObject({
|
|
186
225
|
calendarObject: { url: object.url, etag: object.etag, data: iCalString },
|
|
226
|
+
...(signal !== undefined ? { fetchOptions: { signal } } : {}),
|
|
187
227
|
});
|
|
188
|
-
|
|
228
|
+
signal?.throwIfAborted();
|
|
229
|
+
assertOk(response, '更新事件', this.config);
|
|
189
230
|
}
|
|
190
231
|
catch (error) {
|
|
232
|
+
signal?.throwIfAborted();
|
|
191
233
|
if (error instanceof CalDAVError)
|
|
192
234
|
throw error;
|
|
193
|
-
throw translateError(error, '更新事件');
|
|
235
|
+
throw translateError(error, '更新事件', this.config);
|
|
194
236
|
}
|
|
195
237
|
const event = parseEventFromICal(iCalString, object.url, object.etag);
|
|
196
238
|
if (event === null)
|
|
@@ -198,13 +240,15 @@ export class CalendarService {
|
|
|
198
240
|
return event;
|
|
199
241
|
}
|
|
200
242
|
/** 按 uid 删除事件。 */
|
|
201
|
-
async delete(uid) {
|
|
243
|
+
async delete(uid, signal) {
|
|
244
|
+
signal?.throwIfAborted();
|
|
202
245
|
let object;
|
|
203
246
|
try {
|
|
204
|
-
object = await this.findObject(uid);
|
|
247
|
+
object = await this.findObject(uid, signal);
|
|
205
248
|
}
|
|
206
249
|
catch (error) {
|
|
207
|
-
|
|
250
|
+
signal?.throwIfAborted();
|
|
251
|
+
throw translateError(error, '查找事件', this.config);
|
|
208
252
|
}
|
|
209
253
|
if (object === undefined) {
|
|
210
254
|
throw new CalDAVError('找不到 uid 对应的事件:请用 calendar_list 或 calendar_search 重新获取最新 uid,' +
|
|
@@ -212,27 +256,30 @@ export class CalendarService {
|
|
|
212
256
|
}
|
|
213
257
|
try {
|
|
214
258
|
const client = await this.client();
|
|
259
|
+
signal?.throwIfAborted();
|
|
215
260
|
const response = await client.deleteCalendarObject({
|
|
216
261
|
calendarObject: { url: object.url, etag: object.etag },
|
|
262
|
+
...(signal !== undefined ? { fetchOptions: { signal } } : {}),
|
|
217
263
|
});
|
|
218
|
-
|
|
264
|
+
signal?.throwIfAborted();
|
|
265
|
+
assertOk(response, '删除事件', this.config);
|
|
219
266
|
}
|
|
220
267
|
catch (error) {
|
|
268
|
+
signal?.throwIfAborted();
|
|
221
269
|
if (error instanceof CalDAVError)
|
|
222
270
|
throw error;
|
|
223
|
-
throw translateError(error, '删除事件');
|
|
271
|
+
throw translateError(error, '删除事件', this.config);
|
|
224
272
|
}
|
|
225
273
|
return { uid: object.url, href: object.url };
|
|
226
274
|
}
|
|
227
275
|
}
|
|
228
276
|
/** 校验 HTTP 响应,把 401/403 与其它非 2xx 转成中文错误。 */
|
|
229
|
-
function assertOk(response, action) {
|
|
277
|
+
function assertOk(response, action, config) {
|
|
230
278
|
if (response.status === 401 || response.status === 403) {
|
|
231
|
-
throw
|
|
232
|
-
'Google 需在账号安全里创建「应用专用密码」,iCloud 需在 appleid.apple.com 创建 app 专用密码,' +
|
|
233
|
-
'不能用登录密码。请在 profile 的 cordis.patch.yml 覆盖 calendar 行或设置环境变量 DSH_CALENDAR_PASSWORD 后重启。', response.status);
|
|
279
|
+
throw authenticationError(config, response.status);
|
|
234
280
|
}
|
|
235
281
|
if (!response.ok) {
|
|
236
|
-
throw new CalDAVError(action + ' 失败:服务器返回 ' + response.status +
|
|
282
|
+
throw new CalDAVError(action + ' 失败:服务器返回 ' + response.status +
|
|
283
|
+
(config.oauth === undefined ? ' ' + response.statusText : ''), response.status);
|
|
237
284
|
}
|
|
238
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,21 +55,30 @@ 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
|
/** 配置错误:带中文指引,模型和用户都能读。 */
|
|
47
67
|
export declare class ConfigError extends Error {
|
|
48
68
|
constructor(message: string);
|
|
49
69
|
}
|
|
70
|
+
export declare const CALENDAR_PROVIDERS: readonly CalendarProvider[];
|
|
50
71
|
/**
|
|
51
72
|
* 解析配置为可用的 caldavUrl + 凭证。配置缺失抛出 ConfigError(中文指引)。
|
|
52
73
|
* @param config - 插件 config(可能 undefined)。
|
|
53
74
|
* @param env - 环境变量来源(测试可注入)。
|
|
54
75
|
*/
|
|
55
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'>;
|
|
56
83
|
/** 依据 provider 预设或手填字段拼出日历集合 URL。 */
|
|
57
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
|
/** 配置错误:带中文指引,模型和用户都能读。 */
|
|
@@ -15,7 +16,7 @@ export class ConfigError extends Error {
|
|
|
15
16
|
this.name = 'ConfigError';
|
|
16
17
|
}
|
|
17
18
|
}
|
|
18
|
-
const
|
|
19
|
+
export const CALENDAR_PROVIDERS = ['google', 'icloud', 'nextcloud', 'custom'];
|
|
19
20
|
function nonEmpty(value) {
|
|
20
21
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
|
21
22
|
}
|
|
@@ -25,7 +26,7 @@ function trimTrailingSlash(value) {
|
|
|
25
26
|
function normalizeProvider(value) {
|
|
26
27
|
if (value === undefined || value === null || value === '')
|
|
27
28
|
return 'custom';
|
|
28
|
-
if (typeof value === 'string' &&
|
|
29
|
+
if (typeof value === 'string' && CALENDAR_PROVIDERS.includes(value)) {
|
|
29
30
|
return value;
|
|
30
31
|
}
|
|
31
32
|
throw new ConfigError('dsh-calendar 的 provider 取值不合法:只支持 google / icloud / nextcloud / custom。' +
|
|
@@ -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.d.ts
CHANGED
|
@@ -27,4 +27,4 @@ export interface CalendarToolDefinition {
|
|
|
27
27
|
timeoutMs?: number;
|
|
28
28
|
}
|
|
29
29
|
/** 构建六个工具定义;每个 execute 惰性解析配置,缺失时抛出中文指引。 */
|
|
30
|
-
export declare function buildCalendarTools(config: CalendarConfig | undefined): CalendarToolDefinition[];
|
|
30
|
+
export declare function buildCalendarTools(config: CalendarConfig | undefined, env?: NodeJS.ProcessEnv): CalendarToolDefinition[];
|
package/lib/tools.js
CHANGED
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
* @module dsh-calendar/tools
|
|
6
6
|
*/
|
|
7
7
|
import { CalendarService } from './caldav.js';
|
|
8
|
-
import { 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;
|
|
12
12
|
function asRecord(args) {
|
|
13
13
|
return typeof args === 'object' && args !== null ? args : {};
|
|
14
14
|
}
|
|
15
|
+
function executionSignal(exec) {
|
|
16
|
+
if (typeof exec !== 'object' || exec === null)
|
|
17
|
+
return undefined;
|
|
18
|
+
const signal = exec.signal;
|
|
19
|
+
return signal instanceof AbortSignal ? signal : undefined;
|
|
20
|
+
}
|
|
15
21
|
function optionalString(args, key) {
|
|
16
22
|
const value = args[key];
|
|
17
23
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
@@ -97,8 +103,19 @@ function buildSearchFilter(query) {
|
|
|
97
103
|
};
|
|
98
104
|
}
|
|
99
105
|
/** 构建六个工具定义;每个 execute 惰性解析配置,缺失时抛出中文指引。 */
|
|
100
|
-
export function buildCalendarTools(config) {
|
|
101
|
-
|
|
106
|
+
export function buildCalendarTools(config, env = process.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
|
+
};
|
|
102
119
|
const list = {
|
|
103
120
|
name: 'calendar_list',
|
|
104
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 使用。',
|
|
@@ -128,7 +145,7 @@ export function buildCalendarTools(config) {
|
|
|
128
145
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
129
146
|
},
|
|
130
147
|
},
|
|
131
|
-
async execute(args) {
|
|
148
|
+
async execute(args, exec) {
|
|
132
149
|
const input = asRecord(args);
|
|
133
150
|
const now = new Date();
|
|
134
151
|
const defaultEnd = new Date(now.getTime() + 7 * 24 * 3600 * 1000);
|
|
@@ -139,7 +156,7 @@ export function buildCalendarTools(config) {
|
|
|
139
156
|
assertTimeRange(start, end);
|
|
140
157
|
const expand = booleanWithDefault(input, 'expand', true);
|
|
141
158
|
const maxOccurrences = clampedInteger(input, 'maxOccurrences', 30, 1, 200);
|
|
142
|
-
const events = sortEvents(await service().list(start, end, { expand, maxOccurrences }));
|
|
159
|
+
const events = sortEvents(await service().list(start, end, { expand, maxOccurrences }, executionSignal(exec)));
|
|
143
160
|
return { count: events.length, start, end, events };
|
|
144
161
|
},
|
|
145
162
|
timeoutMs: TIMEOUT_MS,
|
|
@@ -168,7 +185,7 @@ export function buildCalendarTools(config) {
|
|
|
168
185
|
return [{ type: 'text', text: '已新建事件:' + formatEvent(event) }];
|
|
169
186
|
},
|
|
170
187
|
},
|
|
171
|
-
async execute(args) {
|
|
188
|
+
async execute(args, exec) {
|
|
172
189
|
const input = asRecord(args);
|
|
173
190
|
const summary = requiredString(input, 'summary', '事件标题');
|
|
174
191
|
const start = requiredString(input, 'start', '开始时间');
|
|
@@ -189,7 +206,7 @@ export function buildCalendarTools(config) {
|
|
|
189
206
|
...(allDay !== undefined ? { allDay } : {}),
|
|
190
207
|
...(rrule !== undefined ? { rrule } : {}),
|
|
191
208
|
};
|
|
192
|
-
const created = await service().create(fields);
|
|
209
|
+
const created = await service().create(fields, executionSignal(exec));
|
|
193
210
|
return { created };
|
|
194
211
|
},
|
|
195
212
|
timeoutMs: TIMEOUT_MS,
|
|
@@ -219,7 +236,7 @@ export function buildCalendarTools(config) {
|
|
|
219
236
|
return [{ type: 'text', text: '已更新事件:' + formatEvent(event) }];
|
|
220
237
|
},
|
|
221
238
|
},
|
|
222
|
-
async execute(args) {
|
|
239
|
+
async execute(args, exec) {
|
|
223
240
|
const input = asRecord(args);
|
|
224
241
|
const uid = requiredString(input, 'uid', '事件 uid');
|
|
225
242
|
const changes = {};
|
|
@@ -248,7 +265,7 @@ export function buildCalendarTools(config) {
|
|
|
248
265
|
const rrule = optionalString(input, 'rrule');
|
|
249
266
|
if (rrule !== undefined)
|
|
250
267
|
changes.rrule = rrule;
|
|
251
|
-
const updated = await service().update(uid, changes);
|
|
268
|
+
const updated = await service().update(uid, changes, executionSignal(exec));
|
|
252
269
|
return { updated };
|
|
253
270
|
},
|
|
254
271
|
timeoutMs: TIMEOUT_MS,
|
|
@@ -275,10 +292,10 @@ export function buildCalendarTools(config) {
|
|
|
275
292
|
return [{ type: 'text', text: '已删除事件:uid=' + result.uid }];
|
|
276
293
|
},
|
|
277
294
|
},
|
|
278
|
-
async execute(args) {
|
|
295
|
+
async execute(args, exec) {
|
|
279
296
|
const input = asRecord(args);
|
|
280
297
|
const uid = requiredString(input, 'uid', '事件 uid');
|
|
281
|
-
const result = await service().delete(uid);
|
|
298
|
+
const result = await service().delete(uid, executionSignal(exec));
|
|
282
299
|
return { deleted: true, uid: result.uid, href: result.href };
|
|
283
300
|
},
|
|
284
301
|
timeoutMs: TIMEOUT_MS,
|
|
@@ -309,11 +326,11 @@ export function buildCalendarTools(config) {
|
|
|
309
326
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
310
327
|
},
|
|
311
328
|
},
|
|
312
|
-
async execute(args) {
|
|
329
|
+
async execute(args, exec) {
|
|
313
330
|
const input = asRecord(args);
|
|
314
331
|
const query = requiredString(input, 'query', '搜索关键词');
|
|
315
332
|
const limit = clampedInteger(input, 'limit', 50, 1, 200);
|
|
316
|
-
const all = await service().all();
|
|
333
|
+
const all = await service().all(executionSignal(exec));
|
|
317
334
|
const matched = sortEvents(all).filter(buildSearchFilter(query)).slice(0, limit);
|
|
318
335
|
return { query, count: matched.length, events: matched };
|
|
319
336
|
},
|
|
@@ -321,7 +338,7 @@ export function buildCalendarTools(config) {
|
|
|
321
338
|
};
|
|
322
339
|
const health = {
|
|
323
340
|
name: 'calendar_health',
|
|
324
|
-
description: 'dsh-calendar 自检:检查 CalDAV
|
|
341
|
+
description: 'dsh-calendar 自检:检查 CalDAV 配置完整性(服务商/日历地址/Basic 或 OAuth 凭据),不发起网络连接或验证账号授权。遇到问题时先运行本工具定位。',
|
|
325
342
|
parameters: compileParameters({}),
|
|
326
343
|
output: {
|
|
327
344
|
schema: { type: 'object', additionalProperties: true },
|
|
@@ -336,29 +353,44 @@ export function buildCalendarTools(config) {
|
|
|
336
353
|
return [{ type: 'text', text: lines.join('\n') }];
|
|
337
354
|
},
|
|
338
355
|
},
|
|
339
|
-
async execute() {
|
|
356
|
+
async execute(_args, exec) {
|
|
357
|
+
executionSignal(exec)?.throwIfAborted();
|
|
340
358
|
const checks = [];
|
|
341
|
-
|
|
342
|
-
const provider =
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
359
|
+
const rawProvider = config?.provider;
|
|
360
|
+
const provider = rawProvider === undefined || rawProvider === null || rawProvider === '' ? 'custom' : String(rawProvider);
|
|
361
|
+
const providerOk = CALENDAR_PROVIDERS.includes(provider);
|
|
362
|
+
checks.push({ name: '服务商', ok: providerOk, detail: providerOk ? provider : '不支持 ' + provider + ';只支持 ' + CALENDAR_PROVIDERS.join(' / ') });
|
|
363
|
+
try {
|
|
364
|
+
const endpoint = buildCaldavUrl(config ?? {}, provider);
|
|
365
|
+
if (resolveAuthMethod(config) === 'oauth')
|
|
366
|
+
validateOAuthUrl(endpoint, 'caldavUrl');
|
|
367
|
+
checks.push({ name: '日历地址', ok: true, detail: '已解析为 ' + endpoint });
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
checks.push({ name: '日历地址', ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
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
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (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;仅检查配置,未联网验证' });
|
|
348
389
|
}
|
|
349
|
-
|
|
350
|
-
ok
|
|
351
|
-
checks.push({ name: '日历地址', ok: false, detail: '未配置:请在 profile 的 cordis.patch.yml 里给 calendar 行填 caldavUrl' });
|
|
390
|
+
catch (error) {
|
|
391
|
+
checks.push({ name: '认证凭据', ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
352
392
|
}
|
|
353
|
-
|
|
354
|
-
checks.push({ name: '账号', ok: hasUser, detail: hasUser ? '已配置' : '未配置:请填 username(Google/iCloud 为账号邮箱)' });
|
|
355
|
-
if (!hasUser)
|
|
356
|
-
ok = false;
|
|
357
|
-
const hasPass = typeof config?.password === 'string' && config.password.trim() !== '';
|
|
358
|
-
checks.push({ name: '密码', ok: hasPass, detail: hasPass ? '已配置' : '未配置:请填 password 或环境变量 DSH_CALENDAR_PASSWORD(Google/iCloud 用应用专用密码)' });
|
|
359
|
-
if (!hasPass)
|
|
360
|
-
ok = false;
|
|
361
|
-
return { ok, plugin: 'dsh-calendar', checks };
|
|
393
|
+
return { ok: checks.every((check) => check.ok === true), plugin: 'dsh-calendar', checks };
|
|
362
394
|
},
|
|
363
395
|
timeoutMs: TIMEOUT_MS,
|
|
364
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",
|