@nextclaw/channel-plugin-feishu 0.2.29-beta.0 → 0.2.29-beta.1
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/dist/index.d.ts +23 -0
- package/dist/index.js +45 -0
- package/dist/src/accounts.js +141 -0
- package/dist/src/app-scope-checker.js +36 -0
- package/dist/src/async.js +34 -0
- package/dist/src/auth-errors.js +72 -0
- package/dist/src/bitable.js +495 -0
- package/dist/src/bot.d.ts +35 -0
- package/dist/src/bot.js +941 -0
- package/dist/src/calendar-calendar.js +54 -0
- package/dist/src/calendar-event-attendee.js +98 -0
- package/dist/src/calendar-event.js +193 -0
- package/dist/src/calendar-freebusy.js +40 -0
- package/dist/src/calendar-shared.js +23 -0
- package/dist/src/calendar.js +16 -0
- package/dist/src/card-action.js +49 -0
- package/dist/src/channel.d.ts +7 -0
- package/dist/src/channel.js +413 -0
- package/dist/src/chat-schema.js +25 -0
- package/dist/src/chat.js +87 -0
- package/dist/src/client.d.ts +16 -0
- package/dist/src/client.js +112 -0
- package/dist/src/config-schema.d.ts +357 -0
- package/dist/src/dedup.js +126 -0
- package/dist/src/device-flow.js +109 -0
- package/dist/src/directory.js +101 -0
- package/dist/src/doc-schema.js +148 -0
- package/dist/src/docx-batch-insert.js +104 -0
- package/dist/src/docx-color-text.js +80 -0
- package/dist/src/docx-table-ops.js +197 -0
- package/dist/src/docx.js +858 -0
- package/dist/src/domains.js +14 -0
- package/dist/src/drive-schema.js +41 -0
- package/dist/src/drive.js +126 -0
- package/dist/src/dynamic-agent.js +93 -0
- package/dist/src/external-keys.js +13 -0
- package/dist/src/feishu-fetch.js +12 -0
- package/dist/src/identity.js +92 -0
- package/dist/src/lark-ticket.js +11 -0
- package/dist/src/media.d.ts +75 -0
- package/dist/src/media.js +304 -0
- package/dist/src/mention.d.ts +52 -0
- package/dist/src/mention.js +82 -0
- package/dist/src/monitor.account.d.ts +1 -0
- package/dist/src/monitor.account.js +393 -0
- package/dist/src/monitor.d.ts +11 -0
- package/dist/src/monitor.js +58 -0
- package/dist/src/monitor.startup.js +24 -0
- package/dist/src/monitor.state.d.ts +1 -0
- package/dist/src/monitor.state.js +80 -0
- package/dist/src/monitor.transport.js +167 -0
- package/dist/src/nextclaw-sdk/account-id.js +15 -0
- package/dist/src/nextclaw-sdk/core-channel.js +150 -0
- package/dist/src/nextclaw-sdk/core-pairing.js +151 -0
- package/dist/src/nextclaw-sdk/dedupe.js +164 -0
- package/dist/src/nextclaw-sdk/feishu.d.ts +1 -0
- package/dist/src/nextclaw-sdk/feishu.js +14 -0
- package/dist/src/nextclaw-sdk/history.js +69 -0
- package/dist/src/nextclaw-sdk/network-body.js +180 -0
- package/dist/src/nextclaw-sdk/network-fetch.js +63 -0
- package/dist/src/nextclaw-sdk/network-webhook.js +126 -0
- package/dist/src/nextclaw-sdk/network.js +4 -0
- package/dist/src/nextclaw-sdk/runtime-store.js +21 -0
- package/dist/src/nextclaw-sdk/secrets-config.js +65 -0
- package/dist/src/nextclaw-sdk/secrets-core.d.ts +1 -0
- package/dist/src/nextclaw-sdk/secrets-core.js +68 -0
- package/dist/src/nextclaw-sdk/secrets-prompt.js +193 -0
- package/dist/src/nextclaw-sdk/secrets.d.ts +1 -0
- package/dist/src/nextclaw-sdk/secrets.js +4 -0
- package/dist/src/nextclaw-sdk/types.d.ts +242 -0
- package/dist/src/oauth.js +171 -0
- package/dist/src/onboarding.js +381 -0
- package/dist/src/outbound.js +150 -0
- package/dist/src/perm-schema.js +49 -0
- package/dist/src/perm.js +90 -0
- package/dist/src/policy.js +61 -0
- package/dist/src/post.js +160 -0
- package/dist/src/probe.d.ts +11 -0
- package/dist/src/probe.js +85 -0
- package/dist/src/raw-request.js +24 -0
- package/dist/src/reactions.d.ts +67 -0
- package/dist/src/reactions.js +91 -0
- package/dist/src/reply-dispatcher.js +250 -0
- package/dist/src/runtime.js +5 -0
- package/dist/src/secret-input.js +3 -0
- package/dist/src/send-result.js +12 -0
- package/dist/src/send-target.js +22 -0
- package/dist/src/send.d.ts +51 -0
- package/dist/src/send.js +265 -0
- package/dist/src/sheets-shared.js +193 -0
- package/dist/src/sheets.js +95 -0
- package/dist/src/streaming-card.js +263 -0
- package/dist/src/targets.js +39 -0
- package/dist/src/task-comment.js +76 -0
- package/dist/src/task-shared.js +13 -0
- package/dist/src/task-subtask.js +79 -0
- package/dist/src/task-task.js +144 -0
- package/dist/src/task-tasklist.js +136 -0
- package/dist/src/task.js +16 -0
- package/dist/src/token-store.js +154 -0
- package/dist/src/tool-account.js +65 -0
- package/dist/src/tool-result.js +18 -0
- package/dist/src/tool-scopes.js +62 -0
- package/dist/src/tools-config.js +30 -0
- package/dist/src/types.d.ts +43 -0
- package/dist/src/typing.js +145 -0
- package/dist/src/uat-client.js +102 -0
- package/dist/src/user-tool-client.js +132 -0
- package/dist/src/user-tool-helpers.js +110 -0
- package/dist/src/user-tool-result.js +10 -0
- package/dist/src/wiki-schema.js +45 -0
- package/dist/src/wiki.js +144 -0
- package/package.json +8 -4
- package/index.ts +0 -75
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/tools-config.ts
|
|
2
|
+
/**
|
|
3
|
+
* Default tool configuration.
|
|
4
|
+
* - doc, chat, wiki, drive, scopes: enabled by default
|
|
5
|
+
* - perm: disabled by default (sensitive operation)
|
|
6
|
+
*/
|
|
7
|
+
const DEFAULT_TOOLS_CONFIG = {
|
|
8
|
+
doc: true,
|
|
9
|
+
chat: true,
|
|
10
|
+
wiki: true,
|
|
11
|
+
drive: true,
|
|
12
|
+
perm: false,
|
|
13
|
+
scopes: true,
|
|
14
|
+
calendar: true,
|
|
15
|
+
task: true,
|
|
16
|
+
sheets: true,
|
|
17
|
+
oauth: true,
|
|
18
|
+
identity: true
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Resolve tools config with defaults.
|
|
22
|
+
*/
|
|
23
|
+
function resolveToolsConfig(cfg) {
|
|
24
|
+
return {
|
|
25
|
+
...DEFAULT_TOOLS_CONFIG,
|
|
26
|
+
...cfg
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { resolveToolsConfig };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { BaseProbeResult } from "./nextclaw-sdk/types.js";
|
|
2
|
+
import { FeishuConfigSchema, z } from "./config-schema.js";
|
|
3
|
+
//#region src/types.d.ts
|
|
4
|
+
type FeishuConfig = z.infer<typeof FeishuConfigSchema>;
|
|
5
|
+
type FeishuDomain = "feishu" | "lark" | (string & {});
|
|
6
|
+
type FeishuDefaultAccountSelectionSource = "explicit-default" | "mapped-default" | "fallback";
|
|
7
|
+
type FeishuAccountSelectionSource = "explicit" | FeishuDefaultAccountSelectionSource;
|
|
8
|
+
type ResolvedFeishuAccount = {
|
|
9
|
+
accountId: string;
|
|
10
|
+
selectionSource: FeishuAccountSelectionSource;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
configured: boolean;
|
|
13
|
+
name?: string;
|
|
14
|
+
appId?: string;
|
|
15
|
+
appSecret?: string;
|
|
16
|
+
encryptKey?: string;
|
|
17
|
+
verificationToken?: string;
|
|
18
|
+
domain: FeishuDomain; /** Merged config (top-level defaults + account-specific overrides) */
|
|
19
|
+
config: FeishuConfig;
|
|
20
|
+
};
|
|
21
|
+
type FeishuSendResult = {
|
|
22
|
+
messageId: string;
|
|
23
|
+
chatId: string;
|
|
24
|
+
};
|
|
25
|
+
type FeishuChatType = "p2p" | "group" | "private";
|
|
26
|
+
type FeishuMessageInfo = {
|
|
27
|
+
messageId: string;
|
|
28
|
+
chatId: string;
|
|
29
|
+
chatType?: FeishuChatType;
|
|
30
|
+
senderId?: string;
|
|
31
|
+
senderOpenId?: string;
|
|
32
|
+
senderType?: string;
|
|
33
|
+
content: string;
|
|
34
|
+
contentType: string;
|
|
35
|
+
createTime?: number;
|
|
36
|
+
};
|
|
37
|
+
type FeishuProbeResult = BaseProbeResult<string> & {
|
|
38
|
+
appId?: string;
|
|
39
|
+
botName?: string;
|
|
40
|
+
botOpenId?: string;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
export { FeishuConfig, FeishuDomain, FeishuMessageInfo, FeishuProbeResult, FeishuSendResult, ResolvedFeishuAccount };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
2
|
+
import { createFeishuClient } from "./client.js";
|
|
3
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
4
|
+
//#region src/typing.ts
|
|
5
|
+
const TYPING_EMOJI = "THUMBSUP";
|
|
6
|
+
/**
|
|
7
|
+
* Feishu API error codes that indicate the caller should back off.
|
|
8
|
+
* These must propagate to the typing circuit breaker so the keepalive loop
|
|
9
|
+
* can trip and stop retrying.
|
|
10
|
+
*
|
|
11
|
+
* - 99991400: Rate limit (too many requests per second)
|
|
12
|
+
* - 99991403: Monthly API call quota exceeded
|
|
13
|
+
* - 429: Standard HTTP 429 returned as a Feishu SDK error code
|
|
14
|
+
*
|
|
15
|
+
* @see https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
|
|
16
|
+
*/
|
|
17
|
+
const FEISHU_BACKOFF_CODES = new Set([
|
|
18
|
+
99991400,
|
|
19
|
+
99991403,
|
|
20
|
+
429
|
|
21
|
+
]);
|
|
22
|
+
/**
|
|
23
|
+
* Custom error class for Feishu backoff conditions detected from non-throwing
|
|
24
|
+
* SDK responses. Carries a numeric `.code` so that `isFeishuBackoffError()`
|
|
25
|
+
* recognises it when the error is caught downstream.
|
|
26
|
+
*/
|
|
27
|
+
var FeishuBackoffError = class extends Error {
|
|
28
|
+
code;
|
|
29
|
+
constructor(code) {
|
|
30
|
+
super(`Feishu API backoff: code ${code}`);
|
|
31
|
+
this.name = "FeishuBackoffError";
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Check whether an error represents a rate-limit or quota-exceeded condition
|
|
37
|
+
* from the Feishu API that should stop the typing keepalive loop.
|
|
38
|
+
*
|
|
39
|
+
* Handles two shapes:
|
|
40
|
+
* 1. AxiosError with `response.status` and `response.data.code`
|
|
41
|
+
* 2. Feishu SDK error with a top-level `code` property
|
|
42
|
+
*/
|
|
43
|
+
function isFeishuBackoffError(err) {
|
|
44
|
+
if (typeof err !== "object" || err === null) return false;
|
|
45
|
+
const response = err.response;
|
|
46
|
+
if (response) {
|
|
47
|
+
if (response.status === 429) return true;
|
|
48
|
+
if (typeof response.data?.code === "number" && FEISHU_BACKOFF_CODES.has(response.data.code)) return true;
|
|
49
|
+
}
|
|
50
|
+
const code = err.code;
|
|
51
|
+
if (typeof code === "number" && FEISHU_BACKOFF_CODES.has(code)) return true;
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Check whether a Feishu SDK response object contains a backoff error code.
|
|
56
|
+
*
|
|
57
|
+
* The Feishu SDK sometimes returns a normal response (no throw) with an
|
|
58
|
+
* API-level error code in the response body. This must be detected so the
|
|
59
|
+
* circuit breaker can trip. See codex review on #28157.
|
|
60
|
+
*/
|
|
61
|
+
function getBackoffCodeFromResponse(response) {
|
|
62
|
+
if (typeof response !== "object" || response === null) return;
|
|
63
|
+
const code = response.code;
|
|
64
|
+
if (typeof code === "number" && FEISHU_BACKOFF_CODES.has(code)) return code;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Add a typing indicator (reaction) to a message.
|
|
68
|
+
*
|
|
69
|
+
* Rate-limit and quota errors are re-thrown so the circuit breaker in
|
|
70
|
+
* `createTypingCallbacks` (typing-start-guard) can trip and stop the
|
|
71
|
+
* keepalive loop. See #28062.
|
|
72
|
+
*
|
|
73
|
+
* Also checks for backoff codes in non-throwing SDK responses (#28157).
|
|
74
|
+
*/
|
|
75
|
+
async function addTypingIndicator(params) {
|
|
76
|
+
const { cfg, messageId, accountId, runtime } = params;
|
|
77
|
+
const account = resolveFeishuAccount({
|
|
78
|
+
cfg,
|
|
79
|
+
accountId
|
|
80
|
+
});
|
|
81
|
+
if (!account.configured) return {
|
|
82
|
+
messageId,
|
|
83
|
+
reactionId: null
|
|
84
|
+
};
|
|
85
|
+
const client = createFeishuClient(account);
|
|
86
|
+
try {
|
|
87
|
+
const response = await client.im.messageReaction.create({
|
|
88
|
+
path: { message_id: messageId },
|
|
89
|
+
data: { reaction_type: { emoji_type: TYPING_EMOJI } }
|
|
90
|
+
});
|
|
91
|
+
const backoffCode = getBackoffCodeFromResponse(response);
|
|
92
|
+
if (backoffCode !== void 0) {
|
|
93
|
+
if (getFeishuRuntime().logging.shouldLogVerbose()) runtime?.log?.(`[feishu] typing indicator response contains backoff code ${backoffCode}, stopping keepalive`);
|
|
94
|
+
throw new FeishuBackoffError(backoffCode);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
messageId,
|
|
98
|
+
reactionId: response?.data?.reaction_id ?? null
|
|
99
|
+
};
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (isFeishuBackoffError(err)) {
|
|
102
|
+
if (getFeishuRuntime().logging.shouldLogVerbose()) runtime?.log?.("[feishu] typing indicator hit rate-limit/quota, stopping keepalive");
|
|
103
|
+
throw err;
|
|
104
|
+
}
|
|
105
|
+
if (getFeishuRuntime().logging.shouldLogVerbose()) runtime?.log?.(`[feishu] failed to add typing indicator: ${String(err)}`);
|
|
106
|
+
return {
|
|
107
|
+
messageId,
|
|
108
|
+
reactionId: null
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Remove a typing indicator (reaction) from a message.
|
|
114
|
+
*
|
|
115
|
+
* Rate-limit and quota errors are re-thrown for the same reason as above.
|
|
116
|
+
*/
|
|
117
|
+
async function removeTypingIndicator(params) {
|
|
118
|
+
if (TYPING_EMOJI !== "Typing") return;
|
|
119
|
+
const { cfg, state, accountId, runtime } = params;
|
|
120
|
+
if (!state.reactionId) return;
|
|
121
|
+
const account = resolveFeishuAccount({
|
|
122
|
+
cfg,
|
|
123
|
+
accountId
|
|
124
|
+
});
|
|
125
|
+
if (!account.configured) return;
|
|
126
|
+
const client = createFeishuClient(account);
|
|
127
|
+
try {
|
|
128
|
+
const backoffCode = getBackoffCodeFromResponse(await client.im.messageReaction.delete({ path: {
|
|
129
|
+
message_id: state.messageId,
|
|
130
|
+
reaction_id: state.reactionId
|
|
131
|
+
} }));
|
|
132
|
+
if (backoffCode !== void 0) {
|
|
133
|
+
if (getFeishuRuntime().logging.shouldLogVerbose()) runtime?.log?.(`[feishu] typing indicator removal response contains backoff code ${backoffCode}, stopping keepalive`);
|
|
134
|
+
throw new FeishuBackoffError(backoffCode);
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (isFeishuBackoffError(err)) {
|
|
138
|
+
if (getFeishuRuntime().logging.shouldLogVerbose()) runtime?.log?.("[feishu] typing indicator removal hit rate-limit/quota, stopping keepalive");
|
|
139
|
+
throw err;
|
|
140
|
+
}
|
|
141
|
+
if (getFeishuRuntime().logging.shouldLogVerbose()) runtime?.log?.(`[feishu] failed to remove typing indicator: ${String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
export { addTypingIndicator, removeTypingIndicator };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { NeedAuthorizationError, REFRESH_TOKEN_RETRYABLE, TOKEN_RETRY_CODES } from "./auth-errors.js";
|
|
2
|
+
import { feishuFetch } from "./feishu-fetch.js";
|
|
3
|
+
import { resolveOAuthEndpoints } from "./device-flow.js";
|
|
4
|
+
import { getStoredToken, removeStoredToken, setStoredToken, tokenStatus } from "./token-store.js";
|
|
5
|
+
//#region src/uat-client.ts
|
|
6
|
+
const refreshLocks = /* @__PURE__ */ new Map();
|
|
7
|
+
async function doRefreshToken(opts, stored) {
|
|
8
|
+
if (Date.now() >= stored.refreshExpiresAt) {
|
|
9
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const endpoints = resolveOAuthEndpoints(opts.domain);
|
|
13
|
+
const requestBody = new URLSearchParams({
|
|
14
|
+
grant_type: "refresh_token",
|
|
15
|
+
refresh_token: stored.refreshToken,
|
|
16
|
+
client_id: opts.appId,
|
|
17
|
+
client_secret: opts.appSecret
|
|
18
|
+
}).toString();
|
|
19
|
+
const callEndpoint = async () => {
|
|
20
|
+
return await (await feishuFetch(endpoints.token, {
|
|
21
|
+
method: "POST",
|
|
22
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
23
|
+
body: requestBody
|
|
24
|
+
})).json();
|
|
25
|
+
};
|
|
26
|
+
let data = await callEndpoint();
|
|
27
|
+
const code = typeof data.code === "number" ? data.code : void 0;
|
|
28
|
+
const error = typeof data.error === "string" ? data.error : void 0;
|
|
29
|
+
if (code !== void 0 && code !== 0 || error) if (code !== void 0 && REFRESH_TOKEN_RETRYABLE.has(code)) {
|
|
30
|
+
data = await callEndpoint();
|
|
31
|
+
const retryCode = typeof data.code === "number" ? data.code : void 0;
|
|
32
|
+
const retryError = typeof data.error === "string" ? data.error : void 0;
|
|
33
|
+
if (retryCode !== void 0 && retryCode !== 0 || retryError) {
|
|
34
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (!data.access_token) throw new Error("Token refresh returned no access_token");
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const updated = {
|
|
44
|
+
userOpenId: stored.userOpenId,
|
|
45
|
+
appId: opts.appId,
|
|
46
|
+
accessToken: String(data.access_token),
|
|
47
|
+
refreshToken: String(data.refresh_token ?? stored.refreshToken),
|
|
48
|
+
expiresAt: now + Number(data.expires_in ?? 7200) * 1e3,
|
|
49
|
+
refreshExpiresAt: data.refresh_token_expires_in ? now + Number(data.refresh_token_expires_in) * 1e3 : stored.refreshExpiresAt,
|
|
50
|
+
scope: String(data.scope ?? stored.scope),
|
|
51
|
+
grantedAt: stored.grantedAt
|
|
52
|
+
};
|
|
53
|
+
await setStoredToken(updated);
|
|
54
|
+
return updated;
|
|
55
|
+
}
|
|
56
|
+
async function refreshWithLock(opts, stored) {
|
|
57
|
+
const key = `${opts.appId}:${opts.userOpenId}`;
|
|
58
|
+
const existing = refreshLocks.get(key);
|
|
59
|
+
if (existing) {
|
|
60
|
+
await existing;
|
|
61
|
+
return getStoredToken(opts.appId, opts.userOpenId);
|
|
62
|
+
}
|
|
63
|
+
const promise = doRefreshToken(opts, stored);
|
|
64
|
+
refreshLocks.set(key, promise);
|
|
65
|
+
try {
|
|
66
|
+
return await promise;
|
|
67
|
+
} finally {
|
|
68
|
+
refreshLocks.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function getValidAccessToken(opts) {
|
|
72
|
+
const stored = await getStoredToken(opts.appId, opts.userOpenId);
|
|
73
|
+
if (!stored) throw new NeedAuthorizationError(opts.userOpenId);
|
|
74
|
+
const status = tokenStatus(stored);
|
|
75
|
+
if (status === "valid") return stored.accessToken;
|
|
76
|
+
if (status === "needs_refresh") {
|
|
77
|
+
const refreshed = await refreshWithLock(opts, stored);
|
|
78
|
+
if (!refreshed) throw new NeedAuthorizationError(opts.userOpenId);
|
|
79
|
+
return refreshed.accessToken;
|
|
80
|
+
}
|
|
81
|
+
await removeStoredToken(opts.appId, opts.userOpenId);
|
|
82
|
+
throw new NeedAuthorizationError(opts.userOpenId);
|
|
83
|
+
}
|
|
84
|
+
async function callWithUAT(opts, apiCall) {
|
|
85
|
+
const accessToken = await getValidAccessToken(opts);
|
|
86
|
+
try {
|
|
87
|
+
return await apiCall(accessToken);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const code = error.code ?? error.response?.data?.code;
|
|
90
|
+
if (!TOKEN_RETRY_CODES.has(Number(code))) throw error;
|
|
91
|
+
const stored = await getStoredToken(opts.appId, opts.userOpenId);
|
|
92
|
+
if (!stored) throw new NeedAuthorizationError(opts.userOpenId);
|
|
93
|
+
const refreshed = await refreshWithLock(opts, stored);
|
|
94
|
+
if (!refreshed) throw new NeedAuthorizationError(opts.userOpenId);
|
|
95
|
+
return apiCall(refreshed.accessToken);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function revokeUAT(appId, userOpenId) {
|
|
99
|
+
await removeStoredToken(appId, userOpenId);
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { callWithUAT, revokeUAT };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { AppScopeMissingError, LARK_ERROR, NeedAuthorizationError, UserAuthRequiredError, UserScopeInsufficientError } from "./auth-errors.js";
|
|
2
|
+
import { getRequiredScopes } from "./tool-scopes.js";
|
|
3
|
+
import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js";
|
|
4
|
+
import { getAppGrantedScopes, invalidateAppScopeCache, missingScopes } from "./app-scope-checker.js";
|
|
5
|
+
import { createFeishuClient } from "./client.js";
|
|
6
|
+
import { getTicket } from "./lark-ticket.js";
|
|
7
|
+
import { rawLarkRequest } from "./raw-request.js";
|
|
8
|
+
import { getStoredToken } from "./token-store.js";
|
|
9
|
+
import { callWithUAT } from "./uat-client.js";
|
|
10
|
+
import * as Lark from "@larksuiteoapi/node-sdk";
|
|
11
|
+
//#region src/user-tool-client.ts
|
|
12
|
+
function assertConfiguredAccount(account) {
|
|
13
|
+
if (!account.enabled) throw new Error(`Feishu account "${account.accountId}" is disabled.`);
|
|
14
|
+
if (!account.configured || !account.appId || !account.appSecret) throw new Error(`Feishu account "${account.accountId}" is not configured.`);
|
|
15
|
+
return account;
|
|
16
|
+
}
|
|
17
|
+
function resolveConfiguredAccount(config, accountIndex = 0) {
|
|
18
|
+
const ticket = getTicket();
|
|
19
|
+
if (ticket?.accountId) return assertConfiguredAccount(resolveFeishuAccount({
|
|
20
|
+
cfg: config,
|
|
21
|
+
accountId: ticket.accountId
|
|
22
|
+
}));
|
|
23
|
+
const accounts = listEnabledFeishuAccounts(config);
|
|
24
|
+
if (accounts.length === 0) throw new Error("No enabled Feishu accounts configured.");
|
|
25
|
+
return assertConfiguredAccount(accounts[Math.min(accountIndex, accounts.length - 1)]);
|
|
26
|
+
}
|
|
27
|
+
var UserToolClient = class {
|
|
28
|
+
account;
|
|
29
|
+
senderOpenId;
|
|
30
|
+
sdk;
|
|
31
|
+
constructor(config, accountIndex = 0) {
|
|
32
|
+
this.config = config;
|
|
33
|
+
this.account = resolveConfiguredAccount(config, accountIndex);
|
|
34
|
+
this.senderOpenId = getTicket()?.senderOpenId;
|
|
35
|
+
this.sdk = createFeishuClient(this.account);
|
|
36
|
+
}
|
|
37
|
+
async invoke(toolAction, fn, options) {
|
|
38
|
+
const requiredScopes = getRequiredScopes(toolAction);
|
|
39
|
+
const tokenType = options?.as ?? "user";
|
|
40
|
+
const appScopeVerified = await this.verifyAppScopes(requiredScopes, tokenType, toolAction);
|
|
41
|
+
if (tokenType === "tenant") try {
|
|
42
|
+
return await fn(this.sdk);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
this.rethrowStructuredError(error, toolAction, requiredScopes);
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const userOpenId = options?.userOpenId ?? this.senderOpenId;
|
|
48
|
+
if (!userOpenId) throw new UserAuthRequiredError("unknown", {
|
|
49
|
+
apiName: toolAction,
|
|
50
|
+
scopes: requiredScopes,
|
|
51
|
+
appScopeVerified,
|
|
52
|
+
appId: this.account.appId
|
|
53
|
+
});
|
|
54
|
+
const stored = await getStoredToken(this.account.appId, userOpenId);
|
|
55
|
+
if (!stored) throw new UserAuthRequiredError(userOpenId, {
|
|
56
|
+
apiName: toolAction,
|
|
57
|
+
scopes: requiredScopes,
|
|
58
|
+
appScopeVerified,
|
|
59
|
+
appId: this.account.appId
|
|
60
|
+
});
|
|
61
|
+
if (appScopeVerified && stored.scope && requiredScopes.length > 0) {
|
|
62
|
+
const granted = new Set(stored.scope.split(/\s+/).filter(Boolean));
|
|
63
|
+
const missingUserScopes = requiredScopes.filter((scope) => !granted.has(scope));
|
|
64
|
+
if (missingUserScopes.length > 0) throw new UserAuthRequiredError(userOpenId, {
|
|
65
|
+
apiName: toolAction,
|
|
66
|
+
scopes: missingUserScopes,
|
|
67
|
+
appScopeVerified,
|
|
68
|
+
appId: this.account.appId
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
return await callWithUAT({
|
|
73
|
+
userOpenId,
|
|
74
|
+
appId: this.account.appId,
|
|
75
|
+
appSecret: this.account.appSecret,
|
|
76
|
+
domain: this.account.domain
|
|
77
|
+
}, (accessToken) => fn(this.sdk, Lark.withUserAccessToken(accessToken), accessToken));
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error instanceof NeedAuthorizationError) throw new UserAuthRequiredError(userOpenId, {
|
|
80
|
+
apiName: toolAction,
|
|
81
|
+
scopes: requiredScopes,
|
|
82
|
+
appScopeVerified,
|
|
83
|
+
appId: this.account.appId
|
|
84
|
+
});
|
|
85
|
+
this.rethrowStructuredError(error, toolAction, requiredScopes, userOpenId);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async invokeByPath(toolAction, path, options) {
|
|
90
|
+
return this.invoke(toolAction, async (_sdk, _opts, uat) => rawLarkRequest({
|
|
91
|
+
domain: this.account.domain,
|
|
92
|
+
path,
|
|
93
|
+
method: options?.method,
|
|
94
|
+
body: options?.body,
|
|
95
|
+
query: options?.query,
|
|
96
|
+
headers: options?.headers,
|
|
97
|
+
accessToken: uat
|
|
98
|
+
}), options);
|
|
99
|
+
}
|
|
100
|
+
async verifyAppScopes(requiredScopes, tokenType, toolAction) {
|
|
101
|
+
if (requiredScopes.length === 0) return true;
|
|
102
|
+
const appGrantedScopes = await getAppGrantedScopes(this.sdk, this.account.appId, tokenType);
|
|
103
|
+
if (appGrantedScopes.length === 0) return false;
|
|
104
|
+
const missingAppScopes = missingScopes(appGrantedScopes, tokenType === "user" ? [...new Set([...requiredScopes, "offline_access"])] : requiredScopes);
|
|
105
|
+
if (missingAppScopes.length > 0) throw new AppScopeMissingError({
|
|
106
|
+
apiName: toolAction,
|
|
107
|
+
scopes: missingAppScopes,
|
|
108
|
+
appId: this.account.appId
|
|
109
|
+
});
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
rethrowStructuredError(error, toolAction, requiredScopes, userOpenId) {
|
|
113
|
+
const code = error.code ?? error.response?.data?.code;
|
|
114
|
+
if (code === LARK_ERROR.APP_SCOPE_MISSING) {
|
|
115
|
+
invalidateAppScopeCache(this.account.appId);
|
|
116
|
+
throw new AppScopeMissingError({
|
|
117
|
+
apiName: toolAction,
|
|
118
|
+
scopes: requiredScopes,
|
|
119
|
+
appId: this.account.appId
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (code === LARK_ERROR.USER_SCOPE_INSUFFICIENT && userOpenId) throw new UserScopeInsufficientError(userOpenId, {
|
|
123
|
+
apiName: toolAction,
|
|
124
|
+
scopes: requiredScopes
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
function createUserToolClient(config, accountIndex = 0) {
|
|
129
|
+
return new UserToolClient(config, accountIndex);
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
export { createUserToolClient };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AppScopeMissingError, UserAuthRequiredError, UserScopeInsufficientError } from "./auth-errors.js";
|
|
2
|
+
import { openPlatformDomain } from "./domains.js";
|
|
3
|
+
import { formatLarkError } from "./user-tool-result.js";
|
|
4
|
+
import { getAllKnownScopes } from "./tool-scopes.js";
|
|
5
|
+
import { createUserToolClient } from "./user-tool-client.js";
|
|
6
|
+
import { jsonToolResult } from "./tool-result.js";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
//#region src/user-tool-helpers.ts
|
|
9
|
+
function json(data) {
|
|
10
|
+
return jsonToolResult(data);
|
|
11
|
+
}
|
|
12
|
+
function createToolContext(api, toolName, accountIndex = 0) {
|
|
13
|
+
const logPrefix = `${toolName}:`;
|
|
14
|
+
return {
|
|
15
|
+
toolClient: () => createUserToolClient(api.config, accountIndex),
|
|
16
|
+
log: {
|
|
17
|
+
info: (message) => api.logger.info?.(`${logPrefix} ${message}`),
|
|
18
|
+
warn: (message) => api.logger.warn?.(`${logPrefix} ${message}`),
|
|
19
|
+
error: (message) => api.logger.error?.(`${logPrefix} ${message}`),
|
|
20
|
+
debug: (message) => api.logger.debug?.(`${logPrefix} ${message}`)
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function registerTool(api, tool, opts) {
|
|
25
|
+
api.registerTool(tool, opts);
|
|
26
|
+
}
|
|
27
|
+
function assertLarkOk(res) {
|
|
28
|
+
if (!res.code || res.code === 0) return;
|
|
29
|
+
throw new Error(res.msg ?? `Feishu API error (code=${res.code})`);
|
|
30
|
+
}
|
|
31
|
+
function StringEnum(values, options) {
|
|
32
|
+
return Type.Union(values.map((value) => Type.Literal(value)), options);
|
|
33
|
+
}
|
|
34
|
+
function parseTimeToTimestamp(input) {
|
|
35
|
+
const date = parseDateLike(input);
|
|
36
|
+
return date ? Math.floor(date.getTime() / 1e3).toString() : null;
|
|
37
|
+
}
|
|
38
|
+
function parseTimeToTimestampMs(input) {
|
|
39
|
+
const date = parseDateLike(input);
|
|
40
|
+
return date ? date.getTime().toString() : null;
|
|
41
|
+
}
|
|
42
|
+
function parseTimeToRFC3339(input) {
|
|
43
|
+
const trimmed = input.trim();
|
|
44
|
+
if (/[Zz]$|[+-]\d{2}:\d{2}$/.test(trimmed)) return new Date(trimmed).toString() === "Invalid Date" ? null : trimmed;
|
|
45
|
+
const match = trimmed.replace(" ", "T").match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
46
|
+
if (!match) {
|
|
47
|
+
const date = new Date(trimmed);
|
|
48
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
49
|
+
}
|
|
50
|
+
const [, y, m, d, hh, mm, ss] = match;
|
|
51
|
+
return `${y}-${m}-${d}T${hh}:${mm}:${ss ?? "00"}+08:00`;
|
|
52
|
+
}
|
|
53
|
+
function unixTimestampToISO8601(raw) {
|
|
54
|
+
if (raw === void 0 || raw === null || raw === "") return null;
|
|
55
|
+
const value = Number(raw);
|
|
56
|
+
if (!Number.isFinite(value)) return null;
|
|
57
|
+
const ms = value > 0xe8d4a51000 ? value : value * 1e3;
|
|
58
|
+
return new Date(ms).toISOString();
|
|
59
|
+
}
|
|
60
|
+
function parseDateLike(input) {
|
|
61
|
+
const trimmed = input.trim();
|
|
62
|
+
if (/[Zz]$|[+-]\d{2}:\d{2}$/.test(trimmed)) {
|
|
63
|
+
const date = new Date(trimmed);
|
|
64
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
65
|
+
}
|
|
66
|
+
const match = trimmed.replace("T", " ").match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?$/);
|
|
67
|
+
if (!match) {
|
|
68
|
+
const date = new Date(trimmed);
|
|
69
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
70
|
+
}
|
|
71
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
72
|
+
return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour) - 8, Number(minute), Number(second ?? "0")));
|
|
73
|
+
}
|
|
74
|
+
async function handleInvokeError(error, _api) {
|
|
75
|
+
if (error instanceof AppScopeMissingError) return json({
|
|
76
|
+
error: "app_scope_missing",
|
|
77
|
+
message: error.message,
|
|
78
|
+
missing_scopes: error.missingScopes,
|
|
79
|
+
app_id: error.appId,
|
|
80
|
+
open_platform: openPlatformDomain("feishu")
|
|
81
|
+
});
|
|
82
|
+
if (error instanceof UserAuthRequiredError) return json({
|
|
83
|
+
error: "need_user_authorization",
|
|
84
|
+
message: "当前用户尚未完成飞书 OAuth 授权,或授权范围不足。",
|
|
85
|
+
required_scopes: error.requiredScopes,
|
|
86
|
+
next_tool_call: {
|
|
87
|
+
tool: "feishu_oauth",
|
|
88
|
+
params: {
|
|
89
|
+
action: "authorize",
|
|
90
|
+
scope: error.requiredScopes.join(" ")
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
default_scope_suggestion: getAllKnownScopes().join(" ")
|
|
94
|
+
});
|
|
95
|
+
if (error instanceof UserScopeInsufficientError) return json({
|
|
96
|
+
error: "user_scope_insufficient",
|
|
97
|
+
message: "当前用户授权范围不足,请重新授权补齐缺失 scope。",
|
|
98
|
+
missing_scopes: error.missingScopes,
|
|
99
|
+
next_tool_call: {
|
|
100
|
+
tool: "feishu_oauth",
|
|
101
|
+
params: {
|
|
102
|
+
action: "authorize",
|
|
103
|
+
scope: error.missingScopes.join(" ")
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
return json({ error: formatLarkError(error) });
|
|
108
|
+
}
|
|
109
|
+
//#endregion
|
|
110
|
+
export { StringEnum, assertLarkOk, createToolContext, handleInvokeError, json, parseTimeToRFC3339, parseTimeToTimestamp, parseTimeToTimestampMs, registerTool, unixTimestampToISO8601 };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/user-tool-result.ts
|
|
2
|
+
function formatLarkError(error) {
|
|
3
|
+
if (!error || typeof error !== "object") return String(error);
|
|
4
|
+
const typed = error;
|
|
5
|
+
if (typeof typed.code === "number" && typed.msg) return typed.msg;
|
|
6
|
+
if (typed.response?.data?.msg) return typed.response.data.msg;
|
|
7
|
+
return typed.message ?? String(error);
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
export { formatLarkError };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
//#region src/wiki-schema.ts
|
|
3
|
+
const FeishuWikiSchema = Type.Union([
|
|
4
|
+
Type.Object({ action: Type.Literal("spaces") }),
|
|
5
|
+
Type.Object({
|
|
6
|
+
action: Type.Literal("nodes"),
|
|
7
|
+
space_id: Type.String({ description: "Knowledge space ID" }),
|
|
8
|
+
parent_node_token: Type.Optional(Type.String({ description: "Parent node token (optional, omit for root)" }))
|
|
9
|
+
}),
|
|
10
|
+
Type.Object({
|
|
11
|
+
action: Type.Literal("get"),
|
|
12
|
+
token: Type.String({ description: "Wiki node token (from URL /wiki/XXX)" })
|
|
13
|
+
}),
|
|
14
|
+
Type.Object({
|
|
15
|
+
action: Type.Literal("search"),
|
|
16
|
+
query: Type.String({ description: "Search query" }),
|
|
17
|
+
space_id: Type.Optional(Type.String({ description: "Limit search to this space (optional)" }))
|
|
18
|
+
}),
|
|
19
|
+
Type.Object({
|
|
20
|
+
action: Type.Literal("create"),
|
|
21
|
+
space_id: Type.String({ description: "Knowledge space ID" }),
|
|
22
|
+
title: Type.String({ description: "Node title" }),
|
|
23
|
+
obj_type: Type.Optional(Type.Union([
|
|
24
|
+
Type.Literal("docx"),
|
|
25
|
+
Type.Literal("sheet"),
|
|
26
|
+
Type.Literal("bitable")
|
|
27
|
+
], { description: "Object type (default: docx)" })),
|
|
28
|
+
parent_node_token: Type.Optional(Type.String({ description: "Parent node token (optional, omit for root)" }))
|
|
29
|
+
}),
|
|
30
|
+
Type.Object({
|
|
31
|
+
action: Type.Literal("move"),
|
|
32
|
+
space_id: Type.String({ description: "Source knowledge space ID" }),
|
|
33
|
+
node_token: Type.String({ description: "Node token to move" }),
|
|
34
|
+
target_space_id: Type.Optional(Type.String({ description: "Target space ID (optional, same space if omitted)" })),
|
|
35
|
+
target_parent_token: Type.Optional(Type.String({ description: "Target parent node token (optional, root if omitted)" }))
|
|
36
|
+
}),
|
|
37
|
+
Type.Object({
|
|
38
|
+
action: Type.Literal("rename"),
|
|
39
|
+
space_id: Type.String({ description: "Knowledge space ID" }),
|
|
40
|
+
node_token: Type.String({ description: "Node token to rename" }),
|
|
41
|
+
title: Type.String({ description: "New title" })
|
|
42
|
+
})
|
|
43
|
+
]);
|
|
44
|
+
//#endregion
|
|
45
|
+
export { FeishuWikiSchema };
|