@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.
- package/README.md +178 -0
- package/package.json +49 -0
- package/src/cli/commands/auth.ts +309 -0
- package/src/cli/commands/calendar.ts +305 -0
- package/src/cli/commands/config.ts +48 -0
- package/src/cli/commands/contact.ts +90 -0
- package/src/cli/commands/init.ts +128 -0
- package/src/cli/commands/setup.ts +327 -0
- package/src/cli/commands/todo.ts +114 -0
- package/src/cli/commands/whoami.ts +63 -0
- package/src/cli/index.ts +68 -0
- package/src/core/auth-manager.ts +214 -0
- package/src/core/calendar.ts +276 -0
- package/src/core/client.ts +100 -0
- package/src/core/config.ts +174 -0
- package/src/core/contact.ts +83 -0
- package/src/core/introspection.ts +103 -0
- package/src/core/todo.ts +109 -0
- package/src/index.ts +76 -0
- package/src/types/index.ts +199 -0
|
@@ -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
|
+
}
|