@zhin.js/plugin-calendar 0.0.0-stage → 0.1.2
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 +14 -0
- package/README.md +189 -2
- package/commands/calendar/auth/index.js +26 -0
- package/commands/calendar/auth/index.ts +29 -0
- package/commands/calendar/logout/index.js +18 -0
- package/commands/calendar/logout/index.ts +18 -0
- package/commands/calendar/status/index.js +10 -0
- package/commands/calendar/status/index.ts +10 -0
- package/lib/caldav.d.ts +18 -0
- package/lib/caldav.d.ts.map +1 -0
- package/lib/caldav.js +232 -0
- package/lib/caldav.js.map +1 -0
- package/lib/chat-auth.d.ts +49 -0
- package/lib/chat-auth.d.ts.map +1 -0
- package/lib/chat-auth.js +247 -0
- package/lib/chat-auth.js.map +1 -0
- package/lib/cli/auth.d.ts +3 -0
- package/lib/cli/auth.d.ts.map +1 -0
- package/lib/cli/auth.js +182 -0
- package/lib/cli/auth.js.map +1 -0
- package/lib/config.d.ts +87 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +95 -0
- package/lib/config.js.map +1 -0
- package/lib/credential-store.d.ts +30 -0
- package/lib/credential-store.d.ts.map +1 -0
- package/lib/credential-store.js +42 -0
- package/lib/credential-store.js.map +1 -0
- package/lib/free-time.d.ts +13 -0
- package/lib/free-time.d.ts.map +1 -0
- package/lib/free-time.js +96 -0
- package/lib/free-time.js.map +1 -0
- package/lib/http-callback.d.ts +13 -0
- package/lib/http-callback.d.ts.map +1 -0
- package/lib/http-callback.js +26 -0
- package/lib/http-callback.js.map +1 -0
- package/lib/index.d.ts +32 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +122 -0
- package/lib/index.js.map +1 -0
- package/lib/oauth-auth.d.ts +37 -0
- package/lib/oauth-auth.d.ts.map +1 -0
- package/lib/oauth-auth.js +91 -0
- package/lib/oauth-auth.js.map +1 -0
- package/lib/oauth-session.d.ts +27 -0
- package/lib/oauth-session.d.ts.map +1 -0
- package/lib/oauth-session.js +43 -0
- package/lib/oauth-session.js.map +1 -0
- package/lib/oauth.d.ts +28 -0
- package/lib/oauth.d.ts.map +1 -0
- package/lib/oauth.js +200 -0
- package/lib/oauth.js.map +1 -0
- package/lib/runtime.d.ts +11 -0
- package/lib/runtime.d.ts.map +1 -0
- package/lib/runtime.js +3 -0
- package/lib/runtime.js.map +1 -0
- package/lib/secrets.d.ts +3 -0
- package/lib/secrets.d.ts.map +1 -0
- package/lib/secrets.js +6 -0
- package/lib/secrets.js.map +1 -0
- package/lib/time.d.ts +37 -0
- package/lib/time.d.ts.map +1 -0
- package/lib/time.js +118 -0
- package/lib/time.js.map +1 -0
- package/lib/token-manager.d.ts +71 -0
- package/lib/token-manager.d.ts.map +1 -0
- package/lib/token-manager.js +239 -0
- package/lib/token-manager.js.map +1 -0
- package/lib/tool-errors.d.ts +6 -0
- package/lib/tool-errors.d.ts.map +1 -0
- package/lib/tool-errors.js +17 -0
- package/lib/tool-errors.js.map +1 -0
- package/lib/types.d.ts +28 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/package.json +104 -3
- package/plugin.js +71 -0
- package/skills/calendar-scheduling/SKILL.md +38 -0
- package/skills/calendar-scheduling/tools/create_event/index.js +38 -0
- package/skills/calendar-scheduling/tools/create_event/index.ts +44 -0
- package/skills/calendar-scheduling/tools/find_free_time/index.js +46 -0
- package/skills/calendar-scheduling/tools/find_free_time/index.ts +50 -0
- package/skills/calendar-scheduling/tools/list_events/index.js +32 -0
- package/skills/calendar-scheduling/tools/list_events/index.ts +42 -0
- package/src/caldav.ts +263 -0
- package/src/chat-auth.ts +352 -0
- package/src/cli/auth.ts +235 -0
- package/src/config.ts +108 -0
- package/src/credential-store.ts +79 -0
- package/src/free-time.ts +128 -0
- package/src/http-callback.ts +55 -0
- package/src/index.ts +193 -0
- package/src/oauth-auth.ts +145 -0
- package/src/oauth-session.ts +65 -0
- package/src/oauth.ts +298 -0
- package/src/runtime.ts +16 -0
- package/src/secrets.ts +5 -0
- package/src/time.ts +167 -0
- package/src/token-manager.ts +358 -0
- package/src/tool-errors.ts +18 -0
- package/src/types.ts +37 -0
package/src/chat-auth.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat-driven OAuth authorization: `calendar auth <provider>` creates a PKCE
|
|
3
|
+
* link, the HTTP callback exchanges the code, stores the refresh token in the
|
|
4
|
+
* plugin database and notifies the conversation that requested it.
|
|
5
|
+
*/
|
|
6
|
+
import type { CalendarConfig } from "./config.js";
|
|
7
|
+
import type {
|
|
8
|
+
CalendarCredentialStore,
|
|
9
|
+
CredentialOwner,
|
|
10
|
+
} from "./credential-store.js";
|
|
11
|
+
import {
|
|
12
|
+
buildAuthorizeUrl,
|
|
13
|
+
createOAuthState,
|
|
14
|
+
createPkcePair,
|
|
15
|
+
exchangeAuthorizationCode,
|
|
16
|
+
} from "./oauth-auth.js";
|
|
17
|
+
import { OAuthSessionRegistry, OAUTH_SESSION_TTL_MS } from "./oauth-session.js";
|
|
18
|
+
import { cleanSecret } from "./secrets.js";
|
|
19
|
+
import {
|
|
20
|
+
authCommandFor,
|
|
21
|
+
type OAuthProviderKind,
|
|
22
|
+
type OAuthTokenManager,
|
|
23
|
+
} from "./token-manager.js";
|
|
24
|
+
|
|
25
|
+
export const DEFAULT_OAUTH_CALLBACK_PATH = "/calendar/oauth/callback";
|
|
26
|
+
|
|
27
|
+
export const OAUTH_PROVIDERS: readonly OAuthProviderKind[] = [
|
|
28
|
+
"google",
|
|
29
|
+
"microsoft",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
export type CalendarNotifier<Target> = (
|
|
33
|
+
target: Target,
|
|
34
|
+
text: string
|
|
35
|
+
) => Promise<void>;
|
|
36
|
+
|
|
37
|
+
export interface CalendarAuthServiceOptions<Target> {
|
|
38
|
+
readonly config: CalendarConfig;
|
|
39
|
+
readonly managers: Partial<Record<OAuthProviderKind, OAuthTokenManager>>;
|
|
40
|
+
readonly credentials: CalendarCredentialStore | undefined;
|
|
41
|
+
readonly notify: CalendarNotifier<Target> | undefined;
|
|
42
|
+
/** Returns the full callback URL, or undefined when no HTTP host is available. */
|
|
43
|
+
readonly resolveRedirectUri: () => string | undefined;
|
|
44
|
+
readonly fetchImpl?: typeof fetch;
|
|
45
|
+
readonly now?: () => number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface CallbackResult {
|
|
49
|
+
readonly statusCode: number;
|
|
50
|
+
readonly message: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface ClientCredentials {
|
|
54
|
+
readonly clientId: string;
|
|
55
|
+
readonly clientSecret: string;
|
|
56
|
+
readonly tenant: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const PROVIDER_DISPLAY_NAME: Record<OAuthProviderKind, string> = {
|
|
60
|
+
google: "Google 日历",
|
|
61
|
+
microsoft: "Outlook 日历",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function parseOAuthProvider(
|
|
65
|
+
value: unknown
|
|
66
|
+
): OAuthProviderKind | undefined {
|
|
67
|
+
const normalized = String(value ?? "")
|
|
68
|
+
.trim()
|
|
69
|
+
.toLowerCase();
|
|
70
|
+
if (normalized === "google") {
|
|
71
|
+
return "google";
|
|
72
|
+
}
|
|
73
|
+
if (normalized === "microsoft" || normalized === "outlook") {
|
|
74
|
+
return "microsoft";
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function trimTrailingSlashes(value: string): string {
|
|
80
|
+
let end = value.length;
|
|
81
|
+
while (end > 0 && value[end - 1] === "/") {
|
|
82
|
+
end -= 1;
|
|
83
|
+
}
|
|
84
|
+
return value.slice(0, end);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function trimLeadingSlashes(value: string): string {
|
|
88
|
+
let start = 0;
|
|
89
|
+
while (start < value.length && value[start] === "/") {
|
|
90
|
+
start += 1;
|
|
91
|
+
}
|
|
92
|
+
return value.slice(start);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Joins a base URL and the callback path, tolerating extra/missing slashes. */
|
|
96
|
+
export function buildRedirectUri(
|
|
97
|
+
baseUrl: string,
|
|
98
|
+
callbackPath: string
|
|
99
|
+
): string {
|
|
100
|
+
const trimmedBase = trimTrailingSlashes(baseUrl.trim());
|
|
101
|
+
const normalizedPath = `/${trimLeadingSlashes(callbackPath.trim())}`;
|
|
102
|
+
return `${trimmedBase}${normalizedPath}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Turns a listening address into a browser-reachable local origin. */
|
|
106
|
+
export function localOriginFromAddress(address: {
|
|
107
|
+
readonly protocol: string;
|
|
108
|
+
readonly host: string;
|
|
109
|
+
readonly port: number;
|
|
110
|
+
}): string {
|
|
111
|
+
const wildcardHosts = new Set(["0.0.0.0", "::", "[::]", ""]);
|
|
112
|
+
const host = wildcardHosts.has(address.host) ? "127.0.0.1" : address.host;
|
|
113
|
+
const formattedHost =
|
|
114
|
+
host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
115
|
+
return `${address.protocol}://${formattedHost}:${address.port}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class CalendarAuthService<Target> {
|
|
119
|
+
readonly #config: CalendarConfig;
|
|
120
|
+
readonly #managers: Partial<Record<OAuthProviderKind, OAuthTokenManager>>;
|
|
121
|
+
readonly #credentials: CalendarCredentialStore | undefined;
|
|
122
|
+
readonly #notify: CalendarNotifier<Target> | undefined;
|
|
123
|
+
readonly #resolveRedirectUri: () => string | undefined;
|
|
124
|
+
readonly #fetch: typeof fetch | undefined;
|
|
125
|
+
readonly #sessions: OAuthSessionRegistry<CredentialOwner<Target>>;
|
|
126
|
+
|
|
127
|
+
constructor(options: CalendarAuthServiceOptions<Target>) {
|
|
128
|
+
this.#config = options.config;
|
|
129
|
+
this.#managers = options.managers;
|
|
130
|
+
this.#credentials = options.credentials;
|
|
131
|
+
this.#notify = options.notify;
|
|
132
|
+
this.#resolveRedirectUri = options.resolveRedirectUri;
|
|
133
|
+
this.#fetch = options.fetchImpl;
|
|
134
|
+
this.#sessions = new OAuthSessionRegistry({ now: options.now });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Handles `calendar auth <provider>` and returns the chat reply. */
|
|
138
|
+
async startAuthorization(
|
|
139
|
+
provider: OAuthProviderKind,
|
|
140
|
+
requester: CredentialOwner<Target>
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
const displayName = PROVIDER_DISPLAY_NAME[provider];
|
|
143
|
+
if (!this.#credentials) {
|
|
144
|
+
return "日历授权需要数据库(databaseHostToken)来保存 refresh_token,当前不可用。";
|
|
145
|
+
}
|
|
146
|
+
const client = this.#clientCredentials(provider);
|
|
147
|
+
if (!client) {
|
|
148
|
+
return this.#missingClientMessage(provider);
|
|
149
|
+
}
|
|
150
|
+
const redirectUri = this.#resolveRedirectUri();
|
|
151
|
+
if (!redirectUri) {
|
|
152
|
+
return "未找到 HTTP 服务地址:请启用 zhin 的 HTTP Host,或在 plugins.calendar.oauth_redirect_base_url 配置外部可访问地址。";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pkce = createPkcePair();
|
|
156
|
+
const state = createOAuthState();
|
|
157
|
+
this.#sessions.create({
|
|
158
|
+
provider,
|
|
159
|
+
state,
|
|
160
|
+
codeVerifier: pkce.verifier,
|
|
161
|
+
redirectUri,
|
|
162
|
+
requester,
|
|
163
|
+
});
|
|
164
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
165
|
+
provider,
|
|
166
|
+
clientId: client.clientId,
|
|
167
|
+
redirectUri,
|
|
168
|
+
state,
|
|
169
|
+
codeChallenge: pkce.challenge,
|
|
170
|
+
tenant: client.tenant,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const validMinutes = Math.round(OAUTH_SESSION_TTL_MS / 60_000);
|
|
174
|
+
return [
|
|
175
|
+
`请在浏览器打开下面的链接完成${displayName}授权(${validMinutes} 分钟内有效,只能使用一次):`,
|
|
176
|
+
authorizeUrl,
|
|
177
|
+
"",
|
|
178
|
+
`回调地址:${redirectUri}`,
|
|
179
|
+
"该地址必须能从打开链接的浏览器访问,并已登记在 OAuth 客户端的重定向 URI 中。",
|
|
180
|
+
].join("\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Handles the OAuth redirect back to the HTTP host. */
|
|
184
|
+
async handleCallback(searchParams: URLSearchParams): Promise<CallbackResult> {
|
|
185
|
+
const state = searchParams.get("state") ?? "";
|
|
186
|
+
const session = state ? this.#sessions.consume(state) : undefined;
|
|
187
|
+
if (!session) {
|
|
188
|
+
return {
|
|
189
|
+
statusCode: 400,
|
|
190
|
+
message:
|
|
191
|
+
"授权链接无效、已使用或已过期,请在聊天中重新发送 calendar auth 命令。",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const provider = session.provider;
|
|
196
|
+
const displayName = PROVIDER_DISPLAY_NAME[provider];
|
|
197
|
+
const oauthError = searchParams.get("error");
|
|
198
|
+
if (oauthError) {
|
|
199
|
+
const description = searchParams.get("error_description") ?? "";
|
|
200
|
+
const detail = `${oauthError} ${description}`.trim();
|
|
201
|
+
await this.#notifySafely(
|
|
202
|
+
session.requester.target,
|
|
203
|
+
`${displayName}授权失败:${detail}`
|
|
204
|
+
);
|
|
205
|
+
return { statusCode: 400, message: `授权失败:${detail}` };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const code = searchParams.get("code") ?? "";
|
|
209
|
+
const client = this.#clientCredentials(provider);
|
|
210
|
+
if (!code || !client || !this.#credentials) {
|
|
211
|
+
return {
|
|
212
|
+
statusCode: 400,
|
|
213
|
+
message: "缺少授权码或客户端配置,请重新授权。",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
const tokens = await exchangeAuthorizationCode({
|
|
219
|
+
provider,
|
|
220
|
+
clientId: client.clientId,
|
|
221
|
+
clientSecret: client.clientSecret || undefined,
|
|
222
|
+
tenant: client.tenant,
|
|
223
|
+
redirectUri: session.redirectUri,
|
|
224
|
+
code,
|
|
225
|
+
codeVerifier: session.codeVerifier,
|
|
226
|
+
fetchImpl: this.#fetch,
|
|
227
|
+
});
|
|
228
|
+
await this.#credentials.save(
|
|
229
|
+
provider,
|
|
230
|
+
tokens.refreshToken,
|
|
231
|
+
session.requester as CredentialOwner
|
|
232
|
+
);
|
|
233
|
+
this.#managers[provider]?.setRefreshToken(tokens.refreshToken);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
236
|
+
await this.#notifySafely(
|
|
237
|
+
session.requester.target,
|
|
238
|
+
`${displayName}授权失败:${reason}`
|
|
239
|
+
);
|
|
240
|
+
return { statusCode: 502, message: `授权失败:${reason}` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const providerHint =
|
|
244
|
+
this.#config.provider === provider
|
|
245
|
+
? ""
|
|
246
|
+
: `\n注意:当前 plugins.calendar.provider 是 ${
|
|
247
|
+
this.#config.provider
|
|
248
|
+
},日历工具不会使用 ${provider}。`;
|
|
249
|
+
await this.#notifySafely(
|
|
250
|
+
session.requester.target,
|
|
251
|
+
`${displayName}授权成功,refresh_token 已保存到插件数据库。${providerHint}`
|
|
252
|
+
);
|
|
253
|
+
return { statusCode: 200, message: "授权成功,可以关闭此页回到聊天。" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Handles `calendar status`. */
|
|
257
|
+
async describeStatus(): Promise<string> {
|
|
258
|
+
const lines = [`当前 provider:${this.#config.provider}`];
|
|
259
|
+
for (const provider of OAUTH_PROVIDERS) {
|
|
260
|
+
lines.push(await this.#describeProvider(provider));
|
|
261
|
+
}
|
|
262
|
+
return lines.join("\n");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Handles `calendar logout <provider>`: removes the stored token. */
|
|
266
|
+
async logout(provider: OAuthProviderKind): Promise<string> {
|
|
267
|
+
const displayName = PROVIDER_DISPLAY_NAME[provider];
|
|
268
|
+
if (!this.#credentials) {
|
|
269
|
+
return "数据库不可用,没有可删除的授权。";
|
|
270
|
+
}
|
|
271
|
+
await this.#credentials.delete(provider);
|
|
272
|
+
this.#managers[provider]?.clearTokens();
|
|
273
|
+
return `已删除${displayName}在插件数据库中的授权。若 .env 里仍有 refresh_token,重载配置后会继续作为后备使用。`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Called by the token manager once when the refresh token is rejected. */
|
|
277
|
+
async notifyAuthorizationLost(
|
|
278
|
+
provider: OAuthProviderKind,
|
|
279
|
+
message: string
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
const credential = await this.#credentials?.get(provider);
|
|
282
|
+
const owner = credential?.authorized_by as CredentialOwner<Target> | null;
|
|
283
|
+
if (!owner) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await this.#notifySafely(owner.target, message);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async #describeProvider(provider: OAuthProviderKind): Promise<string> {
|
|
290
|
+
const displayName = PROVIDER_DISPLAY_NAME[provider];
|
|
291
|
+
if (!this.#clientCredentials(provider)) {
|
|
292
|
+
return `${displayName}:未配置 client_id`;
|
|
293
|
+
}
|
|
294
|
+
const credential = await this.#credentials?.get(provider);
|
|
295
|
+
const manager = this.#managers[provider];
|
|
296
|
+
if (manager?.authorizationLost) {
|
|
297
|
+
return `${displayName}:授权已失效,请发送「${authCommandFor(
|
|
298
|
+
provider
|
|
299
|
+
)}」`;
|
|
300
|
+
}
|
|
301
|
+
if (credential) {
|
|
302
|
+
return `${displayName}:已授权(数据库,${credential.authorized_at})`;
|
|
303
|
+
}
|
|
304
|
+
if (manager?.hasRefreshToken) {
|
|
305
|
+
return `${displayName}:使用配置/环境变量中的 refresh_token`;
|
|
306
|
+
}
|
|
307
|
+
return `${displayName}:未授权,请发送「${authCommandFor(provider)}」`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#clientCredentials(
|
|
311
|
+
provider: OAuthProviderKind
|
|
312
|
+
): ClientCredentials | undefined {
|
|
313
|
+
if (provider === "google") {
|
|
314
|
+
const google = this.#config.google;
|
|
315
|
+
const clientId = cleanSecret(google?.client_id ?? "");
|
|
316
|
+
const clientSecret = cleanSecret(google?.client_secret ?? "");
|
|
317
|
+
if (!clientId || !clientSecret) {
|
|
318
|
+
return undefined;
|
|
319
|
+
}
|
|
320
|
+
return { clientId, clientSecret, tenant: "common" };
|
|
321
|
+
}
|
|
322
|
+
const microsoft = this.#config.microsoft;
|
|
323
|
+
const clientId = cleanSecret(microsoft?.client_id ?? "");
|
|
324
|
+
if (!clientId) {
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
return {
|
|
328
|
+
clientId,
|
|
329
|
+
clientSecret: cleanSecret(microsoft?.client_secret ?? ""),
|
|
330
|
+
tenant: microsoft?.tenant || "common",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
#missingClientMessage(provider: OAuthProviderKind): string {
|
|
335
|
+
if (provider === "google") {
|
|
336
|
+
return "请先在 plugins.calendar.google 配置 client_id 和 client_secret(例如 ${GOOGLE_CALENDAR_CLIENT_ID} / ${GOOGLE_CALENDAR_CLIENT_SECRET})。";
|
|
337
|
+
}
|
|
338
|
+
return "请先在 plugins.calendar.microsoft 配置 client_id(例如 ${MS_GRAPH_CLIENT_ID})。";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async #notifySafely(target: Target, text: string): Promise<void> {
|
|
342
|
+
if (!this.#notify) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
await this.#notify(target, text);
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
349
|
+
console.warn(`[calendar] 发送授权通知失败:${reason}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
package/src/cli/auth.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One-time OAuth loopback helper for Google Calendar / Microsoft Graph.
|
|
4
|
+
*
|
|
5
|
+
* After installing the package:
|
|
6
|
+
* npx zhin-calendar-auth google
|
|
7
|
+
* npx zhin-calendar-auth microsoft
|
|
8
|
+
*
|
|
9
|
+
* Inside the zhin monorepo:
|
|
10
|
+
* pnpm --filter @zhin.js/plugin-calendar auth google
|
|
11
|
+
*
|
|
12
|
+
* Reads credentials from process.env only (does not load .env files).
|
|
13
|
+
* Prints the refresh token for the user to paste into their env and never writes files.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
createServer,
|
|
17
|
+
type IncomingMessage,
|
|
18
|
+
type ServerResponse,
|
|
19
|
+
} from "node:http";
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import {
|
|
22
|
+
buildAuthorizeUrl,
|
|
23
|
+
createOAuthState,
|
|
24
|
+
createPkcePair,
|
|
25
|
+
exchangeAuthorizationCode,
|
|
26
|
+
} from "../oauth-auth.js";
|
|
27
|
+
|
|
28
|
+
type Provider = "google" | "microsoft";
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CALLBACK_PORT = 53682;
|
|
31
|
+
|
|
32
|
+
function printUsageAndExit(): never {
|
|
33
|
+
console.error(`用法:npx zhin-calendar-auth <google|microsoft>
|
|
34
|
+
|
|
35
|
+
环境变量:
|
|
36
|
+
google: GOOGLE_CALENDAR_CLIENT_ID, GOOGLE_CALENDAR_CLIENT_SECRET
|
|
37
|
+
microsoft: MS_GRAPH_CLIENT_ID, 可选 MS_GRAPH_CLIENT_SECRET, 可选 MS_GRAPH_TENANT (默认 common)
|
|
38
|
+
|
|
39
|
+
回调地址固定为 http://127.0.0.1:<port>/callback (默认端口 ${DEFAULT_CALLBACK_PORT},可用 CALENDAR_OAUTH_PORT 覆盖)。
|
|
40
|
+
完成后只打印 refresh_token,请手动写入 .env;本工具不会改写任何文件。`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getBrowserCommand(url: string): { command: string; args: string[] } {
|
|
45
|
+
if (process.platform === "darwin") {
|
|
46
|
+
return { command: "open", args: [url] };
|
|
47
|
+
}
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
return { command: "cmd", args: ["/c", "start", "", url] };
|
|
50
|
+
}
|
|
51
|
+
return { command: "xdg-open", args: [url] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function openBrowser(url: string): void {
|
|
55
|
+
const { command, args } = getBrowserCommand(url);
|
|
56
|
+
try {
|
|
57
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
58
|
+
child.on("error", () => {
|
|
59
|
+
console.error("无法自动打开浏览器,请手动访问上面的 URL。");
|
|
60
|
+
});
|
|
61
|
+
child.unref();
|
|
62
|
+
} catch {
|
|
63
|
+
console.error("无法自动打开浏览器,请手动访问上面的 URL。");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function sendText(
|
|
68
|
+
response: ServerResponse,
|
|
69
|
+
statusCode: number,
|
|
70
|
+
text: string
|
|
71
|
+
): void {
|
|
72
|
+
response.writeHead(statusCode, {
|
|
73
|
+
"content-type": "text/plain; charset=utf-8",
|
|
74
|
+
});
|
|
75
|
+
response.end(text);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function waitForAuthorizationCode(
|
|
79
|
+
port: number,
|
|
80
|
+
expectedState: string
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
const server = createServer(
|
|
84
|
+
(request: IncomingMessage, response: ServerResponse) => {
|
|
85
|
+
const fail = (statusCode: number, text: string, error: Error) => {
|
|
86
|
+
sendText(response, statusCode, text);
|
|
87
|
+
server.close();
|
|
88
|
+
reject(error);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const callbackUrl = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
93
|
+
if (callbackUrl.pathname !== "/callback") {
|
|
94
|
+
sendText(response, 404, "not found");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const oauthError = callbackUrl.searchParams.get("error");
|
|
99
|
+
if (oauthError) {
|
|
100
|
+
const description =
|
|
101
|
+
callbackUrl.searchParams.get("error_description") ?? "";
|
|
102
|
+
fail(
|
|
103
|
+
400,
|
|
104
|
+
`授权失败:${oauthError}\n可以关闭此页。`,
|
|
105
|
+
new Error(`OAuth error: ${oauthError} ${description}`.trim())
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const state = callbackUrl.searchParams.get("state") ?? "";
|
|
111
|
+
if (state !== expectedState) {
|
|
112
|
+
fail(
|
|
113
|
+
400,
|
|
114
|
+
"state 不匹配,请重试。",
|
|
115
|
+
new Error("OAuth state mismatch")
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const code = callbackUrl.searchParams.get("code") ?? "";
|
|
121
|
+
if (!code) {
|
|
122
|
+
fail(400, "missing code", new Error("missing code"));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
response.writeHead(200, {
|
|
127
|
+
"content-type": "text/html; charset=utf-8",
|
|
128
|
+
});
|
|
129
|
+
response.end(
|
|
130
|
+
'<!doctype html><meta charset="utf-8"><title>OK</title><p>授权成功,可以关闭此页回到终端。</p>'
|
|
131
|
+
);
|
|
132
|
+
server.close();
|
|
133
|
+
resolve(code);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
server.close();
|
|
136
|
+
reject(error);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
server.on("error", reject);
|
|
142
|
+
server.listen(port, "127.0.0.1", () => {
|
|
143
|
+
console.error(`等待回调 http://127.0.0.1:${port}/callback …`);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readEnv(name: string): string {
|
|
149
|
+
return process.env[name]?.trim() ?? "";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseProvider(value: string | undefined): Provider {
|
|
153
|
+
const normalized = (value ?? "").toLowerCase();
|
|
154
|
+
if (normalized === "google" || normalized === "microsoft") {
|
|
155
|
+
return normalized;
|
|
156
|
+
}
|
|
157
|
+
return printUsageAndExit();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function main(): Promise<void> {
|
|
161
|
+
const provider = parseProvider(process.argv[2]);
|
|
162
|
+
const isGoogle = provider === "google";
|
|
163
|
+
|
|
164
|
+
const port = Number(process.env.CALENDAR_OAUTH_PORT ?? DEFAULT_CALLBACK_PORT);
|
|
165
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
166
|
+
|
|
167
|
+
const clientIdEnvName = isGoogle
|
|
168
|
+
? "GOOGLE_CALENDAR_CLIENT_ID"
|
|
169
|
+
: "MS_GRAPH_CLIENT_ID";
|
|
170
|
+
const clientSecretEnvName = isGoogle
|
|
171
|
+
? "GOOGLE_CALENDAR_CLIENT_SECRET"
|
|
172
|
+
: "MS_GRAPH_CLIENT_SECRET";
|
|
173
|
+
const refreshTokenEnvName = isGoogle
|
|
174
|
+
? "GOOGLE_CALENDAR_REFRESH_TOKEN"
|
|
175
|
+
: "MS_GRAPH_REFRESH_TOKEN";
|
|
176
|
+
|
|
177
|
+
const clientId = readEnv(clientIdEnvName);
|
|
178
|
+
const clientSecret = readEnv(clientSecretEnvName);
|
|
179
|
+
const tenant = readEnv("MS_GRAPH_TENANT") || "common";
|
|
180
|
+
|
|
181
|
+
if (!clientId) {
|
|
182
|
+
console.error(`请先 export ${clientIdEnvName}=...`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
if (isGoogle && !clientSecret) {
|
|
186
|
+
console.error(`Google 需要 export ${clientSecretEnvName}=...`);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const pkce = createPkcePair();
|
|
191
|
+
const state = createOAuthState();
|
|
192
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
193
|
+
provider,
|
|
194
|
+
clientId,
|
|
195
|
+
redirectUri,
|
|
196
|
+
state,
|
|
197
|
+
codeChallenge: pkce.challenge,
|
|
198
|
+
tenant,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
console.error("\n将打开浏览器完成登录。若未自动打开,请复制以下 URL:\n");
|
|
202
|
+
console.error(authorizeUrl);
|
|
203
|
+
console.error("");
|
|
204
|
+
|
|
205
|
+
const codePromise = waitForAuthorizationCode(port, state);
|
|
206
|
+
openBrowser(authorizeUrl);
|
|
207
|
+
const code = await codePromise;
|
|
208
|
+
|
|
209
|
+
const tokens = await exchangeAuthorizationCode({
|
|
210
|
+
provider,
|
|
211
|
+
clientId,
|
|
212
|
+
clientSecret: clientSecret || undefined,
|
|
213
|
+
tenant,
|
|
214
|
+
redirectUri,
|
|
215
|
+
code,
|
|
216
|
+
codeVerifier: pkce.verifier,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const output = [
|
|
220
|
+
"",
|
|
221
|
+
"===== 请把下面的值写入环境变量(不要提交到 git)=====",
|
|
222
|
+
`${refreshTokenEnvName}=${tokens.refreshToken}`,
|
|
223
|
+
"====================================================",
|
|
224
|
+
"",
|
|
225
|
+
"",
|
|
226
|
+
].join("\n");
|
|
227
|
+
process.stdout.write(output);
|
|
228
|
+
console.error("本工具不会写入 .env。请手动粘贴后重启/重载配置。");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
main().catch((error: unknown) => {
|
|
232
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
233
|
+
console.error(message);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Schema } from "zhin.js";
|
|
2
|
+
|
|
3
|
+
export const calendarConfig = Schema.object({
|
|
4
|
+
provider: Schema.union([
|
|
5
|
+
Schema.const("none"),
|
|
6
|
+
Schema.const("caldav"),
|
|
7
|
+
Schema.const("google"),
|
|
8
|
+
Schema.const("microsoft"),
|
|
9
|
+
])
|
|
10
|
+
.default("none")
|
|
11
|
+
.description("日历后端;none 时工具返回未启用提示")
|
|
12
|
+
.effect("reload"),
|
|
13
|
+
timezone: Schema.string()
|
|
14
|
+
.default("Asia/Manila")
|
|
15
|
+
.description("解释无偏移时间与展示用的 IANA 时区")
|
|
16
|
+
.effect("reload"),
|
|
17
|
+
work_day_start: Schema.string().default("09:00").effect("reload"),
|
|
18
|
+
work_day_end: Schema.string().default("18:00").effect("reload"),
|
|
19
|
+
oauth_redirect_base_url: Schema.string()
|
|
20
|
+
.default("")
|
|
21
|
+
.description(
|
|
22
|
+
"聊天授权回调的外部基础地址(如 https://bot.example.com);留空时使用 zhin HTTP Host 的本机地址"
|
|
23
|
+
)
|
|
24
|
+
.effect("reload"),
|
|
25
|
+
oauth_callback_path: Schema.string()
|
|
26
|
+
.default("/calendar/oauth/callback")
|
|
27
|
+
.description(
|
|
28
|
+
"聊天授权回调路由,挂在 zhin HTTP Host 上;反向代理/隧道只转发部分路径时可调整"
|
|
29
|
+
)
|
|
30
|
+
.effect("reload"),
|
|
31
|
+
caldav: Schema.object({
|
|
32
|
+
enabled: Schema.boolean().default(false),
|
|
33
|
+
calendar_url: Schema.string()
|
|
34
|
+
.default("")
|
|
35
|
+
.description("日历 collection URL")
|
|
36
|
+
.effect("reload"),
|
|
37
|
+
username: Schema.string().default("").effect("reload"),
|
|
38
|
+
password: Schema.string().default("").effect("reload"),
|
|
39
|
+
}),
|
|
40
|
+
google: Schema.object({
|
|
41
|
+
enabled: Schema.boolean().default(false),
|
|
42
|
+
client_id: Schema.string()
|
|
43
|
+
.default("")
|
|
44
|
+
.description("${GOOGLE_CALENDAR_CLIENT_ID}")
|
|
45
|
+
.effect("reload"),
|
|
46
|
+
client_secret: Schema.string()
|
|
47
|
+
.default("")
|
|
48
|
+
.description("${GOOGLE_CALENDAR_CLIENT_SECRET}")
|
|
49
|
+
.effect("reload"),
|
|
50
|
+
refresh_token: Schema.string()
|
|
51
|
+
.default("")
|
|
52
|
+
.description("${GOOGLE_CALENDAR_REFRESH_TOKEN};用 auth google 获取")
|
|
53
|
+
.effect("reload"),
|
|
54
|
+
calendar_id: Schema.string()
|
|
55
|
+
.default("primary")
|
|
56
|
+
.description("日历 ID,默认 primary;可换成其它日历的 id")
|
|
57
|
+
.effect("reload"),
|
|
58
|
+
token_file: Schema.string()
|
|
59
|
+
.default("")
|
|
60
|
+
.description(
|
|
61
|
+
"可选:持久化轮换后的 refresh_token 的 JSON 路径(如 data/calendar-google-token.json)"
|
|
62
|
+
)
|
|
63
|
+
.effect("reload"),
|
|
64
|
+
}),
|
|
65
|
+
microsoft: Schema.object({
|
|
66
|
+
enabled: Schema.boolean().default(false),
|
|
67
|
+
client_id: Schema.string()
|
|
68
|
+
.default("")
|
|
69
|
+
.description("${MS_GRAPH_CLIENT_ID}")
|
|
70
|
+
.effect("reload"),
|
|
71
|
+
client_secret: Schema.string()
|
|
72
|
+
.default("")
|
|
73
|
+
.description("可选;公共客户端可留空。${MS_GRAPH_CLIENT_SECRET}")
|
|
74
|
+
.effect("reload"),
|
|
75
|
+
refresh_token: Schema.string()
|
|
76
|
+
.default("")
|
|
77
|
+
.description("${MS_GRAPH_REFRESH_TOKEN};用 auth microsoft 获取")
|
|
78
|
+
.effect("reload"),
|
|
79
|
+
tenant: Schema.string()
|
|
80
|
+
.default("common")
|
|
81
|
+
.description("Azure tenant,默认 common")
|
|
82
|
+
.effect("reload"),
|
|
83
|
+
calendar_id: Schema.string()
|
|
84
|
+
.default("")
|
|
85
|
+
.description("留空或 primary/me = 默认日历;否则为 Graph calendar id")
|
|
86
|
+
.effect("reload"),
|
|
87
|
+
token_file: Schema.string()
|
|
88
|
+
.default("")
|
|
89
|
+
.description(
|
|
90
|
+
"可选:持久化轮换后的 refresh_token 的 JSON 路径(如 data/calendar-ms-token.json)"
|
|
91
|
+
)
|
|
92
|
+
.effect("reload"),
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export type CalendarConfig = Schema.Output<typeof calendarConfig>;
|
|
97
|
+
|
|
98
|
+
export function assertCalendarTimezone(timezone: string): void {
|
|
99
|
+
try {
|
|
100
|
+
new Intl.DateTimeFormat("zh-CN", { timeZone: timezone }).format(0);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new TypeError(
|
|
103
|
+
`calendar timezone 必须是有效的 IANA 时区:${timezone}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export { cleanSecret } from "./secrets.js";
|