@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,174 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdir } from "node:fs/promises";
4
+
5
+ export interface ContactCacheEntry {
6
+ name: string;
7
+ email?: string;
8
+ user_id?: string; // Also store user_id for lookup
9
+ union_id?: string;
10
+ }
11
+
12
+ export interface FeishuConfig {
13
+ appId: string;
14
+ appSecret: string;
15
+ userAccessToken?: string;
16
+ refreshToken?: string;
17
+ }
18
+
19
+ /**
20
+ * Returns the path to the global configuration file.
21
+ */
22
+ export function getConfigPath(): string {
23
+ return join(homedir(), ".feishu-agent", "config.json");
24
+ }
25
+
26
+ /**
27
+ * Returns the path to the contact cache file.
28
+ */
29
+ export function getContactCachePath(): string {
30
+ return join(homedir(), ".feishu-agent", "contact-cache.json");
31
+ }
32
+
33
+ /**
34
+ * Loads configuration from global config file.
35
+ */
36
+ export async function loadConfig(): Promise<FeishuConfig> {
37
+ const configPath = getConfigPath();
38
+ let config: Partial<FeishuConfig> = {};
39
+
40
+ try {
41
+ const file = Bun.file(configPath);
42
+ if (await file.exists()) {
43
+ config = await file.json();
44
+ }
45
+ } catch {
46
+ // Ignore errors
47
+ }
48
+
49
+ return {
50
+ appId: config.appId || "",
51
+ appSecret: config.appSecret || "",
52
+ userAccessToken: config.userAccessToken,
53
+ refreshToken: config.refreshToken,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Saves the provided configuration to the global configuration file.
59
+ */
60
+ export async function saveGlobalConfig(config: Partial<FeishuConfig>): Promise<void> {
61
+ const configPath = getConfigPath();
62
+ const configDir = join(homedir(), ".feishu-agent");
63
+
64
+ let currentConfig: Partial<FeishuConfig> = {};
65
+ try {
66
+ const file = Bun.file(configPath);
67
+ if (await file.exists()) {
68
+ currentConfig = await file.json();
69
+ }
70
+ } catch {
71
+ // Ignore errors
72
+ }
73
+
74
+ const newConfig = { ...currentConfig, ...config };
75
+
76
+ await mkdir(configDir, { recursive: true });
77
+ await Bun.write(configPath, JSON.stringify(newConfig, null, 2));
78
+ }
79
+
80
+ /**
81
+ * Saves a contact entry to the contact cache.
82
+ * Key is union_id, value is name, email, and open_id.
83
+ */
84
+ export async function saveContactToCache(unionId: string, entry: ContactCacheEntry): Promise<void> {
85
+ const cachePath = getContactCachePath();
86
+ const cacheDir = join(homedir(), ".feishu-agent");
87
+
88
+ let contactCache: Record<string, ContactCacheEntry> = {};
89
+ try {
90
+ const file = Bun.file(cachePath);
91
+ if (await file.exists()) {
92
+ contactCache = await file.json();
93
+ }
94
+ } catch {
95
+ // Ignore errors
96
+ }
97
+
98
+ contactCache[unionId] = entry;
99
+
100
+ await mkdir(cacheDir, { recursive: true });
101
+ await Bun.write(cachePath, JSON.stringify(contactCache, null, 2));
102
+ }
103
+
104
+ /**
105
+ * Loads all cached contacts.
106
+ */
107
+ export async function loadContactCache(): Promise<Record<string, ContactCacheEntry>> {
108
+ const cachePath = getContactCachePath();
109
+ try {
110
+ const file = Bun.file(cachePath);
111
+ if (await file.exists()) {
112
+ return await file.json();
113
+ }
114
+ } catch {
115
+ // Ignore errors
116
+ }
117
+ return {};
118
+ }
119
+
120
+ /**
121
+ * Searches cached contacts by name or email.
122
+ */
123
+ export async function searchContactCache(query: string): Promise<{ union_id: string; user_id?: string; name: string; email?: string }[]> {
124
+ const cache = await loadContactCache();
125
+
126
+ if (!query) return [];
127
+
128
+ const lowerQuery = query.toLowerCase();
129
+
130
+ const results: { union_id: string; user_id?: string; name: string; email?: string }[] = [];
131
+ for (const [unionId, entry] of Object.entries(cache)) {
132
+ if (
133
+ entry.name.toLowerCase().includes(lowerQuery) ||
134
+ (entry.email && entry.email.toLowerCase() === lowerQuery)
135
+ ) {
136
+ results.push({ union_id: unionId, user_id: entry.user_id, name: entry.name, email: entry.email });
137
+ }
138
+ }
139
+ return results;
140
+ }
141
+
142
+ /**
143
+ * Gets a contact from cache by union_id or user_id.
144
+ */
145
+ export async function getContactFromCache(id: string): Promise<ContactCacheEntry | undefined> {
146
+ const cache = await loadContactCache();
147
+ // Direct lookup by union_id
148
+ if (cache[id]) return cache[id];
149
+ // Lookup by user_id
150
+ for (const [unionId, entry] of Object.entries(cache)) {
151
+ if (entry.user_id === id) return entry;
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ /**
157
+ * Get union_id by user_id or vice versa
158
+ */
159
+ export async function resolveContactId(id: string): Promise<{ union_id: string; user_id?: string } | undefined> {
160
+ const cache = await loadContactCache();
161
+ // If id looks like union_id (starts with on_), return it directly
162
+ if (id.startsWith('on_')) {
163
+ const entry = cache[id];
164
+ if (entry) return { union_id: id, user_id: entry.user_id };
165
+ return undefined;
166
+ }
167
+ // Otherwise, search for user_id
168
+ for (const [unionId, entry] of Object.entries(cache)) {
169
+ if (entry.user_id === id) {
170
+ return { union_id: unionId, user_id: id };
171
+ }
172
+ }
173
+ return undefined;
174
+ }
@@ -0,0 +1,83 @@
1
+ import { FeishuClient } from "./client";
2
+ import { ListUsersResponse, UserInfo } from "../types";
3
+ import { saveContactToCache, searchContactCache } from "./config";
4
+
5
+ export class ContactManager {
6
+ private client: FeishuClient;
7
+
8
+ constructor(client: FeishuClient) {
9
+ this.client = client;
10
+ }
11
+
12
+ /**
13
+ * Search users by name or email.
14
+ * First checks cache, then falls back to API if no matches found.
15
+ */
16
+ async searchUser(query: string): Promise<UserInfo[]> {
17
+ if (!query) return [];
18
+
19
+ // 1. Try cache first
20
+ const cachedResults = await searchContactCache(query);
21
+ if (cachedResults.length > 0) {
22
+ console.log(`Found ${cachedResults.length} contact(s) in cache for "${query}"`);
23
+ return cachedResults.map(c => ({
24
+ user_id: c.user_id,
25
+ union_id: c.union_id,
26
+ name: c.name,
27
+ email: c.email,
28
+ }));
29
+ }
30
+
31
+ // 2. Fall back to API
32
+ console.log(`No cache matches for "${query}", searching Feishu API...`);
33
+
34
+ // List users from root department with union_id
35
+ const users = await this.listUsers("0");
36
+
37
+ // Filter by name or email
38
+ const lowerQuery = query.toLowerCase();
39
+ const results = users.filter(u =>
40
+ u.name.toLowerCase().includes(lowerQuery) ||
41
+ (u.en_name && u.en_name.toLowerCase().includes(lowerQuery)) ||
42
+ (u.email && u.email.toLowerCase().includes(lowerQuery))
43
+ );
44
+
45
+ // Cache the results (key = union_id)
46
+ for (const user of results) {
47
+ if (user.union_id && user.name) {
48
+ await saveContactToCache(user.union_id, {
49
+ name: user.name,
50
+ email: user.email,
51
+ user_id: user.user_id,
52
+ open_id: user.open_id,
53
+ });
54
+ }
55
+ }
56
+
57
+ if (results.length > 0) {
58
+ console.log(`Found ${results.length} contact(s), cached for future use.`);
59
+ }
60
+
61
+ return results;
62
+ }
63
+
64
+ /**
65
+ * List users in a specific department
66
+ */
67
+ async listUsers(departmentId: string = "0", pageSize: number = 50, pageToken?: string): Promise<UserInfo[]> {
68
+ const params: Record<string, string> = {
69
+ department_id: departmentId,
70
+ page_size: pageSize.toString(),
71
+ user_id_type: "union_id", // Request union_id for calendar attendee compatibility
72
+ };
73
+ if (pageToken) params.page_token = pageToken;
74
+
75
+ try {
76
+ const res = await this.client.get<ListUsersResponse["data"]>("/open-apis/contact/v3/users", params);
77
+ return res.items || [];
78
+ } catch (error) {
79
+ console.warn(`Failed to list users for department ${departmentId}:`, error);
80
+ return [];
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,103 @@
1
+ import { FeishuClient } from "./client";
2
+ import { ListTablesResponse, ListFieldsResponse, FeishuTable, FeishuField } from "../types";
3
+
4
+ export interface Schema {
5
+ baseToken: string;
6
+ tables: TableSchema[];
7
+ }
8
+
9
+ export interface TableSchema {
10
+ id: string;
11
+ name: string;
12
+ fields: FeishuField[];
13
+ }
14
+
15
+ export class IntrospectionEngine {
16
+ private client: FeishuClient;
17
+
18
+ constructor(client: FeishuClient) {
19
+ this.client = client;
20
+ }
21
+
22
+ async introspect(baseToken: string, onProgress?: (msg: string) => void): Promise<Schema> {
23
+ const tables = await this.listTables(baseToken, onProgress);
24
+
25
+ const tableSchemas: TableSchema[] = await Promise.all(
26
+ tables.map(async (table) => {
27
+ onProgress?.(`Fetching fields for table ${table.name} (${table.table_id})...`);
28
+ const fields = await this.listFields(baseToken, table.table_id);
29
+ return {
30
+ id: table.table_id,
31
+ name: table.name,
32
+ fields,
33
+ };
34
+ })
35
+ );
36
+
37
+ return {
38
+ baseToken,
39
+ tables: tableSchemas,
40
+ };
41
+ }
42
+
43
+ private async listTables(baseToken: string, onProgress?: (msg: string) => void): Promise<FeishuTable[]> {
44
+ onProgress?.("Fetching tables list...");
45
+ let hasMore = true;
46
+ let pageToken = "";
47
+ const allTables: FeishuTable[] = [];
48
+
49
+ while (hasMore) {
50
+ const query: Record<string, string> = {};
51
+ if (pageToken) query.page_token = pageToken;
52
+
53
+ const data = await this.client.get<ListTablesResponse["data"]>(
54
+ `/open-apis/bitable/v1/apps/${baseToken}/tables`,
55
+ query
56
+ );
57
+
58
+ if (data.items) {
59
+ allTables.push(...data.items);
60
+ }
61
+
62
+ if (hasMore && !data.page_token) {
63
+ break; // Safety break
64
+ }
65
+
66
+ hasMore = data.has_more;
67
+ pageToken = data.page_token;
68
+ }
69
+
70
+ onProgress?.(`Found ${allTables.length} tables.`);
71
+ return allTables;
72
+ }
73
+
74
+ private async listFields(baseToken: string, tableId: string): Promise<FeishuField[]> {
75
+ let hasMore = true;
76
+ let pageToken = "";
77
+ const allFields: FeishuField[] = [];
78
+
79
+ while (hasMore) {
80
+ const query: Record<string, string> = {};
81
+ if (pageToken) query.page_token = pageToken;
82
+
83
+ const data = await this.client.get<ListFieldsResponse["data"]>(
84
+ `/open-apis/bitable/v1/apps/${baseToken}/tables/${tableId}/fields`,
85
+ query
86
+ );
87
+
88
+ if (data.items) {
89
+ allFields.push(...data.items);
90
+ }
91
+
92
+ if (hasMore && !data.page_token) {
93
+ break; // Safety break
94
+ }
95
+
96
+ hasMore = data.has_more;
97
+ pageToken = data.page_token;
98
+ }
99
+
100
+ return allFields;
101
+ }
102
+
103
+ }
@@ -0,0 +1,109 @@
1
+ import { FeishuClient } from "./client";
2
+
3
+ export interface TodoItem {
4
+ record_id: string;
5
+ fields: {
6
+ Title: string;
7
+ Done: boolean;
8
+ Priority?: "High" | "Medium" | "Low";
9
+ [key: string]: any;
10
+ };
11
+ }
12
+
13
+ export class TodoManager {
14
+ private client: FeishuClient;
15
+ private appToken: string;
16
+ private tableId?: string;
17
+
18
+ constructor(client: FeishuClient, appToken: string) {
19
+ this.client = client;
20
+ this.appToken = appToken;
21
+ }
22
+
23
+ /**
24
+ * Find a table by name, or return the first table if no name provided.
25
+ */
26
+ async findTable(tableName: string = "Todo"): Promise<string> {
27
+ if (this.tableId) return this.tableId;
28
+
29
+ let pageToken = "";
30
+ let hasMore = true;
31
+
32
+ while (hasMore) {
33
+ const res: any = await this.client.get(`/open-apis/bitable/v1/apps/${this.appToken}/tables`, {
34
+ page_token: pageToken,
35
+ page_size: "100"
36
+ });
37
+
38
+ const tables = res.data.items || [];
39
+ const target = tables.find((t: any) => t.name === tableName);
40
+
41
+ if (target) {
42
+ this.tableId = target.table_id;
43
+ return target.table_id;
44
+ }
45
+
46
+ hasMore = res.data.has_more;
47
+ pageToken = res.data.page_token;
48
+ }
49
+
50
+ throw new Error(`Table "${tableName}" not found in Base ${this.appToken}`);
51
+ }
52
+
53
+ async listTodos(viewId?: string): Promise<TodoItem[]> {
54
+ const tableId = await this.findTable();
55
+ const params: Record<string, string> = { page_size: "100" };
56
+ if (viewId) params.view_id = viewId;
57
+
58
+ // Filter by formula could be added here if needed
59
+ // params.filter = 'CurrentValue.[Done]=FALSE()';
60
+
61
+ const res: any = await this.client.get(
62
+ `/open-apis/bitable/v1/apps/${this.appToken}/tables/${tableId}/records`,
63
+ params
64
+ );
65
+
66
+ return (res.data.items || []).map((item: any) => ({
67
+ record_id: item.record_id,
68
+ fields: item.fields
69
+ }));
70
+ }
71
+
72
+ async createTodo(title: string, priority: string = "Medium"): Promise<TodoItem> {
73
+ const tableId = await this.findTable();
74
+ const body = {
75
+ fields: {
76
+ "Title": title,
77
+ "Done": false,
78
+ "Priority": priority
79
+ }
80
+ };
81
+
82
+ const res: any = await this.client.post(
83
+ `/open-apis/bitable/v1/apps/${this.appToken}/tables/${tableId}/records`,
84
+ body
85
+ );
86
+
87
+ return {
88
+ record_id: res.data.record.record_id,
89
+ fields: res.data.record.fields
90
+ };
91
+ }
92
+
93
+ async markDone(recordId: string): Promise<void> {
94
+ const tableId = await this.findTable();
95
+ const body = {
96
+ fields: {
97
+ "Done": true
98
+ }
99
+ };
100
+
101
+ await this.client.request(
102
+ `/open-apis/bitable/v1/apps/${this.appToken}/tables/${tableId}/records/${recordId}`,
103
+ {
104
+ method: "PUT",
105
+ body: JSON.stringify(body)
106
+ }
107
+ );
108
+ }
109
+ }
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { setupCommand } from "./cli/commands/setup";
4
+ import { authCommand } from "./cli/commands/auth";
5
+ import { whoamiCommand } from "./cli/commands/whoami";
6
+ import { loadConfig } from "./core/config";
7
+ import { createConfigCommands } from "./cli/commands/config";
8
+ import { createCalendarCommands } from "./cli/commands/calendar";
9
+ import { createTodoCommands } from "./cli/commands/todo";
10
+ import { createContactCommands } from "./cli/commands/contact";
11
+
12
+ async function main() {
13
+ const program = new Command();
14
+
15
+ program
16
+ .name("feishu_agent")
17
+ .description("Feishu Agent CLI for AI assistants")
18
+ .version("1.0.0");
19
+
20
+ program
21
+ .command("setup")
22
+ .description("Initialize configuration")
23
+ .action(setupCommand);
24
+
25
+ program
26
+ .command("auth")
27
+ .description("Authenticate with Feishu OAuth")
28
+ .action(authCommand);
29
+
30
+ program
31
+ .command("whoami")
32
+ .description("Show current user info")
33
+ .action(whoamiCommand);
34
+
35
+ // Load config for commands that need it
36
+ const config = await loadConfig();
37
+
38
+ // Config subcommand group
39
+ const configCmd = program
40
+ .command("config")
41
+ .description("Manage configuration");
42
+ createConfigCommands(configCmd);
43
+
44
+ // Calendar subcommand group
45
+ const calendarCmd = program
46
+ .command("calendar")
47
+ .description("Manage calendar events");
48
+ createCalendarCommands(calendarCmd, config);
49
+
50
+ // Todo subcommand group
51
+ const todoCmd = program
52
+ .command("todo")
53
+ .description("Manage todos");
54
+ createTodoCommands(todoCmd, config);
55
+
56
+ // Contact subcommand group
57
+ const contactCmd = program
58
+ .command("contact")
59
+ .description("Manage contacts");
60
+ createContactCommands(contactCmd, config);
61
+
62
+ await program.parseAsync();
63
+ }
64
+
65
+ main().catch((err) => {
66
+ console.error("Error:", err.message);
67
+ process.exit(1);
68
+ });
69
+
70
+ // Export modules for library usage
71
+ export * from "./core/client";
72
+ export * from "./core/auth-manager";
73
+ export * from "./core/calendar";
74
+ export * from "./core/contact";
75
+ export * from "./core/todo";
76
+ export * from "./types";