@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.
- package/CHANGELOG.md +35 -0
- package/README.md +6 -0
- package/commands/calendar/auth/index.js +7 -6
- package/commands/calendar/auth/index.ts +8 -6
- package/commands/calendar/logout/index.js +6 -5
- package/commands/calendar/logout/index.ts +6 -5
- package/commands/calendar/status/index.js +4 -3
- package/commands/calendar/status/index.ts +4 -3
- package/lib/caldav.d.ts.map +1 -1
- package/lib/caldav.js +4 -3
- package/lib/caldav.js.map +1 -1
- package/lib/chat-auth.d.ts +7 -5
- package/lib/chat-auth.d.ts.map +1 -1
- package/lib/chat-auth.js +48 -45
- package/lib/chat-auth.js.map +1 -1
- package/lib/config.d.ts +2 -2
- package/lib/config.js +9 -9
- package/lib/config.js.map +1 -1
- package/lib/credential-store.d.ts +3 -0
- package/lib/credential-store.d.ts.map +1 -1
- package/lib/credential-store.js.map +1 -1
- package/lib/errors.d.ts +72 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +32 -0
- package/lib/errors.js.map +1 -0
- package/lib/free-time.d.ts.map +1 -1
- package/lib/free-time.js +3 -2
- package/lib/free-time.js.map +1 -1
- package/lib/i18n.d.ts +2 -0
- package/lib/i18n.d.ts.map +1 -0
- package/lib/i18n.js +133 -0
- package/lib/i18n.js.map +1 -0
- package/lib/oauth-auth.d.ts.map +1 -1
- package/lib/oauth-auth.js +4 -3
- package/lib/oauth-auth.js.map +1 -1
- package/lib/oauth.d.ts.map +1 -1
- package/lib/oauth.js +7 -6
- package/lib/oauth.js.map +1 -1
- package/lib/time.d.ts.map +1 -1
- package/lib/time.js +5 -7
- package/lib/time.js.map +1 -1
- package/lib/token-manager.d.ts +4 -2
- package/lib/token-manager.d.ts.map +1 -1
- package/lib/token-manager.js +12 -9
- package/lib/token-manager.js.map +1 -1
- package/lib/tool-errors.d.ts +2 -1
- package/lib/tool-errors.d.ts.map +1 -1
- package/lib/tool-errors.js +8 -2
- package/lib/tool-errors.js.map +1 -1
- package/package.json +9 -9
- package/plugin.js +4 -2
- package/schema.json +45 -0
- package/skills/calendar-scheduling/tools/create_event/index.js +4 -3
- package/skills/calendar-scheduling/tools/create_event/index.ts +4 -3
- package/skills/calendar-scheduling/tools/find_free_time/index.js +4 -3
- package/skills/calendar-scheduling/tools/find_free_time/index.ts +4 -3
- package/skills/calendar-scheduling/tools/list_events/index.js +5 -4
- package/skills/calendar-scheduling/tools/list_events/index.ts +5 -4
- package/src/caldav.ts +4 -3
- package/src/chat-auth.ts +55 -50
- package/src/config.ts +9 -9
- package/src/credential-store.ts +3 -0
- package/src/errors.ts +34 -0
- package/src/free-time.ts +3 -2
- package/src/i18n.ts +133 -0
- package/src/oauth-auth.ts +4 -5
- package/src/oauth.ts +7 -10
- package/src/time.ts +5 -4
- package/src/token-manager.ts +16 -13
- 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 =
|
|
146
|
+
const displayName = t(locale, provider);
|
|
143
147
|
if (!this.#credentials) {
|
|
144
|
-
return
|
|
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
|
|
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
|
-
|
|
179
|
+
t(locale, 'openLink', { provider: displayName, minutes: validMinutes }),
|
|
176
180
|
authorizeUrl,
|
|
177
181
|
"",
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
208
|
+
t(locale, 'failed', { provider: displayName, detail }),
|
|
209
|
+
locale
|
|
204
210
|
);
|
|
205
|
-
return { statusCode: 400, message:
|
|
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
|
-
|
|
244
|
+
t(locale, 'failed', { provider: displayName, detail: reason }),
|
|
245
|
+
locale
|
|
239
246
|
);
|
|
240
|
-
return { statusCode: 502, message:
|
|
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
|
-
:
|
|
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
|
-
|
|
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 = [
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
307
|
+
return t(locale, 'databaseStatus', { provider: displayName, at: credential.authorized_at });
|
|
303
308
|
}
|
|
304
309
|
if (manager?.hasRefreshToken) {
|
|
305
|
-
return
|
|
310
|
+
return t(locale, 'envStatus', { provider: displayName });
|
|
306
311
|
}
|
|
307
|
-
return
|
|
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
|
|
341
|
+
return t(locale, 'googleClient');
|
|
337
342
|
}
|
|
338
|
-
return
|
|
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("")
|
package/src/credential-store.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
165
|
+
throw new CalendarError('duration', { value });
|
|
165
166
|
}
|
|
166
167
|
return total;
|
|
167
168
|
}
|