@teamclaw/feishu-agent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
1
+ import { FeishuConfig, TenantAccessTokenResponse, FeishuError } from "../types";
2
+
3
+ interface UserToken {
4
+ accessToken: string;
5
+ refreshToken: string;
6
+ expireTime: number;
7
+ }
8
+
9
+ export class AuthManager {
10
+ private config: FeishuConfig;
11
+ private tenantToken: string | null = null;
12
+ private tenantExpireTime: number = 0;
13
+ private userToken: UserToken | null = null;
14
+
15
+ constructor(config: FeishuConfig) {
16
+ this.config = config;
17
+ // If userAccessToken is provided, use it directly (will be refreshed if expired)
18
+ if (config.userAccessToken) {
19
+ this.userToken = {
20
+ accessToken: config.userAccessToken,
21
+ refreshToken: config.refreshToken || "",
22
+ expireTime: Date.now() + (2 * 60 * 60 * 1000), // Default 2 hours if not expired
23
+ };
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Get tenant access token (for app-level API calls)
29
+ */
30
+ async getTenantAccessToken(): Promise<string> {
31
+ if (this.tenantToken && Date.now() < this.tenantExpireTime) {
32
+ return this.tenantToken;
33
+ }
34
+
35
+ return this.refreshTenantToken();
36
+ }
37
+
38
+ /**
39
+ * Get user access token (for user-level API calls like calendar)
40
+ * Automatically refreshes if expired
41
+ */
42
+ async getUserAccessToken(): Promise<string> {
43
+ if (!this.userToken) {
44
+ throw new FeishuError("User access token not configured", 401);
45
+ }
46
+
47
+ // Refresh if expired (with 5 min buffer)
48
+ if (Date.now() >= this.userToken.expireTime - 300000) {
49
+ await this.refreshUserToken();
50
+ }
51
+
52
+ return this.userToken.accessToken;
53
+ }
54
+
55
+ /**
56
+ * Check if user has authorized (has user_access_token)
57
+ */
58
+ hasUserToken(): boolean {
59
+ return this.userToken !== null;
60
+ }
61
+
62
+ /**
63
+ * Get current user info from token
64
+ */
65
+ async getCurrentUser(): Promise<{ user_id: string; name: string } | null> {
66
+ if (!this.userToken) {
67
+ return null;
68
+ }
69
+
70
+ // Fetch user info using the access token
71
+ try {
72
+ const response = await fetch("https://open.feishu.cn/open-apis/authen/v1/user_info", {
73
+ headers: {
74
+ "Authorization": `Bearer ${this.userToken.accessToken}`,
75
+ },
76
+ });
77
+
78
+ const data = await response.json();
79
+ if (data.code === 0 && data.data) {
80
+ return {
81
+ user_id: data.data.user_id,
82
+ name: data.data.name,
83
+ };
84
+ }
85
+ return null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ private async refreshTenantToken(): Promise<string> {
92
+ const url = `${this.config.baseUrl || "https://open.feishu.cn"}/open-apis/auth/v3/tenant_access_token/internal`;
93
+
94
+ // Add 10s timeout for auth
95
+ const controller = new AbortController();
96
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
97
+
98
+ try {
99
+ const response = await fetch(url, {
100
+ method: "POST",
101
+ headers: {
102
+ "Content-Type": "application/json; charset=utf-8",
103
+ },
104
+ body: JSON.stringify({
105
+ app_id: this.config.appId,
106
+ app_secret: this.config.appSecret,
107
+ }),
108
+ signal: controller.signal,
109
+ });
110
+
111
+ if (!response.ok) {
112
+ throw new FeishuError(`Auth request failed: ${response.statusText}`, response.status);
113
+ }
114
+
115
+ const data = await response.json() as TenantAccessTokenResponse;
116
+
117
+ if (data.code !== 0) {
118
+ throw new FeishuError(`Feishu API Error: ${data.msg}`, data.code);
119
+ }
120
+
121
+ this.tenantToken = data.tenant_access_token;
122
+ // Expire time is in seconds, convert to ms. Subtract buffer (e.g., 5 mins) to be safe.
123
+ this.tenantExpireTime = Date.now() + (data.expire * 1000) - 300000;
124
+
125
+ return this.tenantToken;
126
+ } catch (error) {
127
+ if (error instanceof Error && error.name === 'AbortError') {
128
+ throw new Error("Auth token refresh timed out after 10s");
129
+ }
130
+ if (error instanceof FeishuError) {
131
+ throw error;
132
+ }
133
+ throw new Error(`Failed to refresh token: ${error instanceof Error ? error.message : String(error)}`);
134
+ } finally {
135
+ clearTimeout(timeoutId);
136
+ }
137
+ }
138
+
139
+ private async refreshToken(): Promise<UserToken> {
140
+ if (!this.userToken?.refreshToken) {
141
+ throw new FeishuError("Refresh token not available", 401);
142
+ }
143
+
144
+ const response = await fetch("https://open.feishu.cn/open-apis/authen/v1/refresh_access_token", {
145
+ method: "POST",
146
+ headers: {
147
+ "Content-Type": "application/json; charset=utf-8",
148
+ },
149
+ body: JSON.stringify({
150
+ grant_type: "refresh_token",
151
+ refresh_token: this.userToken.refreshToken,
152
+ app_id: this.config.appId,
153
+ app_secret: this.config.appSecret,
154
+ }),
155
+ });
156
+
157
+ const data = await response.json();
158
+
159
+ if (data.code !== 0) {
160
+ throw new FeishuError(`Failed to refresh user token: ${data.msg}`, data.code);
161
+ }
162
+
163
+ // Update user token
164
+ const newToken: UserToken = {
165
+ accessToken: data.data.access_token,
166
+ refreshToken: data.data.refresh_token || this.userToken.refreshToken,
167
+ expireTime: Date.now() + (data.data.expires_in * 1000),
168
+ };
169
+
170
+ this.userToken = newToken;
171
+
172
+ // Save updated token to config file
173
+ await this.saveUserToken(newToken);
174
+
175
+ return newToken;
176
+ }
177
+
178
+ private async saveUserToken(token: UserToken): Promise<void> {
179
+ // Try to save to local config first, then global config
180
+ try {
181
+ const { writeFile, readFile, mkdir } = await import("fs/promises");
182
+ const { join } = await import("path");
183
+ const { homedir } = await import("os");
184
+
185
+ // Try local config
186
+ const localConfigPath = ".feishu_agent/config.json";
187
+ try {
188
+ const content = await readFile(localConfigPath, "utf-8");
189
+ const config = JSON.parse(content);
190
+ config.userAccessToken = token.accessToken;
191
+ config.refreshToken = token.refreshToken;
192
+ await mkdir(".feishu_agent", { recursive: true });
193
+ await writeFile(localConfigPath, JSON.stringify(config, null, 2));
194
+ return;
195
+ } catch {
196
+ // Local config doesn't exist, try global config
197
+ }
198
+
199
+ // Global config
200
+ const globalConfigPath = join(homedir(), ".feishu-agent", "config.json");
201
+ try {
202
+ const content = await readFile(globalConfigPath, "utf-8");
203
+ const config = JSON.parse(content);
204
+ config.userAccessToken = token.accessToken;
205
+ config.refreshToken = token.refreshToken;
206
+ await writeFile(globalConfigPath, JSON.stringify(config, null, 2));
207
+ } catch {
208
+ // Global config doesn't exist either
209
+ }
210
+ } catch {
211
+ // Ignore save errors - token will still work until next restart
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,276 @@
1
+ import { FeishuClient } from "./client";
2
+ import {
3
+ CalendarEvent,
4
+ ListCalendarsResponse,
5
+ ListEventsResponse,
6
+ CreateEventResponse,
7
+ CalendarTime,
8
+ CalendarAttendee,
9
+ CreateCalendarPayload,
10
+ CreateCalendarResponse,
11
+ FreeBusyResponse,
12
+ } from "../types";
13
+
14
+ export class CalendarManager {
15
+ private client: FeishuClient;
16
+
17
+ constructor(client: FeishuClient) {
18
+ this.client = client;
19
+
20
+ // Require user authorization for calendar operations
21
+ if (!client.hasUserToken()) {
22
+ throw new Error(
23
+ "User authorization required for calendar access. " +
24
+ "Run 'feishu-agent auth' to authorize with your Feishu account."
25
+ );
26
+ }
27
+ }
28
+
29
+ /**
30
+ * List all calendars for the authenticated user
31
+ * Uses user_access_token if available
32
+ */
33
+ async listCalendars(pageSize: number = 500, pageToken?: string): Promise<ListCalendarsResponse["data"]> {
34
+ const params: Record<string, string> = {
35
+ page_size: pageSize.toString(),
36
+ };
37
+ if (pageToken) params.page_token = pageToken;
38
+
39
+ // Use user token if available to get user's personal calendars
40
+ const res = await this.client.get<ListCalendarsResponse["data"]>(
41
+ "/open-apis/calendar/v4/calendars",
42
+ params,
43
+ true // useUserToken = true
44
+ );
45
+ return res;
46
+ }
47
+
48
+ /**
49
+ * Get the primary calendar for the authenticated user
50
+ */
51
+ async getPrimaryCalendar(): Promise<string> {
52
+ const calendars = await this.listCalendars();
53
+ const primary = calendars.calendar_list.find((c) => c.type === "primary");
54
+
55
+ if (primary) return primary.calendar_id;
56
+ throw new Error("Primary calendar not found");
57
+ }
58
+
59
+ /**
60
+ * List events for a specific calendar
61
+ */
62
+ async listEvents(
63
+ calendarId: string,
64
+ options?: {
65
+ pageSize?: number;
66
+ pageToken?: string;
67
+ startTime?: string;
68
+ endTime?: string;
69
+ }
70
+ ): Promise<ListEventsResponse["data"]> {
71
+ const params: Record<string, string> = {
72
+ page_size: (options?.pageSize || 500).toString(),
73
+ };
74
+
75
+ if (options?.pageToken) params.page_token = options.pageToken;
76
+ if (options?.startTime) params.start_time = options.startTime;
77
+ if (options?.endTime) params.end_time = options.endTime;
78
+
79
+ const res = await this.client.get<ListEventsResponse["data"]>(
80
+ `/open-apis/calendar/v4/calendars/${calendarId}/events`,
81
+ params,
82
+ true // useUserToken = true
83
+ );
84
+ return res;
85
+ }
86
+
87
+ /**
88
+ * Get attendees for a specific event
89
+ */
90
+ async getEventAttendees(calendarId: string, eventId: string): Promise<CalendarAttendee[]> {
91
+ const res = await this.client.get<{ items: CalendarAttendee[] }>(
92
+ `/open-apis/calendar/v4/calendars/${calendarId}/events/${eventId}/attendees`,
93
+ {},
94
+ true // useUserToken = true
95
+ );
96
+ return res.items || [];
97
+ }
98
+
99
+ /**
100
+ * Get details of a specific event
101
+ */
102
+ async getEvent(calendarId: string, eventId: string): Promise<CalendarEvent> {
103
+ const res = await this.client.get<{ event: CalendarEvent }>(
104
+ `/open-apis/calendar/v4/calendars/${calendarId}/events/${eventId}`,
105
+ { user_id_type: "union_id" },
106
+ true // useUserToken = true
107
+ );
108
+ return res.event;
109
+ }
110
+
111
+ /**
112
+ * List attendees of a specific event
113
+ */
114
+ async listEventAttendees(
115
+ calendarId: string,
116
+ eventId: string,
117
+ options?: {
118
+ pageSize?: number;
119
+ pageToken?: string;
120
+ }
121
+ ): Promise<{ items: CalendarAttendee[]; page_token: string; has_more: boolean }> {
122
+ const params: Record<string, string> = {
123
+ page_size: (options?.pageSize || 50).toString(),
124
+ };
125
+ if (options?.pageToken) params.page_token = options.pageToken;
126
+
127
+ const res = await this.client.get<{ items: CalendarAttendee[]; page_token: string; has_more: boolean }>(
128
+ `/open-apis/calendar/v4/calendars/${calendarId}/events/${eventId}/attendees`,
129
+ params,
130
+ true // useUserToken = true
131
+ );
132
+ return res;
133
+ }
134
+
135
+ /**
136
+ * Create a new event
137
+ */
138
+ async createEvent(
139
+ calendarId: string,
140
+ event: {
141
+ summary: string;
142
+ description?: string;
143
+ startTime: CalendarTime;
144
+ endTime: CalendarTime;
145
+ attendeeUserIds?: string[];
146
+ }
147
+ ): Promise<CalendarEvent> {
148
+ // Step 1: Create the event
149
+ const body: Record<string, any> = {
150
+ summary: event.summary,
151
+ description: event.description,
152
+ start_time: event.startTime,
153
+ end_time: event.endTime,
154
+ };
155
+
156
+ const res = await this.client.post<CreateEventResponse["data"]>(
157
+ `/open-apis/calendar/v4/calendars/${calendarId}/events`,
158
+ body,
159
+ { user_id_type: "union_id" },
160
+ true
161
+ );
162
+
163
+ const createdEvent = res.event;
164
+
165
+ // Step 2: Add attendees if specified
166
+ if (event.attendeeUserIds && event.attendeeUserIds.length > 0) {
167
+ await this.client.post(
168
+ `/open-apis/calendar/v4/calendars/${calendarId}/events/${createdEvent.event_id}/attendees`,
169
+ { attendees: event.attendeeUserIds.map(id => ({ type: "user", user_id: id })) },
170
+ { user_id_type: "union_id" },
171
+ true
172
+ );
173
+ }
174
+
175
+ return createdEvent;
176
+ }
177
+
178
+ /**
179
+ * Delete an event
180
+ */
181
+ async deleteEvent(calendarId: string, eventId: string): Promise<void> {
182
+ await this.client.request(
183
+ `/open-apis/calendar/v4/calendars/${calendarId}/events/${eventId}`,
184
+ { method: "DELETE" },
185
+ true // useUserToken = true
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Create a secondary calendar
191
+ */
192
+ async createCalendar(payload: CreateCalendarPayload): Promise<string> {
193
+ const res = await this.client.post<CreateCalendarResponse["data"]>(
194
+ "/open-apis/calendar/v4/calendars",
195
+ payload,
196
+ undefined,
197
+ true // useUserToken = true
198
+ );
199
+ return res.calendar.calendar_id;
200
+ }
201
+
202
+ /**
203
+ * Get free/busy information for a user
204
+ */
205
+ async getUserFreeBusy(userId: string, timeMin: string, timeMax: string): Promise<FreeBusyResponse["data"]> {
206
+ const body = {
207
+ time_min: timeMin,
208
+ time_max: timeMax,
209
+ user_id: userId
210
+ };
211
+
212
+ // Assume union_id (starts with on_) or user_id, default to union_id
213
+ let userIdType = "union_id";
214
+ if (userId.startsWith("on_")) userIdType = "union_id";
215
+
216
+ const res = await this.client.post<FreeBusyResponse["data"]>(
217
+ "/open-apis/calendar/v4/freebusy/list",
218
+ body,
219
+ { user_id_type: userIdType },
220
+ true // useUserToken = true
221
+ );
222
+ return res;
223
+ }
224
+
225
+ /**
226
+ * Update an event
227
+ */
228
+ async updateEvent(
229
+ calendarId: string,
230
+ eventId: string,
231
+ updates: {
232
+ summary?: string;
233
+ description?: string;
234
+ startTime?: CalendarTime;
235
+ endTime?: CalendarTime;
236
+ attendees?: CalendarAttendee[];
237
+ }
238
+ ): Promise<CalendarEvent> {
239
+ const body: Record<string, any> = {};
240
+ if (updates.summary !== undefined) body.summary = updates.summary;
241
+ if (updates.description !== undefined) body.description = updates.description;
242
+ if (updates.startTime !== undefined) body.start_time = updates.startTime;
243
+ if (updates.endTime !== undefined) body.end_time = updates.endTime;
244
+ if (updates.attendees !== undefined) body.attendees = updates.attendees;
245
+
246
+ const res = await this.client.post<CreateEventResponse["data"]>(
247
+ `/open-apis/calendar/v4/calendars/${calendarId}/events/${eventId}`,
248
+ body,
249
+ undefined,
250
+ true // useUserToken = true
251
+ );
252
+ return res.event;
253
+ }
254
+
255
+ /**
256
+ * Share a calendar with a user
257
+ */
258
+ async shareCalendarWithUser(
259
+ calendarId: string,
260
+ userId: string,
261
+ role: "reader" | "writer" | "free_busy_reader" = "reader"
262
+ ): Promise<void> {
263
+ throw new Error(
264
+ "Calendar sharing requires user authorization. " +
265
+ "Please share the calendar manually in Feishu app."
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Get calendar share link
271
+ */
272
+ getCalendarShareLink(calendarId: string): string {
273
+ return `https://applink.feishu.cn/client/calendar/?calendarId=${encodeURIComponent(calendarId)}`;
274
+ }
275
+
276
+ }
@@ -0,0 +1,100 @@
1
+ import { AuthManager } from "./auth-manager";
2
+ import { FeishuConfig, FeishuError } from "../types";
3
+
4
+ export class FeishuClient {
5
+ private authManager: AuthManager;
6
+ private baseUrl: string;
7
+
8
+ constructor(config: FeishuConfig) {
9
+ this.authManager = new AuthManager(config);
10
+ this.baseUrl = config.baseUrl || "https://open.feishu.cn";
11
+ }
12
+
13
+ /**
14
+ * Check if user has authorized (has user_access_token)
15
+ */
16
+ hasUserToken(): boolean {
17
+ return this.authManager.hasUserToken();
18
+ }
19
+
20
+ /**
21
+ * Get current user info
22
+ */
23
+ async getCurrentUser(): Promise<{ user_id: string; name: string } | null> {
24
+ return this.authManager.getCurrentUser();
25
+ }
26
+
27
+ private async request<T>(path: string, options: RequestInit = {}, useUserToken = false): Promise<T> {
28
+ // Use user_access_token for user-level APIs (calendar, etc.)
29
+ const token = useUserToken
30
+ ? await this.authManager.getUserAccessToken()
31
+ : await this.authManager.getTenantAccessToken();
32
+ const url = `${this.baseUrl}${path}`;
33
+
34
+ const headers = {
35
+ "Authorization": `Bearer ${token}`,
36
+ "Content-Type": "application/json; charset=utf-8",
37
+ ...options.headers,
38
+ };
39
+
40
+ // Add 30s timeout
41
+ const controller = new AbortController();
42
+ const timeoutId = setTimeout(() => controller.abort(), 30000);
43
+
44
+ try {
45
+ const response = await fetch(url, {
46
+ ...options,
47
+ headers,
48
+ signal: controller.signal,
49
+ });
50
+
51
+ if (!response.ok) {
52
+ try {
53
+ const errorData = await response.json();
54
+ // Try to extract permission info if it exists
55
+ let errorMsg = `Request failed: ${response.statusText}`;
56
+ if (errorData.code) {
57
+ errorMsg = `Feishu API Error: ${errorData.msg} (Code: ${errorData.code})`;
58
+ } else if (errorData.error && errorData.error.message) {
59
+ errorMsg = `Feishu API Error: ${errorData.error.message}`;
60
+ }
61
+
62
+ throw new FeishuError(errorMsg, errorData.code || response.status, errorData);
63
+ } catch (e) {
64
+ if (e instanceof FeishuError) throw e;
65
+ // If JSON parsing fails, fall back to status text
66
+ throw new FeishuError(`Request failed: ${response.statusText}`, response.status);
67
+ }
68
+ }
69
+
70
+ const data = await response.json();
71
+
72
+ if (data.code !== 0) {
73
+ throw new FeishuError(`Feishu API Error: ${data.msg}`, data.code, data);
74
+ }
75
+
76
+ return data.data as T;
77
+ } catch (error) {
78
+ if (error instanceof Error && error.name === 'AbortError') {
79
+ throw new Error(`Request timed out after 30s: ${path}`);
80
+ }
81
+ throw error;
82
+ } finally {
83
+ clearTimeout(timeoutId);
84
+ }
85
+ }
86
+
87
+ async get<T>(path: string, params?: Record<string, string>, useUserToken = false): Promise<T> {
88
+ const queryString = params ? "?" + new URLSearchParams(params).toString() : "";
89
+ return this.request<T>(`${path}${queryString}`, { method: "GET" }, useUserToken);
90
+ }
91
+
92
+ async post<T>(path: string, body: any, params?: Record<string, string>, useUserToken = false): Promise<T> {
93
+ const queryString = params ? "?" + new URLSearchParams(params).toString() : "";
94
+ return this.request<T>(`${path}${queryString}`, {
95
+ method: "POST",
96
+ body: JSON.stringify(body),
97
+ }, useUserToken);
98
+ }
99
+
100
+ }