@openclaw-plugins/feishu-plus 0.1.7-fork.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.
@@ -0,0 +1,225 @@
1
+ import {
2
+ createReplyPrefixContext,
3
+ createTypingCallbacks,
4
+ logTypingFailure,
5
+ type ClawdbotConfig,
6
+ type RuntimeEnv,
7
+ type ReplyPayload,
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
+ import { resolveFeishuAccount } from "./accounts.js";
14
+ import {
15
+ addTypingIndicator,
16
+ removeTypingIndicator,
17
+ type TypingIndicatorState,
18
+ } from "./typing.js";
19
+ import { createFeishuStreamingHandler, FeishuStream } from "./stream.js";
20
+
21
+ /**
22
+ * Detect if text contains markdown elements that benefit from card rendering.
23
+ * Used by auto render mode.
24
+ */
25
+ function shouldUseCard(text: string): boolean {
26
+ // Code blocks (fenced)
27
+ if (/```[\s\S]*?```/.test(text)) return true;
28
+ // Tables (at least header + separator row with |)
29
+ if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
30
+ return false;
31
+ }
32
+
33
+ export type CreateFeishuReplyDispatcherParams = {
34
+ cfg: ClawdbotConfig;
35
+ agentId: string;
36
+ runtime: RuntimeEnv;
37
+ chatId: string;
38
+ replyToMessageId?: string;
39
+ /** Mention targets, will be auto-included in replies */
40
+ mentionTargets?: MentionTarget[];
41
+ /** Account ID for multi-account support */
42
+ accountId?: string;
43
+ /** Enable streaming mode for card-based real-time updates */
44
+ enableStreaming?: boolean;
45
+ };
46
+
47
+ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
48
+ const core = getFeishuRuntime();
49
+ const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId, enableStreaming } = params;
50
+
51
+ // Resolve account for config access
52
+ const account = resolveFeishuAccount({ cfg, accountId });
53
+
54
+ const prefixContext = createReplyPrefixContext({
55
+ cfg,
56
+ agentId,
57
+ });
58
+
59
+ // Feishu doesn't have a native typing indicator API.
60
+ // We use message reactions as a typing indicator substitute.
61
+ let typingState: TypingIndicatorState | null = null;
62
+
63
+ // Streaming state
64
+ let streamInstance: FeishuStream | null = null;
65
+ let streamingMessageId: string | null = null;
66
+
67
+ const typingCallbacks = createTypingCallbacks({
68
+ start: async () => {
69
+ // Skip typing indicator when streaming is enabled
70
+ if (enableStreaming) {
71
+ params.runtime.log?.(`feishu[${account.accountId}]: streaming enabled, skipping typing indicator`);
72
+ return;
73
+ }
74
+ if (!replyToMessageId) return;
75
+ typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
76
+ params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`);
77
+ },
78
+ stop: async () => {
79
+ if (!typingState) return;
80
+ await removeTypingIndicator({ cfg, state: typingState, accountId });
81
+ typingState = null;
82
+ params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`);
83
+ },
84
+ onStartError: (err) => {
85
+ logTypingFailure({
86
+ log: (message) => params.runtime.log?.(message),
87
+ channel: "feishu",
88
+ action: "start",
89
+ error: err,
90
+ });
91
+ },
92
+ onStopError: (err) => {
93
+ logTypingFailure({
94
+ log: (message) => params.runtime.log?.(message),
95
+ channel: "feishu",
96
+ action: "stop",
97
+ error: err,
98
+ });
99
+ },
100
+ });
101
+
102
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit({
103
+ cfg,
104
+ channel: "feishu",
105
+ defaultLimit: 4000,
106
+ });
107
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
108
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
109
+ cfg,
110
+ channel: "feishu",
111
+ });
112
+
113
+ const { dispatcher, replyOptions, markDispatchIdle } =
114
+ core.channel.reply.createReplyDispatcherWithTyping({
115
+ responsePrefix: prefixContext.responsePrefix,
116
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
117
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
118
+ onReplyStart: typingCallbacks.onReplyStart,
119
+ deliver: async (payload: ReplyPayload) => {
120
+ params.runtime.log?.(`feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`);
121
+ const text = payload.text ?? "";
122
+ if (!text.trim()) {
123
+ params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`);
124
+ return;
125
+ }
126
+
127
+ // Check render mode: auto (default), raw, or card
128
+ const feishuCfg = account.config;
129
+ const renderMode = feishuCfg?.renderMode ?? "auto";
130
+
131
+ // Determine if we should use card for this message
132
+ const useCard =
133
+ renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
134
+
135
+ // Only include @mentions in the first chunk (avoid duplicate @s)
136
+ let isFirstChunk = true;
137
+
138
+ if (useCard) {
139
+ // Card mode: send as interactive card with markdown rendering
140
+ const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
141
+ params.runtime.log?.(`feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`);
142
+ for (const chunk of chunks) {
143
+ await sendMarkdownCardFeishu({
144
+ cfg,
145
+ to: chatId,
146
+ text: chunk,
147
+ replyToMessageId,
148
+ mentions: isFirstChunk ? mentionTargets : undefined,
149
+ accountId,
150
+ });
151
+ isFirstChunk = false;
152
+ }
153
+ } else {
154
+ // Raw mode: send as plain text with table conversion
155
+ const converted = core.channel.text.convertMarkdownTables(text, tableMode);
156
+ const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
157
+ params.runtime.log?.(`feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`);
158
+ for (const chunk of chunks) {
159
+ await sendMessageFeishu({
160
+ cfg,
161
+ to: chatId,
162
+ text: chunk,
163
+ replyToMessageId,
164
+ mentions: isFirstChunk ? mentionTargets : undefined,
165
+ accountId,
166
+ });
167
+ isFirstChunk = false;
168
+ }
169
+ }
170
+ },
171
+ onError: (err, info) => {
172
+ params.runtime.error?.(`feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`);
173
+ typingCallbacks.onIdle?.();
174
+ },
175
+ onIdle: typingCallbacks.onIdle,
176
+ // Enable partial reply callback for streaming
177
+ onPartialReply: enableStreaming
178
+ ? async (text: string, isFinal: boolean) => {
179
+ // Initialize stream on first partial reply
180
+ if (!streamInstance) {
181
+ // Send initial card message
182
+ const result = await sendMarkdownCardFeishu({
183
+ cfg,
184
+ to: chatId,
185
+ text: text,
186
+ replyToMessageId,
187
+ mentions: mentionTargets,
188
+ accountId,
189
+ });
190
+ streamingMessageId = result.messageId;
191
+
192
+ // Create stream handler for subsequent updates
193
+ if (streamingMessageId) {
194
+ const handler = createFeishuStreamingHandler({
195
+ cfg,
196
+ messageId: streamingMessageId,
197
+ accountId,
198
+ });
199
+ streamInstance = handler.stream;
200
+ }
201
+ } else if (streamInstance) {
202
+ // Update existing stream
203
+ if (isFinal) {
204
+ await streamInstance.finalize(text);
205
+ streamInstance.destroy();
206
+ streamInstance = null;
207
+ } else {
208
+ await streamInstance.update(text);
209
+ }
210
+ }
211
+ }
212
+ : undefined,
213
+ });
214
+
215
+ return {
216
+ dispatcher,
217
+ replyOptions: {
218
+ ...replyOptions,
219
+ onModelSelected: prefixContext.onModelSelected,
220
+ },
221
+ markDispatchIdle,
222
+ // Expose stream instance for external control
223
+ streamInstance: () => streamInstance,
224
+ };
225
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setFeishuRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getFeishuRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("Feishu runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }