@nextclaw/channel-plugin-feishu 0.2.12 → 0.2.14
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 +3 -1
- package/index.ts +65 -0
- package/openclaw.plugin.json +3 -7
- package/package.json +33 -9
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +111 -0
- package/src/accounts.test.ts +371 -0
- package/src/accounts.ts +244 -0
- package/src/async.ts +62 -0
- package/src/bitable.ts +725 -0
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +193 -0
- package/src/bot.stripBotMention.test.ts +134 -0
- package/src/bot.test.ts +2107 -0
- package/src/bot.ts +1556 -0
- package/src/card-action.ts +79 -0
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +369 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +324 -0
- package/src/client.ts +196 -0
- package/src/config-schema.test.ts +247 -0
- package/src/config-schema.ts +306 -0
- package/src/dedup.ts +203 -0
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +156 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +187 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +445 -0
- package/src/docx.ts +1460 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +228 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +523 -0
- package/src/media.ts +484 -0
- package/src/mention.ts +133 -0
- package/src/monitor.account.ts +562 -0
- package/src/monitor.reaction.test.ts +653 -0
- package/src/monitor.startup.test.ts +190 -0
- package/src/monitor.startup.ts +64 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +155 -0
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +264 -0
- package/src/monitor.ts +95 -0
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +142 -0
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +489 -0
- package/src/outbound.test.ts +356 -0
- package/src/outbound.ts +176 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +176 -0
- package/src/policy.test.ts +154 -0
- package/src/policy.ts +123 -0
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +270 -0
- package/src/probe.ts +156 -0
- package/src/reactions.ts +153 -0
- package/src/reply-dispatcher.test.ts +513 -0
- package/src/reply-dispatcher.ts +397 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-result.ts +29 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +29 -0
- package/src/send.reply-fallback.test.ts +189 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +481 -0
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +374 -0
- package/src/targets.test.ts +70 -0
- package/src/targets.ts +107 -0
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +103 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +210 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +233 -0
- package/index.js +0 -27
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createReplyPrefixContext,
|
|
3
|
+
createTypingCallbacks,
|
|
4
|
+
logTypingFailure,
|
|
5
|
+
type ClawdbotConfig,
|
|
6
|
+
type ReplyPayload,
|
|
7
|
+
type RuntimeEnv,
|
|
8
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
9
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
10
|
+
import { createFeishuClient } from "./client.js";
|
|
11
|
+
import { sendMediaFeishu } from "./media.js";
|
|
12
|
+
import type { MentionTarget } from "./mention.js";
|
|
13
|
+
import { buildMentionedCardContent } from "./mention.js";
|
|
14
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
15
|
+
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
|
16
|
+
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
|
|
17
|
+
import { resolveReceiveIdType } from "./targets.js";
|
|
18
|
+
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
|
19
|
+
|
|
20
|
+
/** Detect if text contains markdown elements that benefit from card rendering */
|
|
21
|
+
function shouldUseCard(text: string): boolean {
|
|
22
|
+
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Maximum age (ms) for a message to receive a typing indicator reaction.
|
|
26
|
+
* Messages older than this are likely replays after context compaction (#30418). */
|
|
27
|
+
const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000;
|
|
28
|
+
const MS_EPOCH_MIN = 1_000_000_000_000;
|
|
29
|
+
|
|
30
|
+
function normalizeEpochMs(timestamp: number | undefined): number | undefined {
|
|
31
|
+
if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
// Defensive normalization: some payloads use seconds, others milliseconds.
|
|
35
|
+
// Values below 1e12 are treated as epoch-seconds.
|
|
36
|
+
return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CreateFeishuReplyDispatcherParams = {
|
|
40
|
+
cfg: ClawdbotConfig;
|
|
41
|
+
agentId: string;
|
|
42
|
+
runtime: RuntimeEnv;
|
|
43
|
+
chatId: string;
|
|
44
|
+
replyToMessageId?: string;
|
|
45
|
+
/** When true, preserve typing indicator on reply target but send messages without reply metadata */
|
|
46
|
+
skipReplyToInMessages?: boolean;
|
|
47
|
+
replyInThread?: boolean;
|
|
48
|
+
/** True when inbound message is already inside a thread/topic context */
|
|
49
|
+
threadReply?: boolean;
|
|
50
|
+
rootId?: string;
|
|
51
|
+
mentionTargets?: MentionTarget[];
|
|
52
|
+
accountId?: string;
|
|
53
|
+
/** Epoch ms when the inbound message was created. Used to suppress typing
|
|
54
|
+
* indicators on old/replayed messages after context compaction (#30418). */
|
|
55
|
+
messageCreateTimeMs?: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
|
59
|
+
const core = getFeishuRuntime();
|
|
60
|
+
const {
|
|
61
|
+
cfg,
|
|
62
|
+
agentId,
|
|
63
|
+
chatId,
|
|
64
|
+
replyToMessageId,
|
|
65
|
+
skipReplyToInMessages,
|
|
66
|
+
replyInThread,
|
|
67
|
+
threadReply,
|
|
68
|
+
rootId,
|
|
69
|
+
mentionTargets,
|
|
70
|
+
accountId,
|
|
71
|
+
} = params;
|
|
72
|
+
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
|
|
73
|
+
const threadReplyMode = threadReply === true;
|
|
74
|
+
const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
|
|
75
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
76
|
+
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
|
77
|
+
|
|
78
|
+
let typingState: TypingIndicatorState | null = null;
|
|
79
|
+
const typingCallbacks = createTypingCallbacks({
|
|
80
|
+
start: async () => {
|
|
81
|
+
// Check if typing indicator is enabled (default: true)
|
|
82
|
+
if (!(account.config.typingIndicator ?? true)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!replyToMessageId) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Skip typing indicator for old messages — likely replays after context
|
|
89
|
+
// compaction that would flood users with stale notifications (#30418).
|
|
90
|
+
const messageCreateTimeMs = normalizeEpochMs(params.messageCreateTimeMs);
|
|
91
|
+
if (
|
|
92
|
+
messageCreateTimeMs !== undefined &&
|
|
93
|
+
Date.now() - messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS
|
|
94
|
+
) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Feishu reactions persist until explicitly removed, so skip keepalive
|
|
98
|
+
// re-adds when a reaction already exists. Re-adding the same emoji
|
|
99
|
+
// triggers a new push notification for every call (#28660).
|
|
100
|
+
if (typingState?.reactionId) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
typingState = await addTypingIndicator({
|
|
104
|
+
cfg,
|
|
105
|
+
messageId: replyToMessageId,
|
|
106
|
+
accountId,
|
|
107
|
+
runtime: params.runtime,
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
stop: async () => {
|
|
111
|
+
if (!typingState) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
await removeTypingIndicator({ cfg, state: typingState, accountId, runtime: params.runtime });
|
|
115
|
+
typingState = null;
|
|
116
|
+
},
|
|
117
|
+
onStartError: (err) =>
|
|
118
|
+
logTypingFailure({
|
|
119
|
+
log: (message) => params.runtime.log?.(message),
|
|
120
|
+
channel: "feishu",
|
|
121
|
+
action: "start",
|
|
122
|
+
error: err,
|
|
123
|
+
}),
|
|
124
|
+
onStopError: (err) =>
|
|
125
|
+
logTypingFailure({
|
|
126
|
+
log: (message) => params.runtime.log?.(message),
|
|
127
|
+
channel: "feishu",
|
|
128
|
+
action: "stop",
|
|
129
|
+
error: err,
|
|
130
|
+
}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", accountId, {
|
|
134
|
+
fallbackLimit: 4000,
|
|
135
|
+
});
|
|
136
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
|
137
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
|
|
138
|
+
const renderMode = account.config?.renderMode ?? "auto";
|
|
139
|
+
// Card streaming may miss thread affinity in topic contexts; use direct replies there.
|
|
140
|
+
const streamingEnabled =
|
|
141
|
+
!threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
|
|
142
|
+
|
|
143
|
+
let streaming: FeishuStreamingSession | null = null;
|
|
144
|
+
let streamText = "";
|
|
145
|
+
let lastPartial = "";
|
|
146
|
+
const deliveredFinalTexts = new Set<string>();
|
|
147
|
+
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
|
148
|
+
let streamingStartPromise: Promise<void> | null = null;
|
|
149
|
+
type StreamTextUpdateMode = "snapshot" | "delta";
|
|
150
|
+
|
|
151
|
+
const queueStreamingUpdate = (
|
|
152
|
+
nextText: string,
|
|
153
|
+
options?: {
|
|
154
|
+
dedupeWithLastPartial?: boolean;
|
|
155
|
+
mode?: StreamTextUpdateMode;
|
|
156
|
+
},
|
|
157
|
+
) => {
|
|
158
|
+
if (!nextText) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (options?.dedupeWithLastPartial && nextText === lastPartial) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (options?.dedupeWithLastPartial) {
|
|
165
|
+
lastPartial = nextText;
|
|
166
|
+
}
|
|
167
|
+
const mode = options?.mode ?? "snapshot";
|
|
168
|
+
streamText =
|
|
169
|
+
mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
|
|
170
|
+
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
|
171
|
+
if (streamingStartPromise) {
|
|
172
|
+
await streamingStartPromise;
|
|
173
|
+
}
|
|
174
|
+
if (streaming?.isActive()) {
|
|
175
|
+
await streaming.update(streamText);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const startStreaming = () => {
|
|
181
|
+
if (!streamingEnabled || streamingStartPromise || streaming) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
streamingStartPromise = (async () => {
|
|
185
|
+
const creds =
|
|
186
|
+
account.appId && account.appSecret
|
|
187
|
+
? { appId: account.appId, appSecret: account.appSecret, domain: account.domain }
|
|
188
|
+
: null;
|
|
189
|
+
if (!creds) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
streaming = new FeishuStreamingSession(createFeishuClient(account), creds, (message) =>
|
|
194
|
+
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
|
|
195
|
+
);
|
|
196
|
+
try {
|
|
197
|
+
await streaming.start(chatId, resolveReceiveIdType(chatId), {
|
|
198
|
+
replyToMessageId,
|
|
199
|
+
replyInThread: effectiveReplyInThread,
|
|
200
|
+
rootId,
|
|
201
|
+
});
|
|
202
|
+
} catch (error) {
|
|
203
|
+
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
|
|
204
|
+
streaming = null;
|
|
205
|
+
}
|
|
206
|
+
})();
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const closeStreaming = async () => {
|
|
210
|
+
if (streamingStartPromise) {
|
|
211
|
+
await streamingStartPromise;
|
|
212
|
+
}
|
|
213
|
+
await partialUpdateQueue;
|
|
214
|
+
if (streaming?.isActive()) {
|
|
215
|
+
let text = streamText;
|
|
216
|
+
if (mentionTargets?.length) {
|
|
217
|
+
text = buildMentionedCardContent(mentionTargets, text);
|
|
218
|
+
}
|
|
219
|
+
await streaming.close(text);
|
|
220
|
+
}
|
|
221
|
+
streaming = null;
|
|
222
|
+
streamingStartPromise = null;
|
|
223
|
+
streamText = "";
|
|
224
|
+
lastPartial = "";
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const sendChunkedTextReply = async (params: {
|
|
228
|
+
text: string;
|
|
229
|
+
useCard: boolean;
|
|
230
|
+
infoKind?: string;
|
|
231
|
+
}) => {
|
|
232
|
+
let first = true;
|
|
233
|
+
const chunkSource = params.useCard
|
|
234
|
+
? params.text
|
|
235
|
+
: core.channel.text.convertMarkdownTables(params.text, tableMode);
|
|
236
|
+
for (const chunk of core.channel.text.chunkTextWithMode(
|
|
237
|
+
chunkSource,
|
|
238
|
+
textChunkLimit,
|
|
239
|
+
chunkMode,
|
|
240
|
+
)) {
|
|
241
|
+
const message = {
|
|
242
|
+
cfg,
|
|
243
|
+
to: chatId,
|
|
244
|
+
text: chunk,
|
|
245
|
+
replyToMessageId: sendReplyToMessageId,
|
|
246
|
+
replyInThread: effectiveReplyInThread,
|
|
247
|
+
mentions: first ? mentionTargets : undefined,
|
|
248
|
+
accountId,
|
|
249
|
+
};
|
|
250
|
+
if (params.useCard) {
|
|
251
|
+
await sendMarkdownCardFeishu(message);
|
|
252
|
+
} else {
|
|
253
|
+
await sendMessageFeishu(message);
|
|
254
|
+
}
|
|
255
|
+
first = false;
|
|
256
|
+
}
|
|
257
|
+
if (params.infoKind === "final") {
|
|
258
|
+
deliveredFinalTexts.add(params.text);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
263
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
264
|
+
responsePrefix: prefixContext.responsePrefix,
|
|
265
|
+
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
266
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
267
|
+
onReplyStart: () => {
|
|
268
|
+
deliveredFinalTexts.clear();
|
|
269
|
+
if (streamingEnabled && renderMode === "card") {
|
|
270
|
+
startStreaming();
|
|
271
|
+
}
|
|
272
|
+
void typingCallbacks.onReplyStart?.();
|
|
273
|
+
},
|
|
274
|
+
deliver: async (payload: ReplyPayload, info) => {
|
|
275
|
+
const text = payload.text ?? "";
|
|
276
|
+
const mediaList =
|
|
277
|
+
payload.mediaUrls && payload.mediaUrls.length > 0
|
|
278
|
+
? payload.mediaUrls
|
|
279
|
+
: payload.mediaUrl
|
|
280
|
+
? [payload.mediaUrl]
|
|
281
|
+
: [];
|
|
282
|
+
const hasText = Boolean(text.trim());
|
|
283
|
+
const hasMedia = mediaList.length > 0;
|
|
284
|
+
const skipTextForDuplicateFinal =
|
|
285
|
+
info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
|
|
286
|
+
const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
|
|
287
|
+
|
|
288
|
+
if (!shouldDeliverText && !hasMedia) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (shouldDeliverText) {
|
|
293
|
+
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
294
|
+
|
|
295
|
+
if (info?.kind === "block") {
|
|
296
|
+
// Drop internal block chunks unless we can safely consume them as
|
|
297
|
+
// streaming-card fallback content.
|
|
298
|
+
if (!(streamingEnabled && useCard)) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
startStreaming();
|
|
302
|
+
if (streamingStartPromise) {
|
|
303
|
+
await streamingStartPromise;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (info?.kind === "final" && streamingEnabled && useCard) {
|
|
308
|
+
startStreaming();
|
|
309
|
+
if (streamingStartPromise) {
|
|
310
|
+
await streamingStartPromise;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (streaming?.isActive()) {
|
|
315
|
+
if (info?.kind === "block") {
|
|
316
|
+
// Some runtimes emit block payloads without onPartial/final callbacks.
|
|
317
|
+
// Mirror block text into streamText so onIdle close still sends content.
|
|
318
|
+
queueStreamingUpdate(text, { mode: "delta" });
|
|
319
|
+
}
|
|
320
|
+
if (info?.kind === "final") {
|
|
321
|
+
streamText = mergeStreamingText(streamText, text);
|
|
322
|
+
await closeStreaming();
|
|
323
|
+
deliveredFinalTexts.add(text);
|
|
324
|
+
}
|
|
325
|
+
// Send media even when streaming handled the text
|
|
326
|
+
if (hasMedia) {
|
|
327
|
+
for (const mediaUrl of mediaList) {
|
|
328
|
+
await sendMediaFeishu({
|
|
329
|
+
cfg,
|
|
330
|
+
to: chatId,
|
|
331
|
+
mediaUrl,
|
|
332
|
+
replyToMessageId: sendReplyToMessageId,
|
|
333
|
+
replyInThread: effectiveReplyInThread,
|
|
334
|
+
accountId,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (useCard) {
|
|
342
|
+
await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
|
|
343
|
+
} else {
|
|
344
|
+
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (hasMedia) {
|
|
349
|
+
for (const mediaUrl of mediaList) {
|
|
350
|
+
await sendMediaFeishu({
|
|
351
|
+
cfg,
|
|
352
|
+
to: chatId,
|
|
353
|
+
mediaUrl,
|
|
354
|
+
replyToMessageId: sendReplyToMessageId,
|
|
355
|
+
replyInThread: effectiveReplyInThread,
|
|
356
|
+
accountId,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
onError: async (error, info) => {
|
|
362
|
+
params.runtime.error?.(
|
|
363
|
+
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
|
|
364
|
+
);
|
|
365
|
+
await closeStreaming();
|
|
366
|
+
typingCallbacks.onIdle?.();
|
|
367
|
+
},
|
|
368
|
+
onIdle: async () => {
|
|
369
|
+
await closeStreaming();
|
|
370
|
+
typingCallbacks.onIdle?.();
|
|
371
|
+
},
|
|
372
|
+
onCleanup: () => {
|
|
373
|
+
typingCallbacks.onCleanup?.();
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
dispatcher,
|
|
379
|
+
replyOptions: {
|
|
380
|
+
...replyOptions,
|
|
381
|
+
onModelSelected: prefixContext.onModelSelected,
|
|
382
|
+
disableBlockStreaming: true,
|
|
383
|
+
onPartialReply: streamingEnabled
|
|
384
|
+
? (payload: ReplyPayload) => {
|
|
385
|
+
if (!payload.text) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
queueStreamingUpdate(payload.text, {
|
|
389
|
+
dedupeWithLastPartial: true,
|
|
390
|
+
mode: "snapshot",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
: undefined,
|
|
394
|
+
},
|
|
395
|
+
markDispatchIdle,
|
|
396
|
+
};
|
|
397
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
|
2
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
|
|
3
|
+
|
|
4
|
+
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
|
5
|
+
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
|
|
6
|
+
export { getFeishuRuntime, setFeishuRuntime };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSecretInputSchema,
|
|
3
|
+
hasConfiguredSecretInput,
|
|
4
|
+
normalizeResolvedSecretInputString,
|
|
5
|
+
normalizeSecretInputString,
|
|
6
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
buildSecretInputSchema,
|
|
10
|
+
hasConfiguredSecretInput,
|
|
11
|
+
normalizeResolvedSecretInputString,
|
|
12
|
+
normalizeSecretInputString,
|
|
13
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
2
|
+
|
|
3
|
+
type FeishuMessageClient = {
|
|
4
|
+
im: {
|
|
5
|
+
message: {
|
|
6
|
+
reply: (params: {
|
|
7
|
+
path: { message_id: string };
|
|
8
|
+
data: Record<string, unknown>;
|
|
9
|
+
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
|
|
10
|
+
create: (params: {
|
|
11
|
+
params: { receive_id_type: string };
|
|
12
|
+
data: Record<string, unknown>;
|
|
13
|
+
}) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function sendFeishuMessageWithOptionalReply(params: {
|
|
19
|
+
client: FeishuMessageClient;
|
|
20
|
+
receiveId: string;
|
|
21
|
+
receiveIdType: string;
|
|
22
|
+
content: string;
|
|
23
|
+
msgType: string;
|
|
24
|
+
replyToMessageId?: string;
|
|
25
|
+
replyInThread?: boolean;
|
|
26
|
+
sendErrorPrefix: string;
|
|
27
|
+
replyErrorPrefix: string;
|
|
28
|
+
fallbackSendErrorPrefix?: string;
|
|
29
|
+
shouldFallbackFromReply?: (response: { code?: number; msg?: string }) => boolean;
|
|
30
|
+
}): Promise<{ messageId: string; chatId: string }> {
|
|
31
|
+
const data = {
|
|
32
|
+
content: params.content,
|
|
33
|
+
msg_type: params.msgType,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (params.replyToMessageId) {
|
|
37
|
+
const response = await params.client.im.message.reply({
|
|
38
|
+
path: { message_id: params.replyToMessageId },
|
|
39
|
+
data: {
|
|
40
|
+
...data,
|
|
41
|
+
...(params.replyInThread ? { reply_in_thread: true } : {}),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (params.shouldFallbackFromReply?.(response)) {
|
|
45
|
+
const fallback = await params.client.im.message.create({
|
|
46
|
+
params: { receive_id_type: params.receiveIdType },
|
|
47
|
+
data: {
|
|
48
|
+
receive_id: params.receiveId,
|
|
49
|
+
...data,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
assertFeishuMessageApiSuccess(
|
|
53
|
+
fallback,
|
|
54
|
+
params.fallbackSendErrorPrefix ?? params.sendErrorPrefix,
|
|
55
|
+
);
|
|
56
|
+
return toFeishuSendResult(fallback, params.receiveId);
|
|
57
|
+
}
|
|
58
|
+
assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
|
|
59
|
+
return toFeishuSendResult(response, params.receiveId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const response = await params.client.im.message.create({
|
|
63
|
+
params: { receive_id_type: params.receiveIdType },
|
|
64
|
+
data: {
|
|
65
|
+
receive_id: params.receiveId,
|
|
66
|
+
...data,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
assertFeishuMessageApiSuccess(response, params.sendErrorPrefix);
|
|
70
|
+
return toFeishuSendResult(response, params.receiveId);
|
|
71
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type FeishuMessageApiResponse = {
|
|
2
|
+
code?: number;
|
|
3
|
+
msg?: string;
|
|
4
|
+
data?: {
|
|
5
|
+
message_id?: string;
|
|
6
|
+
};
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function assertFeishuMessageApiSuccess(
|
|
10
|
+
response: FeishuMessageApiResponse,
|
|
11
|
+
errorPrefix: string,
|
|
12
|
+
) {
|
|
13
|
+
if (response.code !== 0) {
|
|
14
|
+
throw new Error(`${errorPrefix}: ${response.msg || `code ${response.code}`}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function toFeishuSendResult(
|
|
19
|
+
response: FeishuMessageApiResponse,
|
|
20
|
+
chatId: string,
|
|
21
|
+
): {
|
|
22
|
+
messageId: string;
|
|
23
|
+
chatId: string;
|
|
24
|
+
} {
|
|
25
|
+
return {
|
|
26
|
+
messageId: response.data?.message_id ?? "unknown",
|
|
27
|
+
chatId,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { resolveFeishuSendTarget } from "./send-target.js";
|
|
4
|
+
|
|
5
|
+
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
|
|
8
|
+
vi.mock("./accounts.js", () => ({
|
|
9
|
+
resolveFeishuAccount: resolveFeishuAccountMock,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("./client.js", () => ({
|
|
13
|
+
createFeishuClient: createFeishuClientMock,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe("resolveFeishuSendTarget", () => {
|
|
17
|
+
const cfg = {} as ClawdbotConfig;
|
|
18
|
+
const client = { id: "client" };
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
resolveFeishuAccountMock.mockReset().mockReturnValue({
|
|
22
|
+
accountId: "default",
|
|
23
|
+
enabled: true,
|
|
24
|
+
configured: true,
|
|
25
|
+
});
|
|
26
|
+
createFeishuClientMock.mockReset().mockReturnValue(client);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps explicit group targets as chat_id even when ID shape is ambiguous", () => {
|
|
30
|
+
const result = resolveFeishuSendTarget({
|
|
31
|
+
cfg,
|
|
32
|
+
to: "feishu:group:group_room_alpha",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.receiveId).toBe("group_room_alpha");
|
|
36
|
+
expect(result.receiveIdType).toBe("chat_id");
|
|
37
|
+
expect(result.client).toBe(client);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("maps dm-prefixed open IDs to open_id", () => {
|
|
41
|
+
const result = resolveFeishuSendTarget({
|
|
42
|
+
cfg,
|
|
43
|
+
to: "lark:dm:ou_123",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(result.receiveId).toBe("ou_123");
|
|
47
|
+
expect(result.receiveIdType).toBe("open_id");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("maps dm-prefixed non-open IDs to user_id", () => {
|
|
51
|
+
const result = resolveFeishuSendTarget({
|
|
52
|
+
cfg,
|
|
53
|
+
to: " feishu:dm:user_123 ",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.receiveId).toBe("user_123");
|
|
57
|
+
expect(result.receiveIdType).toBe("user_id");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("throws when target account is not configured", () => {
|
|
61
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
62
|
+
accountId: "default",
|
|
63
|
+
enabled: true,
|
|
64
|
+
configured: false,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(() =>
|
|
68
|
+
resolveFeishuSendTarget({
|
|
69
|
+
cfg,
|
|
70
|
+
to: "feishu:group:oc_123",
|
|
71
|
+
}),
|
|
72
|
+
).toThrow('Feishu account "default" not configured');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
3
|
+
import { createFeishuClient } from "./client.js";
|
|
4
|
+
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
|
5
|
+
|
|
6
|
+
export function resolveFeishuSendTarget(params: {
|
|
7
|
+
cfg: ClawdbotConfig;
|
|
8
|
+
to: string;
|
|
9
|
+
accountId?: string;
|
|
10
|
+
}) {
|
|
11
|
+
const target = params.to.trim();
|
|
12
|
+
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
13
|
+
if (!account.configured) {
|
|
14
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
15
|
+
}
|
|
16
|
+
const client = createFeishuClient(account);
|
|
17
|
+
const receiveId = normalizeFeishuTarget(target);
|
|
18
|
+
if (!receiveId) {
|
|
19
|
+
throw new Error(`Invalid Feishu target: ${params.to}`);
|
|
20
|
+
}
|
|
21
|
+
// Preserve explicit routing prefixes (chat/group/user/dm/open_id) when present.
|
|
22
|
+
// normalizeFeishuTarget strips these prefixes, so infer type from the raw target first.
|
|
23
|
+
const withoutProviderPrefix = target.replace(/^(feishu|lark):/i, "");
|
|
24
|
+
return {
|
|
25
|
+
client,
|
|
26
|
+
receiveId,
|
|
27
|
+
receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
|
|
28
|
+
};
|
|
29
|
+
}
|