@nextclaw/channel-plugin-feishu 0.2.15 → 0.2.17
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/index.ts +10 -0
- package/package.json +1 -1
- package/src/accounts.test.ts +10 -0
- package/src/accounts.ts +10 -10
- package/src/app-scope-checker.ts +75 -0
- package/src/auth-errors.ts +90 -0
- package/src/calendar-calendar.ts +72 -0
- package/src/calendar-event-attendee.ts +96 -0
- package/src/calendar-event.ts +236 -0
- package/src/calendar-freebusy.ts +58 -0
- package/src/calendar-shared.ts +32 -0
- package/src/calendar.ts +18 -0
- package/src/card-action.ts +19 -7
- package/src/config-schema.ts +5 -0
- package/src/device-flow.ts +188 -0
- package/src/domains.ts +19 -0
- package/src/feishu-fetch.ts +9 -0
- package/src/identity.ts +160 -0
- package/src/lark-ticket.ts +26 -0
- package/src/monitor.account.ts +48 -20
- package/src/oauth.ts +248 -0
- package/src/raw-request.ts +45 -0
- package/src/sheets.ts +374 -0
- package/src/task-comment.ts +95 -0
- package/src/task-shared.ts +10 -0
- package/src/task-subtask.ts +94 -0
- package/src/task-task.ts +172 -0
- package/src/task-tasklist.ts +154 -0
- package/src/task.ts +18 -0
- package/src/token-store.ts +211 -0
- package/src/tool-account-routing.test.ts +19 -0
- package/src/tool-account.ts +16 -0
- package/src/tool-scopes.ts +102 -0
- package/src/tools-config.test.ts +19 -0
- package/src/tools-config.ts +5 -0
- package/src/types.ts +5 -0
- package/src/uat-client.ts +159 -0
- package/src/user-tool-client.ts +224 -0
- package/src/user-tool-helpers.ts +157 -0
- package/src/user-tool-result.ts +22 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
|
|
3
|
+
import { normalizeEventTimes, resolveCalendarId } from "./calendar-shared.js";
|
|
4
|
+
import { assertLarkOk, createToolContext, handleInvokeError, json, parseTimeToTimestamp, registerTool, StringEnum } from "./user-tool-helpers.js";
|
|
5
|
+
|
|
6
|
+
const CalendarEventSchema = Type.Union([
|
|
7
|
+
Type.Object({
|
|
8
|
+
action: Type.Literal("create"),
|
|
9
|
+
start_time: Type.String(),
|
|
10
|
+
end_time: Type.String(),
|
|
11
|
+
summary: Type.Optional(Type.String()),
|
|
12
|
+
calendar_id: Type.Optional(Type.String()),
|
|
13
|
+
description: Type.Optional(Type.String()),
|
|
14
|
+
attendees: Type.Optional(Type.Array(Type.Object({ type: StringEnum(["user", "chat", "resource", "third_party"]), id: Type.String() }))),
|
|
15
|
+
user_open_id: Type.Optional(Type.String()),
|
|
16
|
+
need_notification: Type.Optional(Type.Boolean()),
|
|
17
|
+
location: Type.Optional(Type.String()),
|
|
18
|
+
}),
|
|
19
|
+
Type.Object({
|
|
20
|
+
action: Type.Literal("list"),
|
|
21
|
+
start_time: Type.String(),
|
|
22
|
+
end_time: Type.String(),
|
|
23
|
+
calendar_id: Type.Optional(Type.String()),
|
|
24
|
+
page_size: Type.Optional(Type.Number()),
|
|
25
|
+
page_token: Type.Optional(Type.String()),
|
|
26
|
+
}),
|
|
27
|
+
Type.Object({ action: Type.Literal("get"), event_id: Type.String(), calendar_id: Type.Optional(Type.String()) }),
|
|
28
|
+
Type.Object({
|
|
29
|
+
action: Type.Literal("patch"),
|
|
30
|
+
event_id: Type.String(),
|
|
31
|
+
calendar_id: Type.Optional(Type.String()),
|
|
32
|
+
summary: Type.Optional(Type.String()),
|
|
33
|
+
description: Type.Optional(Type.String()),
|
|
34
|
+
start_time: Type.Optional(Type.String()),
|
|
35
|
+
end_time: Type.Optional(Type.String()),
|
|
36
|
+
location: Type.Optional(Type.String()),
|
|
37
|
+
}),
|
|
38
|
+
Type.Object({ action: Type.Literal("delete"), event_id: Type.String(), calendar_id: Type.Optional(Type.String()), need_notification: Type.Optional(Type.Boolean()) }),
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function buildAttendees(payload: {
|
|
42
|
+
attendees?: Array<{ type: "user" | "chat" | "resource" | "third_party"; id: string }>;
|
|
43
|
+
user_open_id?: string;
|
|
44
|
+
}) {
|
|
45
|
+
const attendees = [
|
|
46
|
+
...(payload.user_open_id ? [{ type: "user" as const, id: payload.user_open_id }] : []),
|
|
47
|
+
...(payload.attendees ?? []),
|
|
48
|
+
];
|
|
49
|
+
return attendees.map((attendee) => {
|
|
50
|
+
if (attendee.type === "user") return { type: attendee.type, user_id: attendee.id };
|
|
51
|
+
if (attendee.type === "chat") return { type: attendee.type, chat_id: attendee.id };
|
|
52
|
+
if (attendee.type === "resource") return { type: attendee.type, room_id: attendee.id };
|
|
53
|
+
return { type: attendee.type, third_party_email: attendee.id };
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerFeishuCalendarEventTool(api: OpenClawPluginApi) {
|
|
58
|
+
const { toolClient } = createToolContext(api, "feishu_calendar_event");
|
|
59
|
+
registerTool(
|
|
60
|
+
api,
|
|
61
|
+
{
|
|
62
|
+
name: "feishu_calendar_event",
|
|
63
|
+
label: "Feishu Calendar Event",
|
|
64
|
+
description: "按本人身份创建、查询、修改、删除飞书日程。",
|
|
65
|
+
parameters: CalendarEventSchema,
|
|
66
|
+
async execute(_toolCallId, params) {
|
|
67
|
+
const payload = params as {
|
|
68
|
+
action: "create" | "list" | "get" | "patch" | "delete";
|
|
69
|
+
calendar_id?: string;
|
|
70
|
+
event_id?: string;
|
|
71
|
+
start_time?: string;
|
|
72
|
+
end_time?: string;
|
|
73
|
+
summary?: string;
|
|
74
|
+
description?: string;
|
|
75
|
+
attendees?: Array<{ type: "user" | "chat" | "resource" | "third_party"; id: string }>;
|
|
76
|
+
user_open_id?: string;
|
|
77
|
+
need_notification?: boolean;
|
|
78
|
+
location?: string;
|
|
79
|
+
page_size?: number;
|
|
80
|
+
page_token?: string;
|
|
81
|
+
};
|
|
82
|
+
try {
|
|
83
|
+
const client = toolClient();
|
|
84
|
+
const calendarId = await resolveCalendarId(client, payload.calendar_id);
|
|
85
|
+
|
|
86
|
+
if (payload.action === "create") {
|
|
87
|
+
const startTs = payload.start_time ? parseTimeToTimestamp(payload.start_time) : null;
|
|
88
|
+
const endTs = payload.end_time ? parseTimeToTimestamp(payload.end_time) : null;
|
|
89
|
+
if (!startTs || !endTs) {
|
|
90
|
+
return json({ error: "start_time 和 end_time 必须为带时区的 ISO 8601 / RFC 3339 时间。" });
|
|
91
|
+
}
|
|
92
|
+
const response = await client.invoke(
|
|
93
|
+
"feishu_calendar_event.create",
|
|
94
|
+
(sdk, opts) =>
|
|
95
|
+
sdk.calendar.calendarEvent.create(
|
|
96
|
+
{
|
|
97
|
+
path: { calendar_id: calendarId },
|
|
98
|
+
data: {
|
|
99
|
+
summary: payload.summary,
|
|
100
|
+
description: payload.description,
|
|
101
|
+
start_time: { timestamp: startTs },
|
|
102
|
+
end_time: { timestamp: endTs },
|
|
103
|
+
...(payload.location ? { location: { name: payload.location } } : {}),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
opts,
|
|
107
|
+
),
|
|
108
|
+
{ as: "user" },
|
|
109
|
+
);
|
|
110
|
+
assertLarkOk(response);
|
|
111
|
+
const event = (response.data as { event?: { event_id?: string } } | undefined)?.event;
|
|
112
|
+
const attendeePayload = buildAttendees(payload);
|
|
113
|
+
if (event?.event_id && attendeePayload.length > 0) {
|
|
114
|
+
await client.invoke(
|
|
115
|
+
"feishu_calendar_event.create",
|
|
116
|
+
(sdk, opts) =>
|
|
117
|
+
sdk.calendar.calendarEventAttendee.create(
|
|
118
|
+
{
|
|
119
|
+
path: { calendar_id: calendarId, event_id: event.event_id! },
|
|
120
|
+
params: { user_id_type: "open_id" as never },
|
|
121
|
+
data: {
|
|
122
|
+
attendees: attendeePayload,
|
|
123
|
+
need_notification: payload.need_notification ?? true,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
opts,
|
|
127
|
+
),
|
|
128
|
+
{ as: "user" },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return json({ event: normalizeEventTimes((response.data as { event?: Record<string, unknown> } | undefined)?.event) });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (payload.action === "list") {
|
|
135
|
+
const startTs = payload.start_time ? parseTimeToTimestamp(payload.start_time) : null;
|
|
136
|
+
const endTs = payload.end_time ? parseTimeToTimestamp(payload.end_time) : null;
|
|
137
|
+
if (!startTs || !endTs) {
|
|
138
|
+
return json({ error: "start_time 和 end_time 必须为带时区的 ISO 8601 / RFC 3339 时间。" });
|
|
139
|
+
}
|
|
140
|
+
const response = await client.invoke(
|
|
141
|
+
"feishu_calendar_event.instance_view",
|
|
142
|
+
(sdk, opts) =>
|
|
143
|
+
sdk.calendar.calendarEvent.instanceView(
|
|
144
|
+
{
|
|
145
|
+
path: { calendar_id: calendarId },
|
|
146
|
+
params: {
|
|
147
|
+
start_time: startTs,
|
|
148
|
+
end_time: endTs,
|
|
149
|
+
page_size: payload.page_size,
|
|
150
|
+
page_token: payload.page_token,
|
|
151
|
+
user_id_type: "open_id" as never,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
opts,
|
|
155
|
+
),
|
|
156
|
+
{ as: "user" },
|
|
157
|
+
);
|
|
158
|
+
assertLarkOk(response);
|
|
159
|
+
const items =
|
|
160
|
+
((response.data as { items?: Array<Record<string, unknown>> } | undefined)?.items ?? []).map(
|
|
161
|
+
normalizeEventTimes,
|
|
162
|
+
);
|
|
163
|
+
return json({
|
|
164
|
+
events: items,
|
|
165
|
+
has_more: (response.data as { has_more?: boolean } | undefined)?.has_more ?? false,
|
|
166
|
+
page_token: (response.data as { page_token?: string } | undefined)?.page_token,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (payload.action === "get") {
|
|
171
|
+
const response = await client.invoke(
|
|
172
|
+
"feishu_calendar_event.get",
|
|
173
|
+
(sdk, opts) =>
|
|
174
|
+
sdk.calendar.calendarEvent.get(
|
|
175
|
+
{ path: { calendar_id: calendarId, event_id: payload.event_id! } },
|
|
176
|
+
opts,
|
|
177
|
+
),
|
|
178
|
+
{ as: "user" },
|
|
179
|
+
);
|
|
180
|
+
assertLarkOk(response);
|
|
181
|
+
return json({ event: normalizeEventTimes((response.data as { event?: Record<string, unknown> } | undefined)?.event) });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (payload.action === "patch") {
|
|
185
|
+
const updateData: Record<string, unknown> = {};
|
|
186
|
+
if (payload.summary) updateData.summary = payload.summary;
|
|
187
|
+
if (payload.description) updateData.description = payload.description;
|
|
188
|
+
if (payload.location) updateData.location = { name: payload.location };
|
|
189
|
+
if (payload.start_time) {
|
|
190
|
+
const startTs = parseTimeToTimestamp(payload.start_time);
|
|
191
|
+
if (!startTs) return json({ error: "start_time 必须为带时区的 ISO 8601 / RFC 3339 时间。" });
|
|
192
|
+
updateData.start_time = { timestamp: startTs };
|
|
193
|
+
}
|
|
194
|
+
if (payload.end_time) {
|
|
195
|
+
const endTs = parseTimeToTimestamp(payload.end_time);
|
|
196
|
+
if (!endTs) return json({ error: "end_time 必须为带时区的 ISO 8601 / RFC 3339 时间。" });
|
|
197
|
+
updateData.end_time = { timestamp: endTs };
|
|
198
|
+
}
|
|
199
|
+
const response = await client.invoke(
|
|
200
|
+
"feishu_calendar_event.patch",
|
|
201
|
+
(sdk, opts) =>
|
|
202
|
+
sdk.calendar.calendarEvent.patch(
|
|
203
|
+
{
|
|
204
|
+
path: { calendar_id: calendarId, event_id: payload.event_id! },
|
|
205
|
+
data: updateData,
|
|
206
|
+
},
|
|
207
|
+
opts,
|
|
208
|
+
),
|
|
209
|
+
{ as: "user" },
|
|
210
|
+
);
|
|
211
|
+
assertLarkOk(response);
|
|
212
|
+
return json({ event: normalizeEventTimes((response.data as { event?: Record<string, unknown> } | undefined)?.event) });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const response = await client.invoke(
|
|
216
|
+
"feishu_calendar_event.delete",
|
|
217
|
+
(sdk, opts) =>
|
|
218
|
+
sdk.calendar.calendarEvent.delete(
|
|
219
|
+
{
|
|
220
|
+
path: { calendar_id: calendarId, event_id: payload.event_id! },
|
|
221
|
+
params: { need_notification: payload.need_notification ?? true },
|
|
222
|
+
},
|
|
223
|
+
opts,
|
|
224
|
+
),
|
|
225
|
+
{ as: "user" },
|
|
226
|
+
);
|
|
227
|
+
assertLarkOk(response);
|
|
228
|
+
return json({ success: true, event_id: payload.event_id });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
return handleInvokeError(error, api);
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
{ name: "feishu_calendar_event" },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
|
|
3
|
+
import { assertLarkOk, createToolContext, handleInvokeError, json, parseTimeToRFC3339, registerTool } from "./user-tool-helpers.js";
|
|
4
|
+
|
|
5
|
+
const CalendarFreebusySchema = Type.Object({
|
|
6
|
+
action: Type.Literal("list"),
|
|
7
|
+
user_ids: Type.Array(Type.String()),
|
|
8
|
+
time_min: Type.String(),
|
|
9
|
+
time_max: Type.String(),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function registerFeishuCalendarFreebusyTool(api: OpenClawPluginApi) {
|
|
13
|
+
const { toolClient } = createToolContext(api, "feishu_calendar_freebusy");
|
|
14
|
+
registerTool(api, {
|
|
15
|
+
name: "feishu_calendar_freebusy",
|
|
16
|
+
label: "Feishu Calendar Freebusy",
|
|
17
|
+
description: "按本人身份查询 1-10 位用户在一段时间内的忙闲状态。",
|
|
18
|
+
parameters: CalendarFreebusySchema,
|
|
19
|
+
async execute(_toolCallId, params) {
|
|
20
|
+
const payload = params as { action: "list"; user_ids: string[]; time_min: string; time_max: string };
|
|
21
|
+
try {
|
|
22
|
+
if (!payload.user_ids?.length || payload.user_ids.length > 10) {
|
|
23
|
+
return json({ error: "user_ids 必须是 1-10 个用户 open_id。" });
|
|
24
|
+
}
|
|
25
|
+
const timeMin = parseTimeToRFC3339(payload.time_min);
|
|
26
|
+
const timeMax = parseTimeToRFC3339(payload.time_max);
|
|
27
|
+
if (!timeMin || !timeMax) {
|
|
28
|
+
return json({ error: "time_min 和 time_max 必须是带时区的 RFC 3339 时间。" });
|
|
29
|
+
}
|
|
30
|
+
const client = toolClient();
|
|
31
|
+
const response = await client.invoke(
|
|
32
|
+
"feishu_calendar_freebusy.list",
|
|
33
|
+
(sdk, opts) =>
|
|
34
|
+
sdk.calendar.freebusy.batch(
|
|
35
|
+
{
|
|
36
|
+
data: {
|
|
37
|
+
time_min: timeMin,
|
|
38
|
+
time_max: timeMax,
|
|
39
|
+
user_ids: payload.user_ids,
|
|
40
|
+
include_external_calendar: true,
|
|
41
|
+
only_busy: true,
|
|
42
|
+
} as never,
|
|
43
|
+
},
|
|
44
|
+
opts,
|
|
45
|
+
),
|
|
46
|
+
{ as: "user" },
|
|
47
|
+
);
|
|
48
|
+
assertLarkOk(response);
|
|
49
|
+
return json({
|
|
50
|
+
freebusy_lists:
|
|
51
|
+
(response.data as { freebusy_lists?: unknown[] } | undefined)?.freebusy_lists ?? [],
|
|
52
|
+
});
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return handleInvokeError(error, api);
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}, { name: "feishu_calendar_freebusy" });
|
|
58
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createToolContext, assertLarkOk, unixTimestampToISO8601 } from "./user-tool-helpers.js";
|
|
2
|
+
|
|
3
|
+
export function normalizeEventTimes<T extends Record<string, unknown> | undefined>(event: T): T {
|
|
4
|
+
if (!event) return event;
|
|
5
|
+
const typed = event as Record<string, unknown>;
|
|
6
|
+
const start = typed.start_time as { timestamp?: string | number } | undefined;
|
|
7
|
+
const end = typed.end_time as { timestamp?: string | number } | undefined;
|
|
8
|
+
return {
|
|
9
|
+
...typed,
|
|
10
|
+
start_time_iso8601: unixTimestampToISO8601(start?.timestamp),
|
|
11
|
+
end_time_iso8601: unixTimestampToISO8601(end?.timestamp),
|
|
12
|
+
} as T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function resolveCalendarId(
|
|
16
|
+
client: ReturnType<typeof createToolContext>["toolClient"] extends () => infer T ? T : never,
|
|
17
|
+
calendarId?: string,
|
|
18
|
+
) {
|
|
19
|
+
if (calendarId) return calendarId;
|
|
20
|
+
const response = await client.invoke(
|
|
21
|
+
"feishu_calendar_calendar.primary",
|
|
22
|
+
(sdk, opts) => sdk.calendar.calendar.primary({}, opts),
|
|
23
|
+
{ as: "user" },
|
|
24
|
+
);
|
|
25
|
+
assertLarkOk(response);
|
|
26
|
+
const calendars = (response.data as { calendars?: Array<{ calendar_id?: string }> } | undefined)?.calendars;
|
|
27
|
+
const primaryId = calendars?.[0]?.calendar_id;
|
|
28
|
+
if (!primaryId) {
|
|
29
|
+
throw new Error("No primary calendar found for current user.");
|
|
30
|
+
}
|
|
31
|
+
return primaryId;
|
|
32
|
+
}
|
package/src/calendar.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "./nextclaw-sdk/feishu.js";
|
|
2
|
+
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
3
|
+
import { registerFeishuCalendarCalendarTool } from "./calendar-calendar.js";
|
|
4
|
+
import { registerFeishuCalendarEventTool } from "./calendar-event.js";
|
|
5
|
+
import { registerFeishuCalendarEventAttendeeTool } from "./calendar-event-attendee.js";
|
|
6
|
+
import { registerFeishuCalendarFreebusyTool } from "./calendar-freebusy.js";
|
|
7
|
+
import { resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
|
|
8
|
+
|
|
9
|
+
export function registerFeishuCalendarTools(api: OpenClawPluginApi) {
|
|
10
|
+
if (!api.config) return;
|
|
11
|
+
const accounts = listEnabledFeishuAccounts(api.config);
|
|
12
|
+
if (accounts.length === 0) return;
|
|
13
|
+
if (!resolveAnyEnabledFeishuToolsConfig(accounts).calendar) return;
|
|
14
|
+
registerFeishuCalendarCalendarTool(api);
|
|
15
|
+
registerFeishuCalendarEventTool(api);
|
|
16
|
+
registerFeishuCalendarEventAttendeeTool(api);
|
|
17
|
+
registerFeishuCalendarFreebusyTool(api);
|
|
18
|
+
}
|
package/src/card-action.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ClawdbotConfig, RuntimeEnv } from "./nextclaw-sdk/feishu.js";
|
|
2
2
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
3
3
|
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
|
|
4
|
+
import { withTicket } from "./lark-ticket.js";
|
|
4
5
|
|
|
5
6
|
export type FeishuCardActionEvent = {
|
|
6
7
|
operator: {
|
|
@@ -69,11 +70,22 @@ export async function handleFeishuCardAction(params: {
|
|
|
69
70
|
);
|
|
70
71
|
|
|
71
72
|
// Dispatch as normal message
|
|
72
|
-
await
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
73
|
+
await withTicket(
|
|
74
|
+
{
|
|
75
|
+
accountId: account.accountId,
|
|
76
|
+
messageId: messageEvent.message.message_id,
|
|
77
|
+
chatId: messageEvent.message.chat_id,
|
|
78
|
+
senderOpenId: event.operator.open_id,
|
|
79
|
+
chatType: messageEvent.message.chat_type,
|
|
80
|
+
startTime: Date.now(),
|
|
81
|
+
},
|
|
82
|
+
() =>
|
|
83
|
+
handleFeishuMessage({
|
|
84
|
+
cfg,
|
|
85
|
+
event: messageEvent,
|
|
86
|
+
botOpenId: params.botOpenId,
|
|
87
|
+
runtime,
|
|
88
|
+
accountId,
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
79
91
|
}
|
package/src/config-schema.ts
CHANGED
|
@@ -92,6 +92,11 @@ const FeishuToolsConfigSchema = z
|
|
|
92
92
|
drive: z.boolean().optional(), // Cloud storage operations (default: true)
|
|
93
93
|
perm: z.boolean().optional(), // Permission management (default: false, sensitive)
|
|
94
94
|
scopes: z.boolean().optional(), // App scopes diagnostic (default: true)
|
|
95
|
+
calendar: z.boolean().optional(), // Calendar operations (default: true)
|
|
96
|
+
task: z.boolean().optional(), // Task operations (default: true)
|
|
97
|
+
sheets: z.boolean().optional(), // Sheets operations (default: true)
|
|
98
|
+
oauth: z.boolean().optional(), // OAuth operations (default: true)
|
|
99
|
+
identity: z.boolean().optional(), // Identity lookup operations (default: true)
|
|
95
100
|
})
|
|
96
101
|
.strict()
|
|
97
102
|
.optional();
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { feishuFetch } from "./feishu-fetch.js";
|
|
2
|
+
import type { FeishuDomain } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type DeviceAuthResponse = {
|
|
5
|
+
deviceCode: string;
|
|
6
|
+
userCode: string;
|
|
7
|
+
verificationUri: string;
|
|
8
|
+
verificationUriComplete: string;
|
|
9
|
+
expiresIn: number;
|
|
10
|
+
interval: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type DeviceFlowTokenData = {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
expiresIn: number;
|
|
17
|
+
refreshExpiresIn: number;
|
|
18
|
+
scope: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type DeviceFlowResult =
|
|
22
|
+
| { ok: true; token: DeviceFlowTokenData }
|
|
23
|
+
| {
|
|
24
|
+
ok: false;
|
|
25
|
+
error: "authorization_pending" | "slow_down" | "access_denied" | "expired_token";
|
|
26
|
+
message: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function resolveOAuthEndpoints(domain: FeishuDomain): {
|
|
30
|
+
deviceAuthorization: string;
|
|
31
|
+
token: string;
|
|
32
|
+
} {
|
|
33
|
+
if (!domain || domain === "feishu") {
|
|
34
|
+
return {
|
|
35
|
+
deviceAuthorization: "https://accounts.feishu.cn/oauth/v1/device_authorization",
|
|
36
|
+
token: "https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (domain === "lark") {
|
|
40
|
+
return {
|
|
41
|
+
deviceAuthorization: "https://accounts.larksuite.com/oauth/v1/device_authorization",
|
|
42
|
+
token: "https://open.larksuite.com/open-apis/authen/v2/oauth/token",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const base = domain.replace(/\/+$/, "");
|
|
47
|
+
let accountsBase = base;
|
|
48
|
+
try {
|
|
49
|
+
const parsed = new URL(base);
|
|
50
|
+
if (parsed.hostname.startsWith("open.")) {
|
|
51
|
+
accountsBase = `${parsed.protocol}//${parsed.hostname.replace(/^open\./, "accounts.")}`;
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// ignore
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
deviceAuthorization: `${accountsBase}/oauth/v1/device_authorization`,
|
|
59
|
+
token: `${base}/open-apis/authen/v2/oauth/token`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function requestDeviceAuthorization(params: {
|
|
64
|
+
appId: string;
|
|
65
|
+
appSecret: string;
|
|
66
|
+
domain: FeishuDomain;
|
|
67
|
+
scope?: string;
|
|
68
|
+
}): Promise<DeviceAuthResponse> {
|
|
69
|
+
const endpoints = resolveOAuthEndpoints(params.domain);
|
|
70
|
+
let scope = params.scope?.trim() ?? "";
|
|
71
|
+
if (!scope.includes("offline_access")) {
|
|
72
|
+
scope = scope ? `${scope} offline_access` : "offline_access";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const basicAuth = Buffer.from(`${params.appId}:${params.appSecret}`).toString("base64");
|
|
76
|
+
const body = new URLSearchParams();
|
|
77
|
+
body.set("client_id", params.appId);
|
|
78
|
+
body.set("scope", scope);
|
|
79
|
+
|
|
80
|
+
const response = await feishuFetch(endpoints.deviceAuthorization, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: {
|
|
83
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
84
|
+
Authorization: `Basic ${basicAuth}`,
|
|
85
|
+
},
|
|
86
|
+
body: body.toString(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
90
|
+
if (!response.ok || data.error) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
String(data.error_description ?? data.error ?? `HTTP ${response.status}`),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
deviceCode: String(data.device_code ?? ""),
|
|
98
|
+
userCode: String(data.user_code ?? ""),
|
|
99
|
+
verificationUri: String(data.verification_uri ?? ""),
|
|
100
|
+
verificationUriComplete: String(data.verification_uri_complete ?? data.verification_uri ?? ""),
|
|
101
|
+
expiresIn: Number(data.expires_in ?? 240),
|
|
102
|
+
interval: Number(data.interval ?? 5),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const timer = setTimeout(resolve, ms);
|
|
109
|
+
signal?.addEventListener(
|
|
110
|
+
"abort",
|
|
111
|
+
() => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
114
|
+
},
|
|
115
|
+
{ once: true },
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function pollDeviceToken(params: {
|
|
121
|
+
appId: string;
|
|
122
|
+
appSecret: string;
|
|
123
|
+
domain: FeishuDomain;
|
|
124
|
+
deviceCode: string;
|
|
125
|
+
interval: number;
|
|
126
|
+
expiresIn: number;
|
|
127
|
+
signal?: AbortSignal;
|
|
128
|
+
}): Promise<DeviceFlowResult> {
|
|
129
|
+
const endpoints = resolveOAuthEndpoints(params.domain);
|
|
130
|
+
const deadline = Date.now() + params.expiresIn * 1000;
|
|
131
|
+
let interval = params.interval;
|
|
132
|
+
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
if (params.signal?.aborted) {
|
|
135
|
+
return { ok: false, error: "expired_token", message: "Polling was cancelled" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await sleep(interval * 1000, params.signal);
|
|
139
|
+
|
|
140
|
+
const response = await feishuFetch(endpoints.token, {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
143
|
+
body: new URLSearchParams({
|
|
144
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
145
|
+
device_code: params.deviceCode,
|
|
146
|
+
client_id: params.appId,
|
|
147
|
+
client_secret: params.appSecret,
|
|
148
|
+
}).toString(),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
152
|
+
const error = typeof data.error === "string" ? data.error : undefined;
|
|
153
|
+
|
|
154
|
+
if (!error && data.access_token) {
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
token: {
|
|
158
|
+
accessToken: String(data.access_token),
|
|
159
|
+
refreshToken: String(data.refresh_token ?? ""),
|
|
160
|
+
expiresIn: Number(data.expires_in ?? 7200),
|
|
161
|
+
refreshExpiresIn: Number(data.refresh_token_expires_in ?? data.expires_in ?? 7200),
|
|
162
|
+
scope: String(data.scope ?? ""),
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (error === "authorization_pending") {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (error === "slow_down") {
|
|
171
|
+
interval = Math.min(interval + 5, 60);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (error === "access_denied" || error === "expired_token") {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
error,
|
|
178
|
+
message: String(data.error_description ?? error),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
error: "expired_token",
|
|
186
|
+
message: "Device authorization expired before approval finished.",
|
|
187
|
+
};
|
|
188
|
+
}
|
package/src/domains.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FeishuDomain } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function openPlatformDomain(domain?: FeishuDomain): string {
|
|
4
|
+
return domain === "lark" ? "https://open.larksuite.com" : "https://open.feishu.cn";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function wwwDomain(domain?: FeishuDomain): string {
|
|
8
|
+
return domain === "lark" ? "https://www.larksuite.com" : "https://www.feishu.cn";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveDomainUrl(domain: FeishuDomain): string {
|
|
12
|
+
if (domain === "feishu") {
|
|
13
|
+
return "https://open.feishu.cn";
|
|
14
|
+
}
|
|
15
|
+
if (domain === "lark") {
|
|
16
|
+
return "https://open.larksuite.com";
|
|
17
|
+
}
|
|
18
|
+
return domain.replace(/\/+$/, "");
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const FEISHU_USER_AGENT = "nextclaw-feishu-plugin/0.2";
|
|
2
|
+
|
|
3
|
+
export function feishuFetch(url: string | URL | Request, init?: RequestInit): Promise<Response> {
|
|
4
|
+
const headers = new Headers(init?.headers);
|
|
5
|
+
if (!headers.has("User-Agent")) {
|
|
6
|
+
headers.set("User-Agent", FEISHU_USER_AGENT);
|
|
7
|
+
}
|
|
8
|
+
return fetch(url, { ...init, headers });
|
|
9
|
+
}
|