@zhin.js/plugin-calendar 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +6 -0
  3. package/commands/calendar/auth/index.js +7 -6
  4. package/commands/calendar/auth/index.ts +8 -6
  5. package/commands/calendar/logout/index.js +6 -5
  6. package/commands/calendar/logout/index.ts +6 -5
  7. package/commands/calendar/status/index.js +4 -3
  8. package/commands/calendar/status/index.ts +4 -3
  9. package/lib/caldav.d.ts.map +1 -1
  10. package/lib/caldav.js +4 -3
  11. package/lib/caldav.js.map +1 -1
  12. package/lib/chat-auth.d.ts +7 -5
  13. package/lib/chat-auth.d.ts.map +1 -1
  14. package/lib/chat-auth.js +48 -45
  15. package/lib/chat-auth.js.map +1 -1
  16. package/lib/config.d.ts +2 -2
  17. package/lib/config.js +9 -9
  18. package/lib/config.js.map +1 -1
  19. package/lib/credential-store.d.ts +3 -0
  20. package/lib/credential-store.d.ts.map +1 -1
  21. package/lib/credential-store.js.map +1 -1
  22. package/lib/errors.d.ts +72 -0
  23. package/lib/errors.d.ts.map +1 -0
  24. package/lib/errors.js +32 -0
  25. package/lib/errors.js.map +1 -0
  26. package/lib/free-time.d.ts.map +1 -1
  27. package/lib/free-time.js +3 -2
  28. package/lib/free-time.js.map +1 -1
  29. package/lib/i18n.d.ts +2 -0
  30. package/lib/i18n.d.ts.map +1 -0
  31. package/lib/i18n.js +133 -0
  32. package/lib/i18n.js.map +1 -0
  33. package/lib/oauth-auth.d.ts.map +1 -1
  34. package/lib/oauth-auth.js +4 -3
  35. package/lib/oauth-auth.js.map +1 -1
  36. package/lib/oauth.d.ts.map +1 -1
  37. package/lib/oauth.js +7 -6
  38. package/lib/oauth.js.map +1 -1
  39. package/lib/time.d.ts.map +1 -1
  40. package/lib/time.js +5 -7
  41. package/lib/time.js.map +1 -1
  42. package/lib/token-manager.d.ts +4 -2
  43. package/lib/token-manager.d.ts.map +1 -1
  44. package/lib/token-manager.js +12 -9
  45. package/lib/token-manager.js.map +1 -1
  46. package/lib/tool-errors.d.ts +2 -1
  47. package/lib/tool-errors.d.ts.map +1 -1
  48. package/lib/tool-errors.js +8 -2
  49. package/lib/tool-errors.js.map +1 -1
  50. package/package.json +9 -9
  51. package/plugin.js +4 -2
  52. package/schema.json +45 -0
  53. package/skills/calendar-scheduling/tools/create_event/index.js +4 -3
  54. package/skills/calendar-scheduling/tools/create_event/index.ts +4 -3
  55. package/skills/calendar-scheduling/tools/find_free_time/index.js +4 -3
  56. package/skills/calendar-scheduling/tools/find_free_time/index.ts +4 -3
  57. package/skills/calendar-scheduling/tools/list_events/index.js +5 -4
  58. package/skills/calendar-scheduling/tools/list_events/index.ts +5 -4
  59. package/src/caldav.ts +4 -3
  60. package/src/chat-auth.ts +55 -50
  61. package/src/config.ts +9 -9
  62. package/src/credential-store.ts +3 -0
  63. package/src/errors.ts +34 -0
  64. package/src/free-time.ts +3 -2
  65. package/src/i18n.ts +133 -0
  66. package/src/oauth-auth.ts +4 -5
  67. package/src/oauth.ts +7 -10
  68. package/src/time.ts +5 -4
  69. package/src/token-manager.ts +16 -13
  70. package/src/tool-errors.ts +8 -2
