@m1heng-clawd/feishu 0.1.9 → 0.1.10
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 +42 -28
- package/index.ts +3 -1
- package/package.json +4 -4
- package/src/bitable-tools/actions.ts +199 -0
- package/src/bitable-tools/common.ts +90 -0
- package/src/bitable-tools/meta.ts +80 -0
- package/src/bitable-tools/register.ts +195 -0
- package/src/bitable-tools/schemas.ts +221 -0
- package/src/bitable.ts +1 -441
- package/src/bot.ts +129 -67
- package/src/channel.ts +6 -8
- package/src/config-schema.ts +7 -0
- package/src/dedup.ts +31 -0
- package/src/docx.ts +440 -62
- package/src/drive-schema.ts +20 -0
- package/src/drive.ts +84 -27
- package/src/dynamic-agent.ts +8 -4
- package/src/media.ts +25 -40
- package/src/monitor.ts +29 -2
- package/src/onboarding.ts +186 -105
- package/src/perm.ts +23 -24
- package/src/probe.ts +108 -4
- package/src/reply-dispatcher.ts +137 -73
- package/src/streaming-card.ts +211 -0
- package/src/targets.ts +1 -1
- package/src/task-tools/actions.ts +137 -0
- package/src/task-tools/common.ts +18 -0
- package/src/task-tools/register.ts +101 -0
- package/src/task-tools/schemas.ts +138 -0
- package/src/task.ts +1 -0
- package/src/tools-common/feishu-api.ts +184 -0
- package/src/tools-common/tool-context.ts +23 -0
- package/src/tools-common/tool-exec.ts +73 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +1 -0
- package/src/wiki.ts +42 -43
package/src/probe.ts
CHANGED
|
@@ -1,6 +1,93 @@
|
|
|
1
1
|
import type { FeishuProbeResult } from "./types.js";
|
|
2
2
|
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
|
3
3
|
|
|
4
|
+
const DECIMAL_RADIX = 10;
|
|
5
|
+
const MINUTES_TO_MS = 60 * 1000;
|
|
6
|
+
const MIN_VALID_TTL_MINUTES = 0;
|
|
7
|
+
const DEFAULT_SUCCESS_CACHE_TTL_MINUTES = 15;
|
|
8
|
+
const DEFAULT_ERROR_CACHE_TTL_MINUTES = 5;
|
|
9
|
+
const FEISHU_API_SUCCESS_CODE = 0;
|
|
10
|
+
const SUCCESS_CACHE_TTL_ENV_KEY = "FEISHU_PROBE_CACHE_TTL_MINUTES";
|
|
11
|
+
const ERROR_CACHE_TTL_ENV_KEY = "FEISHU_PROBE_ERROR_CACHE_TTL_MINUTES";
|
|
12
|
+
|
|
13
|
+
// Cache for probe results to avoid API rate limits
|
|
14
|
+
// Success TTL default: 15 minutes (900000 ms)
|
|
15
|
+
// Can be customized via environment variable: FEISHU_PROBE_CACHE_TTL_MINUTES
|
|
16
|
+
// Error TTL default: 5 minutes (300000 ms)
|
|
17
|
+
// Can be customized via environment variable: FEISHU_PROBE_ERROR_CACHE_TTL_MINUTES
|
|
18
|
+
function resolveCacheTtlMs(envKey: string, defaultMinutes: number): number {
|
|
19
|
+
const envTtl = process.env[envKey];
|
|
20
|
+
if (envTtl) {
|
|
21
|
+
const minutes = parseInt(envTtl, DECIMAL_RADIX);
|
|
22
|
+
if (!isNaN(minutes) && minutes > MIN_VALID_TTL_MINUTES) {
|
|
23
|
+
return minutes * MINUTES_TO_MS;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return defaultMinutes * MINUTES_TO_MS;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PROBE_CACHE_TTL_MS = resolveCacheTtlMs(
|
|
30
|
+
SUCCESS_CACHE_TTL_ENV_KEY,
|
|
31
|
+
DEFAULT_SUCCESS_CACHE_TTL_MINUTES,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
interface ProbeCacheEntry {
|
|
35
|
+
result: FeishuProbeResult;
|
|
36
|
+
timestamp: number;
|
|
37
|
+
ttlMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const probeCache = new Map<string, ProbeCacheEntry>();
|
|
41
|
+
const PROBE_ERROR_CACHE_TTL_MS = resolveCacheTtlMs(
|
|
42
|
+
ERROR_CACHE_TTL_ENV_KEY,
|
|
43
|
+
DEFAULT_ERROR_CACHE_TTL_MINUTES,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
function getCacheKey(creds: FeishuClientCredentials): string {
|
|
47
|
+
return `${creds.appId}:${creds.domain || "feishu"}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getCachedResult(creds: FeishuClientCredentials): FeishuProbeResult | null {
|
|
51
|
+
const key = getCacheKey(creds);
|
|
52
|
+
const cached = probeCache.get(key);
|
|
53
|
+
if (!cached) return null;
|
|
54
|
+
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
if (now - cached.timestamp > cached.ttlMs) {
|
|
57
|
+
// Cache expired
|
|
58
|
+
probeCache.delete(key);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return cached.result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function setCachedResult(creds: FeishuClientCredentials, result: FeishuProbeResult): void {
|
|
66
|
+
const key = getCacheKey(creds);
|
|
67
|
+
const ttlMs = result.ok ? PROBE_CACHE_TTL_MS : PROBE_ERROR_CACHE_TTL_MS;
|
|
68
|
+
probeCache.set(key, {
|
|
69
|
+
result,
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
ttlMs,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Clear the probe cache for a specific account or all accounts.
|
|
77
|
+
*/
|
|
78
|
+
export function clearProbeCache(accountId?: string): void {
|
|
79
|
+
if (accountId) {
|
|
80
|
+
// Find and delete entries matching the accountId
|
|
81
|
+
for (const [key, entry] of probeCache.entries()) {
|
|
82
|
+
if (key.startsWith(`${accountId}:`)) {
|
|
83
|
+
probeCache.delete(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
probeCache.clear();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
4
91
|
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
|
|
5
92
|
if (!creds?.appId || !creds?.appSecret) {
|
|
6
93
|
return {
|
|
@@ -9,6 +96,12 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
|
|
|
9
96
|
};
|
|
10
97
|
}
|
|
11
98
|
|
|
99
|
+
// Check cache first
|
|
100
|
+
const cached = getCachedResult(creds);
|
|
101
|
+
if (cached) {
|
|
102
|
+
return cached;
|
|
103
|
+
}
|
|
104
|
+
|
|
12
105
|
try {
|
|
13
106
|
const client = createFeishuClient(creds);
|
|
14
107
|
// Use bot/v3/info API to get bot information
|
|
@@ -18,26 +111,37 @@ export async function probeFeishu(creds?: FeishuClientCredentials): Promise<Feis
|
|
|
18
111
|
data: {},
|
|
19
112
|
});
|
|
20
113
|
|
|
21
|
-
if (response.code !==
|
|
22
|
-
|
|
114
|
+
if (response.code !== FEISHU_API_SUCCESS_CODE) {
|
|
115
|
+
const result: FeishuProbeResult = {
|
|
23
116
|
ok: false,
|
|
24
117
|
appId: creds.appId,
|
|
25
118
|
error: `API error: ${response.msg || `code ${response.code}`}`,
|
|
26
119
|
};
|
|
120
|
+
// Cache error results with error TTL to avoid hammering the API
|
|
121
|
+
setCachedResult(creds, result);
|
|
122
|
+
return result;
|
|
27
123
|
}
|
|
28
124
|
|
|
29
125
|
const bot = response.bot || response.data?.bot;
|
|
30
|
-
|
|
126
|
+
const result: FeishuProbeResult = {
|
|
31
127
|
ok: true,
|
|
32
128
|
appId: creds.appId,
|
|
33
129
|
botName: bot?.bot_name,
|
|
34
130
|
botOpenId: bot?.open_id,
|
|
35
131
|
};
|
|
132
|
+
|
|
133
|
+
// Cache successful result
|
|
134
|
+
setCachedResult(creds, result);
|
|
135
|
+
|
|
136
|
+
return result;
|
|
36
137
|
} catch (err) {
|
|
37
|
-
|
|
138
|
+
const result: FeishuProbeResult = {
|
|
38
139
|
ok: false,
|
|
39
140
|
appId: creds.appId,
|
|
40
141
|
error: err instanceof Error ? err.message : String(err),
|
|
41
142
|
};
|
|
143
|
+
// Cache error results with error TTL
|
|
144
|
+
setCachedResult(creds, result);
|
|
145
|
+
return result;
|
|
42
146
|
}
|
|
43
147
|
}
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -3,30 +3,21 @@ import {
|
|
|
3
3
|
createTypingCallbacks,
|
|
4
4
|
logTypingFailure,
|
|
5
5
|
type ClawdbotConfig,
|
|
6
|
-
type RuntimeEnv,
|
|
7
6
|
type ReplyPayload,
|
|
7
|
+
type RuntimeEnv,
|
|
8
8
|
} from "openclaw/plugin-sdk";
|
|
9
|
-
import { getFeishuRuntime } from "./runtime.js";
|
|
10
|
-
import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
|
|
11
|
-
import type { FeishuConfig } from "./types.js";
|
|
12
|
-
import type { MentionTarget } from "./mention.js";
|
|
13
9
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from "./
|
|
10
|
+
import { createFeishuClient } from "./client.js";
|
|
11
|
+
import { buildMentionedCardContent, type MentionTarget } from "./mention.js";
|
|
12
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
13
|
+
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
|
14
|
+
import { FeishuStreamingSession } from "./streaming-card.js";
|
|
15
|
+
import { resolveReceiveIdType } from "./targets.js";
|
|
16
|
+
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
|
19
17
|
|
|
20
|
-
/**
|
|
21
|
-
* Detect if text contains markdown elements that benefit from card rendering.
|
|
22
|
-
* Used by auto render mode.
|
|
23
|
-
*/
|
|
18
|
+
/** Detect if text contains markdown elements that benefit from card rendering */
|
|
24
19
|
function shouldUseCard(text: string): boolean {
|
|
25
|
-
|
|
26
|
-
if (/```[\s\S]*?```/.test(text)) return true;
|
|
27
|
-
// Tables (at least header + separator row with |)
|
|
28
|
-
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
|
|
29
|
-
return false;
|
|
20
|
+
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
|
30
21
|
}
|
|
31
22
|
|
|
32
23
|
export type CreateFeishuReplyDispatcherParams = {
|
|
@@ -35,132 +26,188 @@ export type CreateFeishuReplyDispatcherParams = {
|
|
|
35
26
|
runtime: RuntimeEnv;
|
|
36
27
|
chatId: string;
|
|
37
28
|
replyToMessageId?: string;
|
|
38
|
-
/** Mention targets, will be auto-included in replies */
|
|
39
29
|
mentionTargets?: MentionTarget[];
|
|
40
|
-
/** Account ID for multi-account support */
|
|
41
30
|
accountId?: string;
|
|
42
31
|
};
|
|
43
32
|
|
|
44
33
|
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
|
45
34
|
const core = getFeishuRuntime();
|
|
46
35
|
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
|
|
47
|
-
|
|
48
|
-
// Resolve account for config access
|
|
49
36
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
37
|
+
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
|
50
38
|
|
|
51
|
-
const prefixContext = createReplyPrefixContext({
|
|
52
|
-
cfg,
|
|
53
|
-
agentId,
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// Feishu doesn't have a native typing indicator API.
|
|
57
|
-
// We use message reactions as a typing indicator substitute.
|
|
58
39
|
let typingState: TypingIndicatorState | null = null;
|
|
59
|
-
|
|
60
40
|
const typingCallbacks = createTypingCallbacks({
|
|
61
41
|
start: async () => {
|
|
62
|
-
if (!replyToMessageId)
|
|
42
|
+
if (!replyToMessageId) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
63
45
|
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
|
|
64
|
-
params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`);
|
|
65
46
|
},
|
|
66
47
|
stop: async () => {
|
|
67
|
-
if (!typingState)
|
|
48
|
+
if (!typingState) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
68
51
|
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
|
69
52
|
typingState = null;
|
|
70
|
-
params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`);
|
|
71
53
|
},
|
|
72
|
-
onStartError: (err) =>
|
|
54
|
+
onStartError: (err) =>
|
|
73
55
|
logTypingFailure({
|
|
74
56
|
log: (message) => params.runtime.log?.(message),
|
|
75
57
|
channel: "feishu",
|
|
76
58
|
action: "start",
|
|
77
59
|
error: err,
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
onStopError: (err) => {
|
|
60
|
+
}),
|
|
61
|
+
onStopError: (err) =>
|
|
81
62
|
logTypingFailure({
|
|
82
63
|
log: (message) => params.runtime.log?.(message),
|
|
83
64
|
channel: "feishu",
|
|
84
65
|
action: "stop",
|
|
85
66
|
error: err,
|
|
86
|
-
})
|
|
87
|
-
},
|
|
67
|
+
}),
|
|
88
68
|
});
|
|
89
69
|
|
|
90
|
-
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
|
|
91
|
-
|
|
92
|
-
channel: "feishu",
|
|
93
|
-
defaultLimit: 4000,
|
|
70
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", account.accountId, {
|
|
71
|
+
fallbackLimit: 4000,
|
|
94
72
|
});
|
|
95
|
-
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
|
73
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu", account.accountId);
|
|
96
74
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
97
75
|
cfg,
|
|
98
76
|
channel: "feishu",
|
|
77
|
+
accountId: account.accountId,
|
|
99
78
|
});
|
|
79
|
+
const renderMode = account.config?.renderMode ?? "auto";
|
|
80
|
+
const streamingEnabled = account.config?.streaming === true && renderMode !== "raw";
|
|
81
|
+
|
|
82
|
+
let streaming: FeishuStreamingSession | null = null;
|
|
83
|
+
let streamText = "";
|
|
84
|
+
let lastPartial = "";
|
|
85
|
+
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
|
86
|
+
let streamingStartPromise: Promise<void> | null = null;
|
|
87
|
+
|
|
88
|
+
const startStreaming = () => {
|
|
89
|
+
if (!streamingEnabled || streamingStartPromise || streaming) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
streamingStartPromise = (async () => {
|
|
93
|
+
const creds =
|
|
94
|
+
account.appId && account.appSecret
|
|
95
|
+
? { appId: account.appId, appSecret: account.appSecret, domain: account.domain }
|
|
96
|
+
: null;
|
|
97
|
+
if (!creds) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
streaming = new FeishuStreamingSession(createFeishuClient(account), creds, (message) =>
|
|
102
|
+
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
|
|
103
|
+
);
|
|
104
|
+
try {
|
|
105
|
+
await streaming.start(chatId, resolveReceiveIdType(chatId));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
|
|
108
|
+
streaming = null;
|
|
109
|
+
}
|
|
110
|
+
})();
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const closeStreaming = async () => {
|
|
114
|
+
if (streamingStartPromise) {
|
|
115
|
+
await streamingStartPromise;
|
|
116
|
+
}
|
|
117
|
+
await partialUpdateQueue;
|
|
118
|
+
if (streaming?.isActive()) {
|
|
119
|
+
let text = streamText;
|
|
120
|
+
if (mentionTargets?.length) {
|
|
121
|
+
text = buildMentionedCardContent(mentionTargets, text);
|
|
122
|
+
}
|
|
123
|
+
await streaming.close(text);
|
|
124
|
+
}
|
|
125
|
+
streaming = null;
|
|
126
|
+
streamingStartPromise = null;
|
|
127
|
+
streamText = "";
|
|
128
|
+
lastPartial = "";
|
|
129
|
+
};
|
|
100
130
|
|
|
101
131
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
102
132
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
103
133
|
responsePrefix: prefixContext.responsePrefix,
|
|
104
134
|
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
105
135
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
106
|
-
onReplyStart:
|
|
107
|
-
|
|
108
|
-
|
|
136
|
+
onReplyStart: () => {
|
|
137
|
+
if (streamingEnabled && renderMode === "card") {
|
|
138
|
+
startStreaming();
|
|
139
|
+
}
|
|
140
|
+
void typingCallbacks.onReplyStart?.();
|
|
141
|
+
},
|
|
142
|
+
deliver: async (payload: ReplyPayload, info) => {
|
|
109
143
|
const text = payload.text ?? "";
|
|
110
144
|
if (!text.trim()) {
|
|
111
|
-
params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`);
|
|
112
145
|
return;
|
|
113
146
|
}
|
|
114
147
|
|
|
115
|
-
|
|
116
|
-
const feishuCfg = account.config;
|
|
117
|
-
const renderMode = feishuCfg?.renderMode ?? "auto";
|
|
148
|
+
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
118
149
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
150
|
+
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
|
|
151
|
+
startStreaming();
|
|
152
|
+
if (streamingStartPromise) {
|
|
153
|
+
await streamingStartPromise;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
122
156
|
|
|
123
|
-
|
|
124
|
-
|
|
157
|
+
if (streaming?.isActive()) {
|
|
158
|
+
if (info?.kind === "final") {
|
|
159
|
+
streamText = text;
|
|
160
|
+
await closeStreaming();
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
125
164
|
|
|
165
|
+
let first = true;
|
|
126
166
|
if (useCard) {
|
|
127
|
-
|
|
128
|
-
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
|
129
|
-
params.runtime.log?.(`feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`);
|
|
130
|
-
for (const chunk of chunks) {
|
|
167
|
+
for (const chunk of core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)) {
|
|
131
168
|
await sendMarkdownCardFeishu({
|
|
132
169
|
cfg,
|
|
133
170
|
to: chatId,
|
|
134
171
|
text: chunk,
|
|
135
172
|
replyToMessageId,
|
|
136
|
-
mentions:
|
|
173
|
+
mentions: first ? mentionTargets : undefined,
|
|
137
174
|
accountId,
|
|
138
175
|
});
|
|
139
|
-
|
|
176
|
+
first = false;
|
|
140
177
|
}
|
|
141
178
|
} else {
|
|
142
|
-
// Raw mode: send as plain text with table conversion
|
|
143
179
|
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
180
|
+
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
181
|
+
converted,
|
|
182
|
+
textChunkLimit,
|
|
183
|
+
chunkMode,
|
|
184
|
+
)) {
|
|
147
185
|
await sendMessageFeishu({
|
|
148
186
|
cfg,
|
|
149
187
|
to: chatId,
|
|
150
188
|
text: chunk,
|
|
151
189
|
replyToMessageId,
|
|
152
|
-
mentions:
|
|
190
|
+
mentions: first ? mentionTargets : undefined,
|
|
153
191
|
accountId,
|
|
154
192
|
});
|
|
155
|
-
|
|
193
|
+
first = false;
|
|
156
194
|
}
|
|
157
195
|
}
|
|
158
196
|
},
|
|
159
|
-
onError: (
|
|
160
|
-
params.runtime.error?.(
|
|
197
|
+
onError: async (error, info) => {
|
|
198
|
+
params.runtime.error?.(
|
|
199
|
+
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
|
|
200
|
+
);
|
|
201
|
+
await closeStreaming();
|
|
202
|
+
typingCallbacks.onIdle?.();
|
|
203
|
+
},
|
|
204
|
+
onIdle: async () => {
|
|
205
|
+
await closeStreaming();
|
|
161
206
|
typingCallbacks.onIdle?.();
|
|
162
207
|
},
|
|
163
|
-
|
|
208
|
+
onCleanup: () => {
|
|
209
|
+
typingCallbacks.onCleanup?.();
|
|
210
|
+
},
|
|
164
211
|
});
|
|
165
212
|
|
|
166
213
|
return {
|
|
@@ -168,6 +215,23 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
|
|
168
215
|
replyOptions: {
|
|
169
216
|
...replyOptions,
|
|
170
217
|
onModelSelected: prefixContext.onModelSelected,
|
|
218
|
+
onPartialReply: streamingEnabled
|
|
219
|
+
? (payload: ReplyPayload) => {
|
|
220
|
+
if (!payload.text || payload.text === lastPartial) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
lastPartial = payload.text;
|
|
224
|
+
streamText = payload.text;
|
|
225
|
+
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
|
226
|
+
if (streamingStartPromise) {
|
|
227
|
+
await streamingStartPromise;
|
|
228
|
+
}
|
|
229
|
+
if (streaming?.isActive()) {
|
|
230
|
+
await streaming.update(streamText);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
: undefined,
|
|
171
235
|
},
|
|
172
236
|
markDispatchIdle,
|
|
173
237
|
};
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { Client } from "@larksuiteoapi/node-sdk";
|
|
2
|
+
import type { FeishuDomain } from "./types.js";
|
|
3
|
+
|
|
4
|
+
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
|
5
|
+
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
|
|
6
|
+
|
|
7
|
+
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
|
8
|
+
|
|
9
|
+
function resolveApiBase(domain?: FeishuDomain): string {
|
|
10
|
+
if (domain === "lark") {
|
|
11
|
+
return "https://open.larksuite.com/open-apis";
|
|
12
|
+
}
|
|
13
|
+
if (domain && domain !== "feishu" && domain.startsWith("http")) {
|
|
14
|
+
return `${domain.replace(/\/+$/, "")}/open-apis`;
|
|
15
|
+
}
|
|
16
|
+
return "https://open.feishu.cn/open-apis";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getToken(creds: Credentials): Promise<string> {
|
|
20
|
+
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
|
21
|
+
const cached = tokenCache.get(key);
|
|
22
|
+
if (cached && cached.expiresAt > Date.now() + 60000) {
|
|
23
|
+
return cached.token;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
|
|
30
|
+
});
|
|
31
|
+
const data = (await res.json()) as {
|
|
32
|
+
code: number;
|
|
33
|
+
msg: string;
|
|
34
|
+
tenant_access_token?: string;
|
|
35
|
+
expire?: number;
|
|
36
|
+
};
|
|
37
|
+
if (data.code !== 0 || !data.tenant_access_token) {
|
|
38
|
+
throw new Error(`Token error: ${data.msg}`);
|
|
39
|
+
}
|
|
40
|
+
tokenCache.set(key, {
|
|
41
|
+
token: data.tenant_access_token,
|
|
42
|
+
expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
|
|
43
|
+
});
|
|
44
|
+
return data.tenant_access_token;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function truncateSummary(text: string, max = 50): string {
|
|
48
|
+
if (!text) {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
const clean = text.replace(/\n/g, " ").trim();
|
|
52
|
+
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class FeishuStreamingSession {
|
|
56
|
+
private client: Client;
|
|
57
|
+
private creds: Credentials;
|
|
58
|
+
private state: CardState | null = null;
|
|
59
|
+
private queue: Promise<void> = Promise.resolve();
|
|
60
|
+
private closed = false;
|
|
61
|
+
private log?: (msg: string) => void;
|
|
62
|
+
private lastUpdateTime = 0;
|
|
63
|
+
private pendingText: string | null = null;
|
|
64
|
+
private updateThrottleMs = 100;
|
|
65
|
+
|
|
66
|
+
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
|
|
67
|
+
this.client = client;
|
|
68
|
+
this.creds = creds;
|
|
69
|
+
this.log = log;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async start(
|
|
73
|
+
receiveId: string,
|
|
74
|
+
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
if (this.state) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
81
|
+
const cardJson = {
|
|
82
|
+
schema: "2.0",
|
|
83
|
+
config: {
|
|
84
|
+
streaming_mode: true,
|
|
85
|
+
summary: { content: "[Generating...]" },
|
|
86
|
+
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
|
|
87
|
+
},
|
|
88
|
+
body: {
|
|
89
|
+
elements: [{ tag: "markdown", content: "Thinking...", element_id: "content" }],
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
|
|
94
|
+
method: "POST",
|
|
95
|
+
headers: {
|
|
96
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
},
|
|
99
|
+
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
|
|
100
|
+
});
|
|
101
|
+
const createData = (await createRes.json()) as {
|
|
102
|
+
code: number;
|
|
103
|
+
msg: string;
|
|
104
|
+
data?: { card_id: string };
|
|
105
|
+
};
|
|
106
|
+
if (createData.code !== 0 || !createData.data?.card_id) {
|
|
107
|
+
throw new Error(`Create card failed: ${createData.msg}`);
|
|
108
|
+
}
|
|
109
|
+
const cardId = createData.data.card_id;
|
|
110
|
+
|
|
111
|
+
const sendRes = await this.client.im.message.create({
|
|
112
|
+
params: { receive_id_type: receiveIdType },
|
|
113
|
+
data: {
|
|
114
|
+
receive_id: receiveId,
|
|
115
|
+
msg_type: "interactive",
|
|
116
|
+
content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
|
|
120
|
+
throw new Error(`Send card failed: ${sendRes.msg}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
|
|
124
|
+
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async update(text: string): Promise<void> {
|
|
128
|
+
if (!this.state || this.closed) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (now - this.lastUpdateTime < this.updateThrottleMs) {
|
|
133
|
+
this.pendingText = text;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
this.pendingText = null;
|
|
137
|
+
this.lastUpdateTime = now;
|
|
138
|
+
|
|
139
|
+
this.queue = this.queue.then(async () => {
|
|
140
|
+
if (!this.state || this.closed) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
this.state.currentText = text;
|
|
144
|
+
this.state.sequence += 1;
|
|
145
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
146
|
+
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
|
147
|
+
method: "PUT",
|
|
148
|
+
headers: {
|
|
149
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
150
|
+
"Content-Type": "application/json",
|
|
151
|
+
},
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
content: text,
|
|
154
|
+
sequence: this.state.sequence,
|
|
155
|
+
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
156
|
+
}),
|
|
157
|
+
}).catch((e) => this.log?.(`Update failed: ${String(e)}`));
|
|
158
|
+
});
|
|
159
|
+
await this.queue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async close(finalText?: string): Promise<void> {
|
|
163
|
+
if (!this.state || this.closed) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
this.closed = true;
|
|
167
|
+
await this.queue;
|
|
168
|
+
|
|
169
|
+
const text = finalText ?? this.pendingText ?? this.state.currentText;
|
|
170
|
+
const apiBase = resolveApiBase(this.creds.domain);
|
|
171
|
+
|
|
172
|
+
if (text && text !== this.state.currentText) {
|
|
173
|
+
this.state.sequence += 1;
|
|
174
|
+
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
|
|
175
|
+
method: "PUT",
|
|
176
|
+
headers: {
|
|
177
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
content: text,
|
|
182
|
+
sequence: this.state.sequence,
|
|
183
|
+
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
|
|
184
|
+
}),
|
|
185
|
+
}).catch(() => {});
|
|
186
|
+
this.state.currentText = text;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
this.state.sequence += 1;
|
|
190
|
+
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
|
|
191
|
+
method: "PATCH",
|
|
192
|
+
headers: {
|
|
193
|
+
Authorization: `Bearer ${await getToken(this.creds)}`,
|
|
194
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
settings: JSON.stringify({
|
|
198
|
+
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
|
|
199
|
+
}),
|
|
200
|
+
sequence: this.state.sequence,
|
|
201
|
+
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
|
|
202
|
+
}),
|
|
203
|
+
}).catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
|
204
|
+
|
|
205
|
+
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
isActive(): boolean {
|
|
209
|
+
return this.state !== null && !this.closed;
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/targets.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
|
|
|
45
45
|
const trimmed = id.trim();
|
|
46
46
|
if (trimmed.startsWith(CHAT_ID_PREFIX)) return "chat_id";
|
|
47
47
|
if (trimmed.startsWith(OPEN_ID_PREFIX)) return "open_id";
|
|
48
|
-
return "
|
|
48
|
+
return "user_id";
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function looksLikeFeishuId(raw: string): boolean {
|