@milerliu/feishu 0.1.5
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/LICENSE +21 -0
- package/README.md +363 -0
- package/index.ts +55 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +61 -0
- package/src/accounts.ts +53 -0
- package/src/bot.ts +685 -0
- package/src/channel.ts +224 -0
- package/src/client.ts +66 -0
- package/src/config-schema.ts +107 -0
- package/src/directory.ts +159 -0
- package/src/docx.ts +711 -0
- package/src/media.ts +515 -0
- package/src/mention.ts +121 -0
- package/src/monitor.ts +151 -0
- package/src/onboarding.ts +358 -0
- package/src/outbound.ts +40 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +46 -0
- package/src/reactions.ts +157 -0
- package/src/reply-dispatcher.ts +179 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +475 -0
- package/src/targets.ts +58 -0
- package/src/types.ts +55 -0
- package/src/typing.ts +73 -0
package/src/reactions.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { FeishuConfig } from "./types.js";
|
|
3
|
+
import { createFeishuClient } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export type FeishuReaction = {
|
|
6
|
+
reactionId: string;
|
|
7
|
+
emojiType: string;
|
|
8
|
+
operatorType: "app" | "user";
|
|
9
|
+
operatorId: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Add a reaction (emoji) to a message.
|
|
14
|
+
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART"
|
|
15
|
+
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
|
16
|
+
*/
|
|
17
|
+
export async function addReactionFeishu(params: {
|
|
18
|
+
cfg: ClawdbotConfig;
|
|
19
|
+
messageId: string;
|
|
20
|
+
emojiType: string;
|
|
21
|
+
}): Promise<{ reactionId: string }> {
|
|
22
|
+
const { cfg, messageId, emojiType } = params;
|
|
23
|
+
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
24
|
+
if (!feishuCfg) {
|
|
25
|
+
throw new Error("Feishu channel not configured");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const client = createFeishuClient(feishuCfg);
|
|
29
|
+
|
|
30
|
+
const response = (await client.im.messageReaction.create({
|
|
31
|
+
path: { message_id: messageId },
|
|
32
|
+
data: {
|
|
33
|
+
reaction_type: {
|
|
34
|
+
emoji_type: emojiType,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
})) as {
|
|
38
|
+
code?: number;
|
|
39
|
+
msg?: string;
|
|
40
|
+
data?: { reaction_id?: string };
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (response.code !== 0) {
|
|
44
|
+
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const reactionId = response.data?.reaction_id;
|
|
48
|
+
if (!reactionId) {
|
|
49
|
+
throw new Error("Feishu add reaction failed: no reaction_id returned");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { reactionId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove a reaction from a message.
|
|
57
|
+
*/
|
|
58
|
+
export async function removeReactionFeishu(params: {
|
|
59
|
+
cfg: ClawdbotConfig;
|
|
60
|
+
messageId: string;
|
|
61
|
+
reactionId: string;
|
|
62
|
+
}): Promise<void> {
|
|
63
|
+
const { cfg, messageId, reactionId } = params;
|
|
64
|
+
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
65
|
+
if (!feishuCfg) {
|
|
66
|
+
throw new Error("Feishu channel not configured");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const client = createFeishuClient(feishuCfg);
|
|
70
|
+
|
|
71
|
+
const response = (await client.im.messageReaction.delete({
|
|
72
|
+
path: {
|
|
73
|
+
message_id: messageId,
|
|
74
|
+
reaction_id: reactionId,
|
|
75
|
+
},
|
|
76
|
+
})) as { code?: number; msg?: string };
|
|
77
|
+
|
|
78
|
+
if (response.code !== 0) {
|
|
79
|
+
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* List all reactions for a message.
|
|
85
|
+
*/
|
|
86
|
+
export async function listReactionsFeishu(params: {
|
|
87
|
+
cfg: ClawdbotConfig;
|
|
88
|
+
messageId: string;
|
|
89
|
+
emojiType?: string;
|
|
90
|
+
}): Promise<FeishuReaction[]> {
|
|
91
|
+
const { cfg, messageId, emojiType } = params;
|
|
92
|
+
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
93
|
+
if (!feishuCfg) {
|
|
94
|
+
throw new Error("Feishu channel not configured");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const client = createFeishuClient(feishuCfg);
|
|
98
|
+
|
|
99
|
+
const response = (await client.im.messageReaction.list({
|
|
100
|
+
path: { message_id: messageId },
|
|
101
|
+
params: emojiType ? { reaction_type: emojiType } : undefined,
|
|
102
|
+
})) as {
|
|
103
|
+
code?: number;
|
|
104
|
+
msg?: string;
|
|
105
|
+
data?: {
|
|
106
|
+
items?: Array<{
|
|
107
|
+
reaction_id?: string;
|
|
108
|
+
reaction_type?: { emoji_type?: string };
|
|
109
|
+
operator_type?: string;
|
|
110
|
+
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
|
111
|
+
}>;
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (response.code !== 0) {
|
|
116
|
+
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const items = response.data?.items ?? [];
|
|
120
|
+
return items.map((item) => ({
|
|
121
|
+
reactionId: item.reaction_id ?? "",
|
|
122
|
+
emojiType: item.reaction_type?.emoji_type ?? "",
|
|
123
|
+
operatorType: item.operator_type === "app" ? "app" : "user",
|
|
124
|
+
operatorId:
|
|
125
|
+
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Common Feishu emoji types for convenience.
|
|
131
|
+
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
|
132
|
+
*/
|
|
133
|
+
export const FeishuEmoji = {
|
|
134
|
+
// Common reactions
|
|
135
|
+
THUMBSUP: "THUMBSUP",
|
|
136
|
+
THUMBSDOWN: "THUMBSDOWN",
|
|
137
|
+
HEART: "HEART",
|
|
138
|
+
SMILE: "SMILE",
|
|
139
|
+
GRINNING: "GRINNING",
|
|
140
|
+
LAUGHING: "LAUGHING",
|
|
141
|
+
CRY: "CRY",
|
|
142
|
+
ANGRY: "ANGRY",
|
|
143
|
+
SURPRISED: "SURPRISED",
|
|
144
|
+
THINKING: "THINKING",
|
|
145
|
+
CLAP: "CLAP",
|
|
146
|
+
OK: "OK",
|
|
147
|
+
FIST: "FIST",
|
|
148
|
+
PRAY: "PRAY",
|
|
149
|
+
FIRE: "FIRE",
|
|
150
|
+
PARTY: "PARTY",
|
|
151
|
+
CHECK: "CHECK",
|
|
152
|
+
CROSS: "CROSS",
|
|
153
|
+
QUESTION: "QUESTION",
|
|
154
|
+
EXCLAMATION: "EXCLAMATION",
|
|
155
|
+
} as const;
|
|
156
|
+
|
|
157
|
+
export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji];
|
|
@@ -0,0 +1,179 @@
|
|
|
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, sendStreamingMessageFeishu } from "./send.js";
|
|
11
|
+
import type { FeishuConfig } from "./types.js";
|
|
12
|
+
import type { MentionTarget } from "./mention.js";
|
|
13
|
+
import {
|
|
14
|
+
addTypingIndicator,
|
|
15
|
+
removeTypingIndicator,
|
|
16
|
+
type TypingIndicatorState,
|
|
17
|
+
} from "./typing.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Detect if text contains markdown elements that benefit from card rendering.
|
|
21
|
+
* Used by auto render mode.
|
|
22
|
+
*/
|
|
23
|
+
function shouldUseCard(text: string): boolean {
|
|
24
|
+
// Code blocks (fenced)
|
|
25
|
+
if (/```[\s\S]*?```/.test(text)) return true;
|
|
26
|
+
// Tables (at least header + separator row with |)
|
|
27
|
+
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CreateFeishuReplyDispatcherParams = {
|
|
32
|
+
cfg: ClawdbotConfig;
|
|
33
|
+
agentId: string;
|
|
34
|
+
runtime: RuntimeEnv;
|
|
35
|
+
chatId: string;
|
|
36
|
+
replyToMessageId?: string;
|
|
37
|
+
/** Mention targets, will be auto-included in replies */
|
|
38
|
+
mentionTargets?: MentionTarget[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
|
42
|
+
const core = getFeishuRuntime();
|
|
43
|
+
const { cfg, agentId, chatId, replyToMessageId, mentionTargets } = params;
|
|
44
|
+
|
|
45
|
+
const prefixContext = createReplyPrefixContext({
|
|
46
|
+
cfg,
|
|
47
|
+
agentId,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Feishu doesn't have a native typing indicator API.
|
|
51
|
+
// We use message reactions as a typing indicator substitute.
|
|
52
|
+
let typingState: TypingIndicatorState | null = null;
|
|
53
|
+
|
|
54
|
+
const typingCallbacks = createTypingCallbacks({
|
|
55
|
+
start: async () => {
|
|
56
|
+
if (!replyToMessageId) return;
|
|
57
|
+
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId });
|
|
58
|
+
params.runtime.log?.(`feishu: added typing indicator reaction`);
|
|
59
|
+
},
|
|
60
|
+
stop: async () => {
|
|
61
|
+
if (!typingState) return;
|
|
62
|
+
await removeTypingIndicator({ cfg, state: typingState });
|
|
63
|
+
typingState = null;
|
|
64
|
+
params.runtime.log?.(`feishu: removed typing indicator reaction`);
|
|
65
|
+
},
|
|
66
|
+
onStartError: (err) => {
|
|
67
|
+
logTypingFailure({
|
|
68
|
+
log: (message) => params.runtime.log?.(message),
|
|
69
|
+
channel: "feishu",
|
|
70
|
+
action: "start",
|
|
71
|
+
error: err,
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
onStopError: (err) => {
|
|
75
|
+
logTypingFailure({
|
|
76
|
+
log: (message) => params.runtime.log?.(message),
|
|
77
|
+
channel: "feishu",
|
|
78
|
+
action: "stop",
|
|
79
|
+
error: err,
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
|
|
85
|
+
cfg,
|
|
86
|
+
channel: "feishu",
|
|
87
|
+
defaultLimit: 4000,
|
|
88
|
+
});
|
|
89
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
|
90
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
91
|
+
cfg,
|
|
92
|
+
channel: "feishu",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
96
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
97
|
+
responsePrefix: prefixContext.responsePrefix,
|
|
98
|
+
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
99
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
100
|
+
onReplyStart: typingCallbacks.onReplyStart,
|
|
101
|
+
deliver: async (payload: ReplyPayload) => {
|
|
102
|
+
params.runtime.log?.(`feishu deliver called: text=${payload.text?.slice(0, 100)}`);
|
|
103
|
+
const text = payload.text ?? "";
|
|
104
|
+
if (!text.trim()) {
|
|
105
|
+
params.runtime.log?.(`feishu deliver: empty text, skipping`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check render mode: auto (default), raw, or card
|
|
110
|
+
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
111
|
+
const renderMode = feishuCfg?.renderMode ?? "auto";
|
|
112
|
+
const streamingEnabled = feishuCfg?.blockStreamingCoalesce?.enabled ?? false;
|
|
113
|
+
|
|
114
|
+
// Determine if we should use card for this message
|
|
115
|
+
const useCard =
|
|
116
|
+
renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
117
|
+
|
|
118
|
+
// Only include @mentions in the first chunk (avoid duplicate @s)
|
|
119
|
+
let isFirstChunk = true;
|
|
120
|
+
|
|
121
|
+
// Streaming mode: use streaming card for better experience
|
|
122
|
+
if (streamingEnabled && useCard && !replyToMessageId) {
|
|
123
|
+
params.runtime.log?.(`feishu deliver: using streaming mode for text (${text.length} chars)`);
|
|
124
|
+
await sendStreamingMessageFeishu({
|
|
125
|
+
cfg,
|
|
126
|
+
to: chatId,
|
|
127
|
+
text,
|
|
128
|
+
mentions: mentionTargets,
|
|
129
|
+
});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (useCard) {
|
|
134
|
+
// Card mode: send as interactive card with markdown rendering
|
|
135
|
+
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
|
|
136
|
+
params.runtime.log?.(`feishu deliver: sending ${chunks.length} card chunks to ${chatId}`);
|
|
137
|
+
for (const chunk of chunks) {
|
|
138
|
+
await sendMarkdownCardFeishu({
|
|
139
|
+
cfg,
|
|
140
|
+
to: chatId,
|
|
141
|
+
text: chunk,
|
|
142
|
+
replyToMessageId,
|
|
143
|
+
mentions: isFirstChunk ? mentionTargets : undefined,
|
|
144
|
+
});
|
|
145
|
+
isFirstChunk = false;
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
// Raw mode: send as plain text with table conversion
|
|
149
|
+
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
150
|
+
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
|
151
|
+
params.runtime.log?.(`feishu deliver: sending ${chunks.length} text chunks to ${chatId}`);
|
|
152
|
+
for (const chunk of chunks) {
|
|
153
|
+
await sendMessageFeishu({
|
|
154
|
+
cfg,
|
|
155
|
+
to: chatId,
|
|
156
|
+
text: chunk,
|
|
157
|
+
replyToMessageId,
|
|
158
|
+
mentions: isFirstChunk ? mentionTargets : undefined,
|
|
159
|
+
});
|
|
160
|
+
isFirstChunk = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
onError: (err, info) => {
|
|
165
|
+
params.runtime.error?.(`feishu ${info.kind} reply failed: ${String(err)}`);
|
|
166
|
+
typingCallbacks.onIdle?.();
|
|
167
|
+
},
|
|
168
|
+
onIdle: typingCallbacks.onIdle,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
dispatcher,
|
|
173
|
+
replyOptions: {
|
|
174
|
+
...replyOptions,
|
|
175
|
+
onModelSelected: prefixContext.onModelSelected,
|
|
176
|
+
},
|
|
177
|
+
markDispatchIdle,
|
|
178
|
+
};
|
|
179
|
+
}
|
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
|
+
}
|