package/src/chat-auth.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { CalendarError } from './errors.js';
2
+ import { resolveLocale, type Locale } from 'zhin.js/i18n';
3
+ import { messages as t } from './i18n.js';
1
4
  /**
2
5
  * Chat-driven OAuth authorization: `calendar auth <provider>` creates a PKCE
3
6
  * link, the HTTP callback exchanges the code, stores the refresh token in the
@@ -31,11 +34,13 @@ export const OAUTH_PROVIDERS: readonly OAuthProviderKind[] = [
31
34
 
32
35
  export type CalendarNotifier<Target> = (
33
36
  target: Target,
34
- text: string
37
+ text: string,
38
+ locale: Locale
35
39
  ) => Promise<void>;
36
40
 
37
41
  export interface CalendarAuthServiceOptions<Target> {
38
42
  readonly config: CalendarConfig;
43
+ readonly locale?: Locale;
39
44
  readonly managers: Partial<Record<OAuthProviderKind, OAuthTokenManager>>;
40
45
  readonly credentials: CalendarCredentialStore | undefined;
41
46
  readonly notify: CalendarNotifier<Target> | undefined;
@@ -56,10 +61,6 @@ interface ClientCredentials {
56
61
  readonly tenant: string;
57
62
  }
58
63
 
59
- const PROVIDER_DISPLAY_NAME: Record<OAuthProviderKind, string> = {
60
- google: "Google 日历",
61
- microsoft: "Outlook 日历",
62
- };
63
64
 
64
65
  export function parseOAuthProvider(
65
66
  value: unknown
@@ -117,6 +118,7 @@ export function localOriginFromAddress(address: {
117
118
 
118
119
  export class CalendarAuthService<Target> {
119
120
  readonly #config: CalendarConfig;
121
+ readonly #locale: Locale;
120
122
  readonly #managers: Partial<Record<OAuthProviderKind, OAuthTokenManager>>;
121
123
  readonly #credentials: CalendarCredentialStore | undefined;
122
124
  readonly #notify: CalendarNotifier<Target> | undefined;
@@ -126,6 +128,7 @@ export class CalendarAuthService<Target> {
126
128
 
127
129
  constructor(options: CalendarAuthServiceOptions<Target>) {
128
130
  this.#config = options.config;
131
+ this.#locale = resolveLocale(options.locale);
129
132
  this.#managers = options.managers;
130
133
  this.#credentials = options.credentials;
131
134
  this.#notify = options.notify;
@@ -137,19 +140,20 @@ export class CalendarAuthService<Target> {
137
140
  /** Handles `calendar auth <provider>` and returns the chat reply. */
138
141
  async startAuthorization(
139
142
  provider: OAuthProviderKind,
140
- requester: CredentialOwner<Target>
143
+ requester: CredentialOwner<Target>,
144
+ locale: Locale = resolveLocale(requester.locale)
141
145
  ): Promise<string> {
142
- const displayName = PROVIDER_DISPLAY_NAME[provider];
146
+ const displayName = t(locale, provider);
143
147
  if (!this.#credentials) {
144
- return "日历授权需要数据库(databaseHostToken)来保存 refresh_token,当前不可用。";
148
+ return t(locale, 'needsDatabase');
145
149
  }
146
150
  const client = this.#clientCredentials(provider);
147
151
  if (!client) {
148
- return this.#missingClientMessage(provider);
152
+ return this.#missingClientMessage(provider, locale);
149
153
  }
150
154
  const redirectUri = this.#resolveRedirectUri();
151
155
  if (!redirectUri) {
152
- return "未找到 HTTP 服务地址:请启用 zhin 的 HTTP Host,或在 plugins.calendar.oauth_redirect_base_url 配置外部可访问地址。";
156
+ return t(locale, 'needsHttp');
153
157
  }
154
158
 
155
159
  const pkce = createPkcePair();
@@ -159,7 +163,7 @@ export class CalendarAuthService<Target> {
159
163
  state,
160
164
  codeVerifier: pkce.verifier,
161
165
  redirectUri,
162
- requester,
166
+ requester: { ...requester, locale },
163
167
  });
