@openclaw-plugins/feishu-plus 0.1.7
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 +560 -0
- package/index.ts +63 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +65 -0
- package/skills/feishu-doc/SKILL.md +99 -0
- package/skills/feishu-doc/references/block-types.md +102 -0
- package/skills/feishu-drive/SKILL.md +96 -0
- package/skills/feishu-perm/SKILL.md +90 -0
- package/skills/feishu-wiki/SKILL.md +96 -0
- package/src/accounts.ts +140 -0
- package/src/bitable.ts +441 -0
- package/src/bot.ts +881 -0
- package/src/channel.ts +334 -0
- package/src/client.ts +114 -0
- package/src/config-schema.ts +199 -0
- package/src/directory.ts +165 -0
- package/src/doc-schema.ts +47 -0
- package/src/docx.ts +480 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +207 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/media.ts +523 -0
- package/src/mention.ts +121 -0
- package/src/monitor.ts +190 -0
- package/src/onboarding.ts +358 -0
- package/src/outbound.ts +40 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +166 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +43 -0
- package/src/reactions.ts +160 -0
- package/src/reply-dispatcher.ts +174 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +360 -0
- package/src/targets.ts +58 -0
- package/src/tools-config.ts +21 -0
- package/src/types.ts +77 -0
- package/src/typing.ts +75 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +224 -0
package/src/perm.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createFeishuClient } from "./client.js";
|
|
3
|
+
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
4
|
+
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
5
|
+
import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
|
|
6
|
+
import { resolveToolsConfig } from "./tools-config.js";
|
|
7
|
+
|
|
8
|
+
// ============ Helpers ============
|
|
9
|
+
|
|
10
|
+
function json(data: unknown) {
|
|
11
|
+
return {
|
|
12
|
+
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
|
|
13
|
+
details: data,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ListTokenType =
|
|
18
|
+
| "doc"
|
|
19
|
+
| "sheet"
|
|
20
|
+
| "file"
|
|
21
|
+
| "wiki"
|
|
22
|
+
| "bitable"
|
|
23
|
+
| "docx"
|
|
24
|
+
| "mindnote"
|
|
25
|
+
| "minutes"
|
|
26
|
+
| "slides";
|
|
27
|
+
type CreateTokenType =
|
|
28
|
+
| "doc"
|
|
29
|
+
| "sheet"
|
|
30
|
+
| "file"
|
|
31
|
+
| "wiki"
|
|
32
|
+
| "bitable"
|
|
33
|
+
| "docx"
|
|
34
|
+
| "folder"
|
|
35
|
+
| "mindnote"
|
|
36
|
+
| "minutes"
|
|
37
|
+
| "slides";
|
|
38
|
+
type MemberType =
|
|
39
|
+
| "email"
|
|
40
|
+
| "openid"
|
|
41
|
+
| "unionid"
|
|
42
|
+
| "openchat"
|
|
43
|
+
| "opendepartmentid"
|
|
44
|
+
| "userid"
|
|
45
|
+
| "groupid"
|
|
46
|
+
| "wikispaceid";
|
|
47
|
+
type PermType = "view" | "edit" | "full_access";
|
|
48
|
+
|
|
49
|
+
// ============ Actions ============
|
|
50
|
+
|
|
51
|
+
async function listMembers(client: Lark.Client, token: string, type: string) {
|
|
52
|
+
const res = await client.drive.permissionMember.list({
|
|
53
|
+
path: { token },
|
|
54
|
+
params: { type: type as ListTokenType },
|
|
55
|
+
});
|
|
56
|
+
if (res.code !== 0) throw new Error(res.msg);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
members:
|
|
60
|
+
res.data?.items?.map((m) => ({
|
|
61
|
+
member_type: m.member_type,
|
|
62
|
+
member_id: m.member_id,
|
|
63
|
+
perm: m.perm,
|
|
64
|
+
name: m.name,
|
|
65
|
+
})) ?? [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function addMember(
|
|
70
|
+
client: Lark.Client,
|
|
71
|
+
token: string,
|
|
72
|
+
type: string,
|
|
73
|
+
memberType: string,
|
|
74
|
+
memberId: string,
|
|
75
|
+
perm: string,
|
|
76
|
+
) {
|
|
77
|
+
const res = await client.drive.permissionMember.create({
|
|
78
|
+
path: { token },
|
|
79
|
+
params: { type: type as CreateTokenType, need_notification: false },
|
|
80
|
+
data: {
|
|
81
|
+
member_type: memberType as MemberType,
|
|
82
|
+
member_id: memberId,
|
|
83
|
+
perm: perm as PermType,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
if (res.code !== 0) throw new Error(res.msg);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
success: true,
|
|
90
|
+
member: res.data?.member,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function removeMember(
|
|
95
|
+
client: Lark.Client,
|
|
96
|
+
token: string,
|
|
97
|
+
type: string,
|
|
98
|
+
memberType: string,
|
|
99
|
+
memberId: string,
|
|
100
|
+
) {
|
|
101
|
+
const res = await client.drive.permissionMember.delete({
|
|
102
|
+
path: { token, member_id: memberId },
|
|
103
|
+
params: { type: type as CreateTokenType, member_type: memberType as MemberType },
|
|
104
|
+
});
|
|
105
|
+
if (res.code !== 0) throw new Error(res.msg);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
success: true,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ============ Tool Registration ============
|
|
113
|
+
|
|
114
|
+
export function registerFeishuPermTools(api: OpenClawPluginApi) {
|
|
115
|
+
if (!api.config) {
|
|
116
|
+
api.logger.debug?.("feishu_perm: No config available, skipping perm tools");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const accounts = listEnabledFeishuAccounts(api.config);
|
|
121
|
+
if (accounts.length === 0) {
|
|
122
|
+
api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const firstAccount = accounts[0];
|
|
127
|
+
const toolsCfg = resolveToolsConfig(firstAccount.config.tools);
|
|
128
|
+
if (!toolsCfg.perm) {
|
|
129
|
+
api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const getClient = () => createFeishuClient(firstAccount);
|
|
134
|
+
|
|
135
|
+
api.registerTool(
|
|
136
|
+
{
|
|
137
|
+
name: "feishu_perm",
|
|
138
|
+
label: "Feishu Perm",
|
|
139
|
+
description: "Feishu permission management. Actions: list, add, remove",
|
|
140
|
+
parameters: FeishuPermSchema,
|
|
141
|
+
async execute(_toolCallId, params) {
|
|
142
|
+
const p = params as FeishuPermParams;
|
|
143
|
+
try {
|
|
144
|
+
const client = getClient();
|
|
145
|
+
switch (p.action) {
|
|
146
|
+
case "list":
|
|
147
|
+
return json(await listMembers(client, p.token, p.type));
|
|
148
|
+
case "add":
|
|
149
|
+
return json(
|
|
150
|
+
await addMember(client, p.token, p.type, p.member_type, p.member_id, p.perm),
|
|
151
|
+
);
|
|
152
|
+
case "remove":
|
|
153
|
+
return json(await removeMember(client, p.token, p.type, p.member_type, p.member_id));
|
|
154
|
+
default:
|
|
155
|
+
return json({ error: `Unknown action: ${(p as any).action}` });
|
|
156
|
+
}
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{ name: "feishu_perm" },
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
api.logger.info?.(`feishu_perm: Registered feishu_perm tool`);
|
|
166
|
+
}
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type FeishuAllowlistMatch = {
|
|
5
|
+
allowed: boolean;
|
|
6
|
+
matchKey?: string;
|
|
7
|
+
matchSource?: "wildcard" | "id" | "name";
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function resolveFeishuAllowlistMatch(params: {
|
|
11
|
+
allowFrom: Array<string | number>;
|
|
12
|
+
senderId: string;
|
|
13
|
+
senderName?: string | null;
|
|
14
|
+
}): FeishuAllowlistMatch {
|
|
15
|
+
const allowFrom = params.allowFrom
|
|
16
|
+
.map((entry) => String(entry).trim().toLowerCase())
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
|
|
19
|
+
if (allowFrom.length === 0) return { allowed: false };
|
|
20
|
+
if (allowFrom.includes("*")) {
|
|
21
|
+
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const senderId = params.senderId.toLowerCase();
|
|
25
|
+
if (allowFrom.includes(senderId)) {
|
|
26
|
+
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const senderName = params.senderName?.toLowerCase();
|
|
30
|
+
if (senderName && allowFrom.includes(senderName)) {
|
|
31
|
+
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return { allowed: false };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function resolveFeishuGroupConfig(params: {
|
|
38
|
+
cfg?: FeishuConfig;
|
|
39
|
+
groupId?: string | null;
|
|
40
|
+
}): FeishuGroupConfig | undefined {
|
|
41
|
+
const groups = params.cfg?.groups ?? {};
|
|
42
|
+
const groupId = params.groupId?.trim();
|
|
43
|
+
if (!groupId) return undefined;
|
|
44
|
+
|
|
45
|
+
const direct = groups[groupId] as FeishuGroupConfig | undefined;
|
|
46
|
+
if (direct) return direct;
|
|
47
|
+
|
|
48
|
+
const lowered = groupId.toLowerCase();
|
|
49
|
+
const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
|
|
50
|
+
return matchKey ? (groups[matchKey] as FeishuGroupConfig | undefined) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function resolveFeishuGroupToolPolicy(
|
|
54
|
+
params: ChannelGroupContext,
|
|
55
|
+
): GroupToolPolicyConfig | undefined {
|
|
56
|
+
const cfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
|
57
|
+
if (!cfg) return undefined;
|
|
58
|
+
|
|
59
|
+
const groupConfig = resolveFeishuGroupConfig({
|
|
60
|
+
cfg,
|
|
61
|
+
groupId: params.groupId,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return groupConfig?.tools;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isFeishuGroupAllowed(params: {
|
|
68
|
+
groupPolicy: "open" | "allowlist" | "disabled";
|
|
69
|
+
allowFrom: Array<string | number>;
|
|
70
|
+
senderId: string;
|
|
71
|
+
senderName?: string | null;
|
|
72
|
+
}): boolean {
|
|
73
|
+
const { groupPolicy } = params;
|
|
74
|
+
if (groupPolicy === "disabled") return false;
|
|
75
|
+
if (groupPolicy === "open") return true;
|
|
76
|
+
return resolveFeishuAllowlistMatch(params).allowed;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function resolveFeishuReplyPolicy(params: {
|
|
80
|
+
isDirectMessage: boolean;
|
|
81
|
+
globalConfig?: FeishuConfig;
|
|
82
|
+
groupConfig?: FeishuGroupConfig;
|
|
83
|
+
}): { requireMention: boolean } {
|
|
84
|
+
if (params.isDirectMessage) {
|
|
85
|
+
return { requireMention: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const requireMention =
|
|
89
|
+
params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
|
|
90
|
+
|
|
91
|
+
return { requireMention };
|
|
92
|
+
}
|
package/src/probe.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FeishuProbeResult } from "./types.js";
|
|
2
|
+
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
|
3
|
+
|
|
4
|
+
export async function probeFeishu(creds?: FeishuClientCredentials): Promise<FeishuProbeResult> {
|
|
5
|
+
if (!creds?.appId || !creds?.appSecret) {
|
|
6
|
+
return {
|
|
7
|
+
ok: false,
|
|
8
|
+
error: "missing credentials (appId, appSecret)",
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const client = createFeishuClient(creds);
|
|
14
|
+
// Use bot/v3/info API to get bot information
|
|
15
|
+
const response = await (client as any).request({
|
|
16
|
+
method: "GET",
|
|
17
|
+
url: "/open-apis/bot/v3/info",
|
|
18
|
+
data: {},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (response.code !== 0) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
appId: creds.appId,
|
|
25
|
+
error: `API error: ${response.msg || `code ${response.code}`}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const bot = response.bot || response.data?.bot;
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
appId: creds.appId,
|
|
33
|
+
botName: bot?.bot_name,
|
|
34
|
+
botOpenId: bot?.open_id,
|
|
35
|
+
};
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
appId: creds.appId,
|
|
40
|
+
error: err instanceof Error ? err.message : String(err),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/reactions.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { createFeishuClient } from "./client.js";
|
|
3
|
+
import { resolveFeishuAccount } from "./accounts.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
|
+
accountId?: string;
|
|
22
|
+
}): Promise<{ reactionId: string }> {
|
|
23
|
+
const { cfg, messageId, emojiType, accountId } = params;
|
|
24
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
25
|
+
if (!account.configured) {
|
|
26
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const client = createFeishuClient(account);
|
|
30
|
+
|
|
31
|
+
const response = (await client.im.messageReaction.create({
|
|
32
|
+
path: { message_id: messageId },
|
|
33
|
+
data: {
|
|
34
|
+
reaction_type: {
|
|
35
|
+
emoji_type: emojiType,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
})) as {
|
|
39
|
+
code?: number;
|
|
40
|
+
msg?: string;
|
|
41
|
+
data?: { reaction_id?: string };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (response.code !== 0) {
|
|
45
|
+
throw new Error(`Feishu add reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const reactionId = response.data?.reaction_id;
|
|
49
|
+
if (!reactionId) {
|
|
50
|
+
throw new Error("Feishu add reaction failed: no reaction_id returned");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { reactionId };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remove a reaction from a message.
|
|
58
|
+
*/
|
|
59
|
+
export async function removeReactionFeishu(params: {
|
|
60
|
+
cfg: ClawdbotConfig;
|
|
61
|
+
messageId: string;
|
|
62
|
+
reactionId: string;
|
|
63
|
+
accountId?: string;
|
|
64
|
+
}): Promise<void> {
|
|
65
|
+
const { cfg, messageId, reactionId, accountId } = params;
|
|
66
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
67
|
+
if (!account.configured) {
|
|
68
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const client = createFeishuClient(account);
|
|
72
|
+
|
|
73
|
+
const response = (await client.im.messageReaction.delete({
|
|
74
|
+
path: {
|
|
75
|
+
message_id: messageId,
|
|
76
|
+
reaction_id: reactionId,
|
|
77
|
+
},
|
|
78
|
+
})) as { code?: number; msg?: string };
|
|
79
|
+
|
|
80
|
+
if (response.code !== 0) {
|
|
81
|
+
throw new Error(`Feishu remove reaction failed: ${response.msg || `code ${response.code}`}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* List all reactions for a message.
|
|
87
|
+
*/
|
|
88
|
+
export async function listReactionsFeishu(params: {
|
|
89
|
+
cfg: ClawdbotConfig;
|
|
90
|
+
messageId: string;
|
|
91
|
+
emojiType?: string;
|
|
92
|
+
accountId?: string;
|
|
93
|
+
}): Promise<FeishuReaction[]> {
|
|
94
|
+
const { cfg, messageId, emojiType, accountId } = params;
|
|
95
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
96
|
+
if (!account.configured) {
|
|
97
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const client = createFeishuClient(account);
|
|
101
|
+
|
|
102
|
+
const response = (await client.im.messageReaction.list({
|
|
103
|
+
path: { message_id: messageId },
|
|
104
|
+
params: emojiType ? { reaction_type: emojiType } : undefined,
|
|
105
|
+
})) as {
|
|
106
|
+
code?: number;
|
|
107
|
+
msg?: string;
|
|
108
|
+
data?: {
|
|
109
|
+
items?: Array<{
|
|
110
|
+
reaction_id?: string;
|
|
111
|
+
reaction_type?: { emoji_type?: string };
|
|
112
|
+
operator_type?: string;
|
|
113
|
+
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
|
114
|
+
}>;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (response.code !== 0) {
|
|
119
|
+
throw new Error(`Feishu list reactions failed: ${response.msg || `code ${response.code}`}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const items = response.data?.items ?? [];
|
|
123
|
+
return items.map((item) => ({
|
|
124
|
+
reactionId: item.reaction_id ?? "",
|
|
125
|
+
emojiType: item.reaction_type?.emoji_type ?? "",
|
|
126
|
+
operatorType: item.operator_type === "app" ? "app" : "user",
|
|
127
|
+
operatorId:
|
|
128
|
+
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Common Feishu emoji types for convenience.
|
|
134
|
+
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
|
135
|
+
*/
|
|
136
|
+
export const FeishuEmoji = {
|
|
137
|
+
// Common reactions
|
|
138
|
+
THUMBSUP: "THUMBSUP",
|
|
139
|
+
THUMBSDOWN: "THUMBSDOWN",
|
|
140
|
+
HEART: "HEART",
|
|
141
|
+
SMILE: "SMILE",
|
|
142
|
+
GRINNING: "GRINNING",
|
|
143
|
+
LAUGHING: "LAUGHING",
|
|
144
|
+
CRY: "CRY",
|
|
145
|
+
ANGRY: "ANGRY",
|
|
146
|
+
SURPRISED: "SURPRISED",
|
|
147
|
+
THINKING: "THINKING",
|
|
148
|
+
CLAP: "CLAP",
|
|
149
|
+
OK: "OK",
|
|
150
|
+
FIST: "FIST",
|
|
151
|
+
PRAY: "PRAY",
|
|
152
|
+
FIRE: "FIRE",
|
|
153
|
+
PARTY: "PARTY",
|
|
154
|
+
CHECK: "CHECK",
|
|
155
|
+
CROSS: "CROSS",
|
|
156
|
+
QUESTION: "QUESTION",
|
|
157
|
+
EXCLAMATION: "EXCLAMATION",
|
|
158
|
+
} as const;
|
|
159
|
+
|
|
160
|
+
export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji];
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect if text contains markdown elements that benefit from card rendering.
|
|
22
|
+
* Used by auto render mode.
|
|
23
|
+
*/
|
|
24
|
+
function shouldUseCard(text: string): boolean {
|
|
25
|
+
// Code blocks (fenced)
|
|
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;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CreateFeishuReplyDispatcherParams = {
|
|
33
|
+
cfg: ClawdbotConfig;
|
|
34
|
+
agentId: string;
|
|
35
|
+
runtime: RuntimeEnv;
|
|
36
|
+
chatId: string;
|
|
37
|
+
replyToMessageId?: string;
|
|
38
|
+
/** Mention targets, will be auto-included in replies */
|
|
39
|
+
mentionTargets?: MentionTarget[];
|
|
40
|
+
/** Account ID for multi-account support */
|
|
41
|
+
accountId?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
|
|
45
|
+
const core = getFeishuRuntime();
|
|
46
|
+
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
|
|
47
|
+
|
|
48
|
+
// Resolve account for config access
|
|
49
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
50
|
+
|
|
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
|
+
let typingState: TypingIndicatorState | null = null;
|
|
59
|
+
|
|
60
|
+
const typingCallbacks = createTypingCallbacks({
|
|
61
|
+
start: async () => {
|
|
62
|
+
if (!replyToMessageId) return;
|
|
63
|
+
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
|
|
64
|
+
params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`);
|
|
65
|
+
},
|
|
66
|
+
stop: async () => {
|
|
67
|
+
if (!typingState) return;
|
|
68
|
+
await removeTypingIndicator({ cfg, state: typingState, accountId });
|
|
69
|
+
typingState = null;
|
|
70
|
+
params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`);
|
|
71
|
+
},
|
|
72
|
+
onStartError: (err) => {
|
|
73
|
+
logTypingFailure({
|
|
74
|
+
log: (message) => params.runtime.log?.(message),
|
|
75
|
+
channel: "feishu",
|
|
76
|
+
action: "start",
|
|
77
|
+
error: err,
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
onStopError: (err) => {
|
|
81
|
+
logTypingFailure({
|
|
82
|
+
log: (message) => params.runtime.log?.(message),
|
|
83
|
+
channel: "feishu",
|
|
84
|
+
action: "stop",
|
|
85
|
+
error: err,
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const textChunkLimit = core.channel.text.resolveTextChunkLimit({
|
|
91
|
+
cfg,
|
|
92
|
+
channel: "feishu",
|
|
93
|
+
defaultLimit: 4000,
|
|
94
|
+
});
|
|
95
|
+
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
|
96
|
+
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
97
|
+
cfg,
|
|
98
|
+
channel: "feishu",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
102
|
+
core.channel.reply.createReplyDispatcherWithTyping({
|
|
103
|
+
responsePrefix: prefixContext.responsePrefix,
|
|
104
|
+
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
|
|
105
|
+
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
106
|
+
onReplyStart: typingCallbacks.onReplyStart,
|
|
107
|
+
deliver: async (payload: ReplyPayload) => {
|
|
108
|
+
params.runtime.log?.(`feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`);
|
|
109
|
+
const text = payload.text ?? "";
|
|
110
|
+
if (!text.trim()) {
|
|
111
|
+
params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check render mode: auto (default), raw, or card
|
|
116
|
+
const feishuCfg = account.config;
|
|
117
|
+
const renderMode = feishuCfg?.renderMode ?? "auto";
|
|
118
|
+
|
|
119
|
+
// Determine if we should use card for this message
|
|
120
|
+
const useCard =
|
|
121
|
+
renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
|
122
|
+
|
|
123
|
+
// Only include @mentions in the first chunk (avoid duplicate @s)
|
|
124
|
+
let isFirstChunk = true;
|
|
125
|
+
|
|
126
|
+
if (useCard) {
|
|
127
|
+
// Card mode: send as interactive card with markdown rendering
|
|
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) {
|
|
131
|
+
await sendMarkdownCardFeishu({
|
|
132
|
+
cfg,
|
|
133
|
+
to: chatId,
|
|
134
|
+
text: chunk,
|
|
135
|
+
replyToMessageId,
|
|
136
|
+
mentions: isFirstChunk ? mentionTargets : undefined,
|
|
137
|
+
accountId,
|
|
138
|
+
});
|
|
139
|
+
isFirstChunk = false;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// Raw mode: send as plain text with table conversion
|
|
143
|
+
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
|
|
144
|
+
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
|
|
145
|
+
params.runtime.log?.(`feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`);
|
|
146
|
+
for (const chunk of chunks) {
|
|
147
|
+
await sendMessageFeishu({
|
|
148
|
+
cfg,
|
|
149
|
+
to: chatId,
|
|
150
|
+
text: chunk,
|
|
151
|
+
replyToMessageId,
|
|
152
|
+
mentions: isFirstChunk ? mentionTargets : undefined,
|
|
153
|
+
accountId,
|
|
154
|
+
});
|
|
155
|
+
isFirstChunk = false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
onError: (err, info) => {
|
|
160
|
+
params.runtime.error?.(`feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`);
|
|
161
|
+
typingCallbacks.onIdle?.();
|
|
162
|
+
},
|
|
163
|
+
onIdle: typingCallbacks.onIdle,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
dispatcher,
|
|
168
|
+
replyOptions: {
|
|
169
|
+
...replyOptions,
|
|
170
|
+
onModelSelected: prefixContext.onModelSelected,
|
|
171
|
+
},
|
|
172
|
+
markDispatchIdle,
|
|
173
|
+
};
|
|
174
|
+
}
|
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
|
+
}
|