164
168
  const authorizeUrl = buildAuthorizeUrl({
165
169
  provider,
@@ -172,11 +176,11 @@ export class CalendarAuthService<Target> {
172
176
 
173
177
  const validMinutes = Math.round(OAUTH_SESSION_TTL_MS / 60_000);
174
178
  return [
175
- `请在浏览器打开下面的链接完成${displayName}授权(${validMinutes} 分钟内有效,只能使用一次):`,
179
+ t(locale, 'openLink', { provider: displayName, minutes: validMinutes }),
176
180
  authorizeUrl,
177
181
  "",
178
- `回调地址:${redirectUri}`,
179
- "该地址必须能从打开链接的浏览器访问,并已登记在 OAuth 客户端的重定向 URI 中。",
182
+ t(locale, 'callback', { url: redirectUri }),
183
+ t(locale, 'callbackHelp'),
180
184
  ].join("\n");
181
185
  }
182
186
 
@@ -188,21 +192,23 @@ export class CalendarAuthService<Target> {
188
192
  return {
189
193
  statusCode: 400,
190
194
  message:
191
- "授权链接无效、已使用或已过期,请在聊天中重新发送 calendar auth 命令。",
195
+ t(this.#locale, 'invalidLink'),
192
196
  };
193
197
  }
194
198
 
199
+ const locale = resolveLocale(session.requester.locale);
195
200
  const provider = session.provider;
196
- const displayName = PROVIDER_DISPLAY_NAME[provider];
201
+ const displayName = t(locale, provider);
197
202
  const oauthError = searchParams.get("error");
198
203
  if (oauthError) {
199
204
  const description = searchParams.get("error_description") ?? "";
200
205
  const detail = `${oauthError} ${description}`.trim();
201
206
  await this.#notifySafely(
202
207
  session.requester.target,
203
- `${displayName}授权失败:${detail}`
208
+ t(locale, 'failed', { provider: displayName, detail }),
209
+ locale
204
210
  );
205
- return { statusCode: 400, message: `授权失败:${detail}` };
211
+ return { statusCode: 400, message: t(locale, 'callbackFailed', { detail }) };
206
212
  }
207
213
 
208
214
  const code = searchParams.get("code") ?? "";
@@ -210,7 +216,7 @@ export class CalendarAuthService<Target> {
210
216
  if (!code || !client || !this.#credentials) {
211
217
  return {
212
218
  statusCode: 400,
213
- message: "缺少授权码或客户端配置,请重新授权。",
219
+ message: t(locale, 'codeMissing'),
214
220
  };
215
221
  }
216
222
 
@@ -232,79 +238,78 @@ export class CalendarAuthService<Target> {
232
238
  );
233
239
  this.#managers[provider]?.setRefreshToken(tokens.refreshToken);
234
240
  } catch (error) {
235
- const reason = error instanceof Error ? error.message : String(error);
241
+ const reason = error instanceof CalendarError ? error.format(locale) : error instanceof Error ? error.message : String(error);
236
242
  await this.#notifySafely(
237
243
  session.requester.target,
238
- `${displayName}授权失败:${reason}`
244
+ t(locale, 'failed', { provider: displayName, detail: reason }),
245
+ locale
239
246
  );
240
- return { statusCode: 502, message: `授权失败:${reason}` };
247
+ return { statusCode: 502, message: t(locale, 'callbackFailed', { detail: reason }) };
241
248
  }
242
249
 
243
250
  const providerHint =
244
251
  this.#config.provider === provider
245
252
  ? ""
246
- : `\n注意:当前 plugins.calendar.provider 是 ${
247
- this.#config.provider
248
- },日历工具不会使用 ${provider}。`;
253
+ : t(locale, 'otherProvider', { current: this.#config.provider, provider });
249
254
  await this.#notifySafely(
250
255
  session.requester.target,
251
- `${displayName}授权成功,refresh_token 已保存到插件数据库。${providerHint}`
256
+ t(locale, 'authorized', { provider: displayName, hint: providerHint }),
257
+ locale
252
258
  );
253
- return { statusCode: 200, message: "授权成功,可以关闭此页回到聊天。" };
259
+ return { statusCode: 200, message: t(locale, 'callbackSuccess') };
254
260
  }
255
261
 
256
262
  /** Handles `calendar status`. */
257
- async describeStatus(): Promise<string> {
258
- const lines = [`当前 provider:${this.#config.provider}`];
263
+ async describeStatus(locale?: Locale, authCommand?: string): Promise<string> {
264
+ const lines = [t(locale, 'currentProvider', { provider: this.#config.provider })];
259
265
  for (const provider of OAUTH_PROVIDERS) {
260
- lines.push(await this.#describeProvider(provider));
266
+ lines.push(await this.#describeProvider(provider, locale, authCommand));
261
267
  }
262
268
  return lines.join("\n");
263
269
  }
264
270
 
265
271
  /** Handles `calendar logout <provider>`: removes the stored token. */
266
- async logout(provider: OAuthProviderKind): Promise<string> {
267
- const displayName = PROVIDER_DISPLAY_NAME[provider];
272
+ async logout(provider: OAuthProviderKind, locale?: Locale): Promise<string> {
273
+ const displayName = t(locale, provider);
268
274
  if (!this.#credentials) {
269
- return "数据库不可用,没有可删除的授权。";
275
+ return t(locale, 'noDatabase');
270
276
  }
271
277
  await this.#credentials.delete(provider);
272
278
  this.#managers[provider]?.clearTokens();
273
- return `已删除${displayName}在插件数据库中的授权。若 .env 里仍有 refresh_token,重载配置后会继续作为后备使用。`;
279
+ return t(locale, 'loggedOut', { provider: displayName });
274
280
  }
275
281
 
276
282
  /** Called by the token manager once when the refresh token is rejected. */
277
283
  async notifyAuthorizationLost(
278
284
  provider: OAuthProviderKind,
279
- message: string
285
+ _message: string
280
286
  ): Promise<void> {
281
287
  const credential = await this.#credentials?.get(provider);
282
288
  const owner = credential?.authorized_by as CredentialOwner<Target> | null;
283
289
  if (!owner) {
284
290
  return;
285
291
  }
286
- await this.#notifySafely(owner.target, message);
292
+ const locale = resolveLocale(owner.locale);
293
+ await this.#notifySafely(owner.target, t(locale, 'lost', { provider: t(locale, provider) }), locale);
287
294
  }
288
295
 
289
- async #describeProvider(provider: OAuthProviderKind): Promise<string> {
290
- const displayName = PROVIDER_DISPLAY_NAME[provider];
296
+ async #describeProvider(provider: OAuthProviderKind, locale?: Locale, authCommand?: string): Promise<string> {
297
+ const displayName = t(locale, provider);
291
298
  if (!this.#clientCredentials(provider)) {
292
- return `${displayName}:未配置 client_id`;
299
+ return t(locale, 'clientMissing', { provider: displayName });
293
300
  }
294
301
  const credential = await this.#credentials?.get(provider);
295
302
  const manager = this.#managers[provider];
296
303
  if (manager?.authorizationLost) {
297
- return `${displayName}:授权已失效,请发送「${authCommandFor(
298
- provider
299
- )}」`;
304
+ return [t(locale, 'lost', { provider: displayName }), authCommand ? t(locale, 'commandHint', { command: authCommandFor(provider, authCommand)! }) : ''].filter(Boolean).join(' ');
300
305
  }
301
306
  if (credential) {
302
- return `${displayName}:已授权(数据库,${credential.authorized_at})`;
307
+ return t(locale, 'databaseStatus', { provider: displayName, at: credential.authorized_at });
303
308
  }
304
309
  if (manager?.hasRefreshToken) {
305
- return `${displayName}:使用配置/环境变量中的 refresh_token`;
310
+ return t(locale, 'envStatus', { provider: displayName });
306
311
  }
307
- return `${displayName}:未授权,请发送「${authCommandFor(provider)}」`;
312
+ return [t(locale, 'notAuthorized', { provider: displayName }), authCommand ? t(locale, 'commandHint', { command: authCommandFor(provider, authCommand)! }) : ''].filter(Boolean).join(' ');
308
313
  }
309
314
 
310
315
  #clientCredentials(
@@ -331,19 +336,19 @@ export class CalendarAuthService<Target> {
331
336
  };
332
337
  }
333
338
 
334
- #missingClientMessage(provider: OAuthProviderKind): string {
339
+ #missingClientMessage(provider: OAuthProviderKind, locale?: Locale): string {
335
340
  if (provider === "google") {
336
- return "请先在 plugins.calendar.google 配置 client_id 和 client_secret(例如 ${GOOGLE_CALENDAR_CLIENT_ID} / ${GOOGLE_CALENDAR_CLIENT_SECRET})。";
341
+ return t(locale, 'googleClient');
337
342
  }
338
- return "请先在 plugins.calendar.microsoft 配置 client_id(例如 ${MS_GRAPH_CLIENT_ID})。";
343
+ return t(locale, 'microsoftClient');
339
344
  }
340
345
 
341
- async #notifySafely(target: Target, text: string): Promise<void> {
346
+ async #notifySafely(target: Target, text: string, locale: Locale): Promise<void> {
342
347
  if (!this.#notify) {
343
348
  return;
344
349
  }
345
350
  try {
346
- await this.#notify(target, text);
351
+ await this.#notify(target, text, locale);
347
352
  } catch (error) {
348
353
  const reason = error instanceof Error ? error.message : String(error);
349
354
  console.warn(`[calendar] 发送授权通知失败:${reason}`);
package/src/config.ts CHANGED
@@ -8,11 +8,11 @@ export const calendarConfig = Schema.object({
8
8
  Schema.const("microsoft"),
9
9
  ])
10
10
  .default("none")
11
- .description("日历后端;none 时工具返回未启用提示")
11
+ .description("日历后端;none 时工具返回未启用提示").i18n({"en": {"description": "Calendar backend. Tools report that the calendar is disabled when set to none."}})
12
12
  .effect("reload"),
13
13
  timezone: Schema.string()
14
14
  .default("Asia/Manila")
15
- .description("解释无偏移时间与展示用的 IANA 时区")
15
+ .description("解释无偏移时间与展示用的 IANA 时区").i18n({"en": {"description": "IANA timezone for interpreting times without offsets and for display."}})
16
16
  .effect("reload"),
17
17
  work_day_start: Schema.string().default("09:00").effect("reload"),
18
18
  work_day_end: Schema.string().default("18:00").effect("reload"),
@@ -32,7 +32,7 @@ export const calendarConfig = Schema.object({
32
32
  enabled: Schema.boolean().default(false),
33
33
  calendar_url: Schema.string()
34
34
  .default("")
35
- .description("日历 collection URL")
35
+ .description("日历 collection URL").i18n({"en": {"description": "Calendar collection URL."}})
36
36
  .effect("reload"),
37
37
  username: Schema.string().default("").effect("reload"),
38
38
  password: Schema.string().default("").effect("reload"),
@@ -49,11 +49,11 @@ export const calendarConfig = Schema.object({
49
49
  .effect("reload"),
50
50
  refresh_token: Schema.string()
51
51
  .default("")
52
- .description("${GOOGLE_CALENDAR_REFRESH_TOKEN};用 auth google 获取")
52
+ .description("${GOOGLE_CALENDAR_REFRESH_TOKEN};用 auth google 获取").i18n({"en": {"description": "${GOOGLE_CALENDAR_REFRESH_TOKEN}; obtain using the calendar authorization flow."}})
53
53
  .effect("reload"),
54
54
  calendar_id: Schema.string()
55
55
  .default("primary")
56
- .description("日历 ID,默认 primary;可换成其它日历的 id")
56
+ .description("日历 ID,默认 primary;可换成其它日历的 id").i18n({"en": {"description": "Calendar ID, primary by default; can be set to another calendar ID."}})
57
57
  .effect("reload"),
58
58
  token_file: Schema.string()
59
59
  .default("")
@@ -70,19 +70,19 @@ export const calendarConfig = Schema.object({
70
70
  .effect("reload"),
71
71
  client_secret: Schema.string()
72
72
  .default("")
73
- .description("可选;公共客户端可留空。${MS_GRAPH_CLIENT_SECRET}")
73
+ .description("可选;公共客户端可留空。${MS_GRAPH_CLIENT_SECRET}").i18n({"en": {"description": "Optional for public clients. ${MS_GRAPH_CLIENT_SECRET}"}})
74
74
  .effect("reload"),
75
75
  refresh_token: Schema.string()
76
76
  .default("")
77
- .description("${MS_GRAPH_REFRESH_TOKEN};用 auth microsoft 获取")
77
+ .description("${MS_GRAPH_REFRESH_TOKEN};用 auth microsoft 获取").i18n({"en": {"description": "${MS_GRAPH_REFRESH_TOKEN}; obtain using the calendar authorization flow."}})
78
78
  .effect("reload"),
79
79
  tenant: Schema.string()
80
80
  .default("common")
81
- .description("Azure tenant,默认 common")
81
+ .description("Azure tenant,默认 common").i18n({"en": {"description": "Azure tenant, common by default."}})
82
82
  .effect("reload"),
83
83
  calendar_id: Schema.string()
84
84
  .default("")
85
- .description("留空或 primary/me = 默认日历;否则为 Graph calendar id")
85
+ .description("留空或 primary/me = 默认日历;否则为 Graph calendar id").i18n({"en": {"description": "Empty, primary or me selects the default calendar; otherwise a Graph calendar ID."}})
86
86
  .effect("reload"),
87
87
  token_file: Schema.string()
88
88
  .default("")
@@ -1,3 +1,4 @@
1
+ import type { Locale } from 'zhin.js/i18n';
1
2
  /**
2
3
  * Persists OAuth refresh tokens in the plugin's database state (never in .env).
3
4
  */
@@ -13,6 +14,8 @@ export interface CalendarStateStore {
13
14
  /** Who completed the chat authorization and where to notify them later. */
14
15
  export interface CredentialOwner<Target = unknown> {
15
16
  readonly sender_id: string;
17
+ /** Fixed when authorization starts; token rotation preserves it. */
18
+ readonly locale?: Locale;
16
19
  readonly target: Target;
17
20
  }
18
21
 
package/src/errors.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { createMessages, type Locale } from 'zhin.js/i18n';
2
+
3
+ const catalog = {
4
+ timezone: { 'zh-CN': '未知时区:{value}', en: 'Unknown timezone: {value}' },
5
+ datetime: { 'zh-CN': '无法解析时间:{value}', en: 'Could not parse datetime: {value}' },
6
+ datetimeFormat: { 'zh-CN': '无法解析时间:{value}(请用 YYYY-MM-DDTHH:mm 格式)', en: 'Could not parse datetime: {value} (use YYYY-MM-DDTHH:mm)' },
7
+ duration: { 'zh-CN': '无法解析时长:{value}(示例:10m、2h30m、1d)', en: 'Could not parse duration: {value} (examples: 10m, 2h30m, 1d)' },
8
+ workTime: { 'zh-CN': '工作时间格式无效:{value}(示例:09:00)', en: 'Invalid work time: {value} (example: 09:00)' },
9
+ positiveDuration: { 'zh-CN': '时长必须大于 0 分钟', en: 'Duration must be greater than 0 minutes' },
10
+ listHttp: { 'zh-CN': '{provider} 列表失败:HTTP {status} {detail}', en: '{provider} could not list events: HTTP {status} {detail}' },
11
+ createHttp: { 'zh-CN': '{provider} 创建失败:HTTP {status}', en: '{provider} could not create the event: HTTP {status}' },
12
+ caldavHttp: { 'zh-CN': 'CalDAV {method} 失败:HTTP {status}', en: 'CalDAV {method} failed: HTTP {status}' },
13
+ exchange: { 'zh-CN': '{provider}: 换取 token 失败:{detail}', en: '{provider}: token exchange failed: {detail}' },
14
+ refreshMissing: { 'zh-CN': '{provider}: 响应没有 refresh_token。Google 请确认 access_type=offline&prompt=consent;Microsoft 请确认已请求 offline_access。', en: '{provider}: the response has no refresh_token. For Google, check access_type=offline&prompt=consent; for Microsoft, request offline_access.' },
15
+ accessMissing: { 'zh-CN': '{provider}: 响应缺少 access_token', en: '{provider}: the response has no access_token' },
16
+ clientMissing: { 'zh-CN': '{provider}: 缺少 {field}', en: '{provider}: missing {field}' },
17
+ refreshHttp: { 'zh-CN': '{provider}: token 刷新失败 HTTP {status}:{detail}', en: '{provider}: token refresh failed, HTTP {status}: {detail}' },
18
+ refreshAccessMissing: { 'zh-CN': '{provider}: 刷新响应缺少 access_token', en: '{provider}: the refresh response has no access_token' },
19
+ } as const;
20
+ const messages = createMessages(catalog);
21
+
22
+ /** Calendar-owned failures retain their code and raw parameters until presentation. */
23
+ export class CalendarError extends Error {
24
+ readonly code: keyof typeof catalog;
25
+ readonly params: Readonly<Record<string, string | number>>;
26
+
27
+ constructor(code: keyof typeof catalog, params: Readonly<Record<string, string | number>> = {}, locale?: Locale) {
28
+ super(messages(locale, code, params));
29
+ this.name = 'CalendarError';
30
+ this.code = code;
31
+ this.params = params;
32
+ }
33
+ format(locale?: Locale): string { return messages(locale, this.code, this.params); }
34
+ }
package/src/free-time.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CalendarError } from './errors.js';
1
2
  /**
2
3
  * Finds free slots inside work hours between `from` and `to`, given busy events.
3
4
  */
@@ -21,7 +22,7 @@ export interface FreeTimeOptions {
21
22
 
22
23
  function parseHm(value: string): { hour: number; minute: number } {
23
24
  const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
24
- if (!match) throw new Error(`工作时间格式无效:${value}(示例:09:00)`);
25
+ if (!match) throw new CalendarError('workTime', { value });
25
26
  return { hour: Number(match[1]), minute: Number(match[2]) };
26
27
  }
27
28
 
@@ -45,7 +46,7 @@ export function findFreeSlots(options: FreeTimeOptions): FreeSlot[] {
45
46
  const startHm = parseHm(options.workDayStart);
46
47
  const endHm = parseHm(options.workDayEnd);
47
48
  const durationMs = options.durationMinutes * 60000;
48
- if (durationMs <= 0) throw new Error("时长必须大于 0 分钟");
49
+ if (durationMs <= 0) throw new CalendarError('positiveDuration');
49
50
 
50
51
  const busy = options.events
51
52
  .map((event) => ({
package/src/i18n.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { createMessages } from 'zhin.js/i18n';
2
+
3
+ export const messages = createMessages({
4
+ untitled: { 'zh-CN': '(无标题)', en: '(Untitled)' },
5
+ "google": {
6
+ "zh-CN": "Google 日历",
7
+ "en": "Google Calendar"
8
+ },
9
+ "microsoft": {
10
+ "zh-CN": "Outlook 日历",
11
+ "en": "Outlook Calendar"
12
+ },
13
+ "providerInput": {
14
+ "zh-CN": "请选择 google 或 microsoft(outlook)",
15
+ "en": "Choose google or microsoft (outlook)."
16
+ },
17
+ "usage": {
18
+ "zh-CN": "用法:{command} google | {command} microsoft",
19
+ "en": "Usage: {command} google | {command} microsoft"
20
+ },
21
+ "chatRequired": {
22
+ "zh-CN": "请在私聊或群聊中发送该命令",
23
+ "en": "Send this command in a private or group conversation."
24
+ },
25
+ "needsDatabase": {
26
+ "zh-CN": "日历授权需要数据库(databaseHostToken)来保存 refresh_token,当前不可用。",
27
+ "en": "Calendar authorization needs a database (databaseHostToken) to store refresh_token; it is currently unavailable."
28
+ },
29
+ "needsHttp": {
30
+ "zh-CN": "未找到 HTTP 服务地址:请启用 zhin 的 HTTP Host,或在 plugins.calendar.oauth_redirect_base_url 配置外部可访问地址。",
31
+ "en": "No HTTP service address was found. Enable the Zhin HTTP Host or configure a publicly reachable oauth_redirect_base_url for this calendar instance."
32
+ },
33
+ "openLink": {
34
+ "zh-CN": "请在浏览器打开下面的链接完成{provider}授权({minutes} 分钟内有效,只能使用一次):",
35
+ "en": "Open the link below in your browser to authorize {provider}. It expires in {minutes} minutes and can be used once:"
36
+ },
37
+ "callback": {
38
+ "zh-CN": "回调地址:{url}",
39
+ "en": "Callback URL: {url}"
40
+ },
41
+ "callbackHelp": {
42
+ "zh-CN": "该地址必须能从打开链接的浏览器访问,并已登记在 OAuth 客户端的重定向 URI 中。",
43
+ "en": "This URL must be reachable from your browser and registered as a redirect URI in the OAuth client."
44
+ },
45
+ "invalidLink": {
46
+ "zh-CN": "授权链接无效、已使用或已过期,请回到聊天重新发起日历授权。",
47
+ "en": "This authorization link is invalid, already used or expired. Start calendar authorization again from chat."
48
+ },
49
+ "failed": {
50
+ "zh-CN": "{provider}授权失败:{detail}",
51
+ "en": "{provider} authorization failed: {detail}"
52
+ },
53
+ "callbackFailed": {
54
+ "zh-CN": "授权失败:{detail}",
55
+ "en": "Authorization failed: {detail}"
56
+ },
57
+ "codeMissing": {
58
+ "zh-CN": "缺少授权码或客户端配置,请重新授权。",
59
+ "en": "The authorization code or client configuration is missing. Start authorization again."
60
+ },
61
+ "otherProvider": {
62
+ "zh-CN": "\n注意:当前 plugins.calendar.provider 是 {current},日历工具不会使用 {provider}。",
63
+ "en": "\nNote: the current calendar provider is {current}; calendar tools will not use {provider}."
64
+ },
65
+ "authorized": {
66
+ "zh-CN": "{provider}授权成功,refresh_token 已保存到插件数据库。{hint}",
67
+ "en": "{provider} authorization succeeded. The refresh token was saved in the plugin database.{hint}"
68
+ },
69
+ "callbackSuccess": {
70
+ "zh-CN": "授权成功,可以关闭此页回到聊天。",
71
+ "en": "Authorization succeeded. You can close this page and return to chat."
72
+ },
73
+ "currentProvider": {
74
+ "zh-CN": "当前 provider:{provider}",
75
+ "en": "Current provider: {provider}"
76
+ },
77
+ "noDatabase": {
78
+ "zh-CN": "数据库不可用,没有可删除的授权。",
79
+ "en": "The database is unavailable; there is no stored authorization to remove."
80
+ },
81
+ "loggedOut": {
82
+ "zh-CN": "已删除{provider}在插件数据库中的授权。若 .env 里仍有 refresh_token,重载配置后会继续作为后备使用。",
83
+ "en": "Removed the {provider} authorization from the plugin database. A refresh token still configured in .env remains a fallback after configuration reload."
84
+ },
85
+ "lost": {
86
+ "zh-CN": "{provider}:授权已失效,请通过日历授权入口重新授权。",
87
+ "en": "{provider}: authorization expired or was revoked. Use the calendar authorization entry to authorize again."
88
+ },
89
+ "notAuthorized": {
90
+ "zh-CN": "{provider}:尚未授权,请通过日历授权入口完成授权。",
91
+ "en": "{provider}: not authorized. Use the calendar authorization entry to authorize."
92
+ },
93
+ "commandHint": {
94
+ "zh-CN": "请发送「{command}」。",
95
+ "en": "Send \"{command}\"."
96
+ },
97
+ "clientMissing": {
98
+ "zh-CN": "{provider}:未配置 client_id",
99
+ "en": "{provider}: client_id is not configured"
100
+ },
101
+ "databaseStatus": {
102
+ "zh-CN": "{provider}:已授权(数据库,{at})",
103
+ "en": "{provider}: authorized (database, {at})"
104
+ },
105
+ "envStatus": {
106
+ "zh-CN": "{provider}:使用配置/环境变量中的 refresh_token",
107
+ "en": "{provider}: using refresh_token from configuration or environment variables"
108
+ },
109
+ "googleClient": {
110
+ "zh-CN": "请先在 plugins.calendar.google 配置 client_id 和 client_secret(例如 GOOGLE_CALENDAR_CLIENT_ID / GOOGLE_CALENDAR_CLIENT_SECRET 环境变量)。",
111
+ "en": "Configure client_id and client_secret for the calendar Google client first, for example using the GOOGLE_CALENDAR_CLIENT_ID and GOOGLE_CALENDAR_CLIENT_SECRET environment variables."
112
+ },
113
+ "microsoftClient": {
114
+ "zh-CN": "请先在 plugins.calendar.microsoft 配置 client_id(例如 MS_GRAPH_CLIENT_ID 环境变量)。",
115
+ "en": "Configure client_id for the calendar Microsoft client first, for example using the MS_GRAPH_CLIENT_ID environment variable."
116
+ },
117
+ "disabled": {
118
+ "zh-CN": "日历未启用:请先配置 provider 与凭据",
119
+ "en": "Calendar is disabled. Configure a provider and credentials first."
120
+ },
121
+ "noEvents": {
122
+ "zh-CN": "({provider}){from} ~ {to} 没有事件",
123
+ "en": "({provider}) No events from {from} to {to}."
124
+ },
125
+ "noFreeTime": {
126
+ "zh-CN": "在工作时段({start}-{end} {timezone})没有足够的空闲",
127
+ "en": "No sufficiently long free slot during work hours ({start}–{end} {timezone})."
128
+ },
129
+ "eventCreated": {
130
+ "zh-CN": "已创建事件 [{id}]\n{title}\n{start} → {end}({timezone})",
131
+ "en": "Event created [{id}]\n{title}\n{start} → {end} ({timezone})"
132
+ }
133
+ });
package/src/oauth-auth.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CalendarError } from './errors.js';
1
2
  /**
2
3
  * OAuth authorization-code + PKCE helpers for the one-time auth CLI.
3
4
  */
@@ -126,17 +127,15 @@ export async function exchangeAuthorizationCode(
126
127
  const desc = String(
127
128
  data.error_description ?? data.error ?? response.statusText
128
129
  );
129
- throw new Error(`${options.provider}: 换取 token 失败:${desc}`);
130
+ throw new CalendarError('exchange', { provider: options.provider, detail: desc });
130
131
  }
131
132
  const refreshToken = String(data.refresh_token ?? "");
132
133
  const accessToken = String(data.access_token ?? "");
133
134
  if (!refreshToken) {
134
- throw new Error(
135
- `${options.provider}: 响应没有 refresh_token。Google 请确认 access_type=offline&prompt=consent;Microsoft 请确认已请求 offline_access。`
136
- );
135
+ throw new CalendarError('refreshMissing', { provider: options.provider });
137
136
  }
138
137
  if (!accessToken)
139
- throw new Error(`${options.provider}: 响应缺少 access_token`);
138
+ throw new CalendarError('accessMissing', { provider: options.provider });
140
139
  return {
141
140
  accessToken,
142
141
  refreshToken,
package/src/oauth.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CalendarError } from './errors.js';
1
2
  /**
2
3
  * Google Calendar and Microsoft Graph providers backed by OAuthTokenManager
3
4
  * (refresh_token flow; access tokens are never stored in config).
@@ -43,7 +44,7 @@ function mapGoogleEvent(
43
44
  const endDate = new Date(end.dateTime ?? end.date ?? "");
44
45
  return {
45
46
  id: String(item.id ?? ""),
46
- title: String(item.summary ?? "(无标题)"),
47
+ title: String(item.summary ?? ""),
47
48
  start: formatInZone(startDate, timezone),
48
49
  end: formatInZone(endDate, timezone),
49
50
  location: item.location ? String(item.location) : undefined,
@@ -68,7 +69,7 @@ function mapMicrosoftEvent(
68
69
  );
69
70
  return {
70
71
  id: String(item.id ?? ""),
71
- title: String(item.subject ?? "(无标题)"),
72
+ title: String(item.subject ?? ""),
72
73
  start: formatInZone(startDate, timezone),
73
74
  end: formatInZone(endDate, timezone),
74
75
  location: item.location
@@ -144,9 +145,7 @@ export function createGoogleCalendarProvider(
144
145
  });
145
146
  if (!response.ok) {
146
147
  const detail = (await response.text().catch(() => "")).slice(0, 300);
147
- throw new Error(
148
- `Google Calendar 列表失败:HTTP ${response.status} ${detail}`
149
- );
148
+ throw new CalendarError('listHttp', { provider: 'Google Calendar', status: response.status, detail });
150
149
  }
151
150
  const data = (await response.json()) as {
152
151
  items?: Record<string, unknown>[];
@@ -180,7 +179,7 @@ export function createGoogleCalendarProvider(
180
179
  }
181
180
  );
182
181
  if (!response.ok)
183
- throw new Error(`Google Calendar 创建失败:HTTP ${response.status}`);
182
+ throw new CalendarError('createHttp', { provider: 'Google Calendar', status: response.status });
184
183
  return mapGoogleEvent(
185
184
  (await response.json()) as Record<string, unknown>,
186
185
  options.timezone
@@ -246,9 +245,7 @@ export function createMicrosoftCalendarProvider(
246
245
  );
247
246
  if (!response.ok) {
248
247
  const detail = (await response.text().catch(() => "")).slice(0, 300);
249
- throw new Error(
250
- `Microsoft Graph 列表失败:HTTP ${response.status} ${detail}`
251
- );
248
+ throw new CalendarError('listHttp', { provider: 'Microsoft Graph', status: response.status, detail });
252
249
  }
253
250
  const data = (await response.json()) as {
254
251
  value?: Record<string, unknown>[];
@@ -288,7 +285,7 @@ export function createMicrosoftCalendarProvider(
288
285
  { prefer: `outlook.timezone="${options.timezone}"` }
289
286
  );
290
287
  if (!response.ok)
291
- throw new Error(`Microsoft Graph 创建失败:HTTP ${response.status}`);
288
+ throw new CalendarError('createHttp', { provider: 'Microsoft Graph', status: response.status });
292
289
  return mapMicrosoftEvent(
293
290
  (await response.json()) as Record<string, unknown>,
294
291
  options.timezone
package/src/time.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { CalendarError } from './errors.js';
1
2
  /**
2
3
  * Timezone helpers built on Intl (no dependencies).
3
4
  */
@@ -48,7 +49,7 @@ export function assertTimeZone(timeZone: string): void {
48
49
  try {
49
50
  formatterFor(timeZone);
50
51
  } catch {
51
- throw new Error(`未知时区:${timeZone}`);
52
+ throw new CalendarError('timezone', { value: timeZone });
52
53
  }
53
54
  }
54
55
 
@@ -124,13 +125,13 @@ export function parseDateTimeInZone(value: string, timeZone: string): Date {
124
125
  const text = value.trim();
125
126
  if (/([zZ]|[+-]\d{2}:?\d{2})$/.test(text)) {
126
127
  const date = new Date(text);
127
- if (Number.isNaN(date.getTime())) throw new Error(`无法解析时间:${value}`);
128
+ if (Number.isNaN(date.getTime())) throw new CalendarError('datetime', { value });
128
129
  return date;
129
130
  }
130
131
  const match =
131
132
  /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(text);
132
133
  if (!match)
133
- throw new Error(`无法解析时间:${value}(请用 YYYY-MM-DDTHH:mm 格式)`);
134
+ throw new CalendarError('datetimeFormat', { value });
134
135
  return zonedTimeToUtc(
135
136
  {
136
137
  year: Number(match[1]),
@@ -161,7 +162,7 @@ export function parseDuration(value: string): number {
161
162
  consumed += match[0];
162
163
  }
163
164
  if (total <= 0 || consumed.replace(/\s/g, "") !== text.replace(/\s/g, "")) {
164
- throw new Error(`无法解析时长:${value}(示例:10m、2h30m、1d)`);
165
+ throw new CalendarError('duration', { value });
165
166
  }
166
167
  return total;
167
168
  }