moltbot-channel-feishu 0.0.8

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,199 @@
1
+ /**
2
+ * Access policy engine for DM and group messages.
3
+ */
4
+
5
+ import type { Config, GroupConfig } from "../config/schema.js";
6
+
7
+ // ============================================================================
8
+ // Types
9
+ // ============================================================================
10
+
11
+ export interface PolicyResult {
12
+ allowed: boolean;
13
+ reason?: string;
14
+ }
15
+
16
+ export interface AllowlistMatch {
17
+ allowed: boolean;
18
+ matchKey?: string;
19
+ matchSource?: "wildcard" | "id" | "name";
20
+ }
21
+
22
+ // ============================================================================
23
+ // Allowlist Matching
24
+ // ============================================================================
25
+
26
+ /**
27
+ * Check if a sender matches an allowlist.
28
+ */
29
+ export function matchAllowlist(
30
+ allowFrom: (string | number)[],
31
+ senderId: string,
32
+ senderName?: string | null
33
+ ): AllowlistMatch {
34
+ const normalized = allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean);
35
+
36
+ if (normalized.length === 0) {
37
+ return { allowed: false };
38
+ }
39
+
40
+ // Check for wildcard
41
+ if (normalized.includes("*")) {
42
+ return { allowed: true, matchKey: "*", matchSource: "wildcard" };
43
+ }
44
+
45
+ // Check by ID
46
+ const lowerSenderId = senderId.toLowerCase();
47
+ if (normalized.includes(lowerSenderId)) {
48
+ return { allowed: true, matchKey: lowerSenderId, matchSource: "id" };
49
+ }
50
+
51
+ // Check by name
52
+ const lowerName = senderName?.toLowerCase();
53
+ if (lowerName && normalized.includes(lowerName)) {
54
+ return { allowed: true, matchKey: lowerName, matchSource: "name" };
55
+ }
56
+
57
+ return { allowed: false };
58
+ }
59
+
60
+ // ============================================================================
61
+ // DM Policy
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Check if a DM from a sender is allowed.
66
+ */
67
+ export function checkDmPolicy(
68
+ config: Config,
69
+ senderId: string,
70
+ senderName?: string | null
71
+ ): PolicyResult {
72
+ const policy = config.dmPolicy ?? "pairing";
73
+
74
+ switch (policy) {
75
+ case "open":
76
+ return { allowed: true };
77
+
78
+ case "pairing":
79
+ // Pairing requires verification flow handled elsewhere
80
+ return { allowed: true };
81
+
82
+ case "allowlist": {
83
+ const allowFrom = config.allowFrom ?? [];
84
+ const match = matchAllowlist(allowFrom, senderId, senderName);
85
+ return match.allowed
86
+ ? { allowed: true }
87
+ : { allowed: false, reason: "Sender not in DM allowlist" };
88
+ }
89
+
90
+ default:
91
+ return { allowed: false, reason: `Unknown DM policy: ${policy}` };
92
+ }
93
+ }
94
+
95
+ // ============================================================================
96
+ // Group Policy
97
+ // ============================================================================
98
+
99
+ /**
100
+ * Resolve group-specific configuration.
101
+ */
102
+ export function resolveGroupConfig(
103
+ config: Config,
104
+ groupId: string | null | undefined
105
+ ): GroupConfig | undefined {
106
+ if (!groupId) return undefined;
107
+
108
+ const groups = config.groups ?? {};
109
+ const trimmed = groupId.trim();
110
+
111
+ // Direct match
112
+ const direct = groups[trimmed];
113
+ if (direct) return direct;
114
+
115
+ // Case-insensitive match
116
+ const lowered = trimmed.toLowerCase();
117
+ const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
118
+ return matchKey ? groups[matchKey] : undefined;
119
+ }
120
+
121
+ /**
122
+ * Check if a message in a group from a sender is allowed.
123
+ */
124
+ export function checkGroupPolicy(
125
+ config: Config,
126
+ groupId: string,
127
+ senderId: string,
128
+ senderName?: string | null
129
+ ): PolicyResult {
130
+ const policy = config.groupPolicy ?? "allowlist";
131
+
132
+ switch (policy) {
133
+ case "disabled":
134
+ return { allowed: false, reason: "Group messages disabled" };
135
+
136
+ case "open":
137
+ return { allowed: true };
138
+
139
+ case "allowlist": {
140
+ // Check group-specific allowlist first
141
+ const groupConfig = resolveGroupConfig(config, groupId);
142
+ const groupAllowFrom = groupConfig?.allowFrom ?? config.groupAllowFrom ?? [];
143
+
144
+ if (groupAllowFrom.length === 0) {
145
+ // No allowlist configured, deny by default
146
+ return {
147
+ allowed: false,
148
+ reason: "No group allowlist configured",
149
+ };
150
+ }
151
+
152
+ const match = matchAllowlist(groupAllowFrom, senderId, senderName);
153
+ return match.allowed
154
+ ? { allowed: true }
155
+ : { allowed: false, reason: "Sender not in group allowlist" };
156
+ }
157
+
158
+ default:
159
+ return { allowed: false, reason: `Unknown group policy: ${policy}` };
160
+ }
161
+ }
162
+
163
+ // ============================================================================
164
+ // Mention Policy
165
+ // ============================================================================
166
+
167
+ /**
168
+ * Check if an @mention is required for the given context.
169
+ */
170
+ export function shouldRequireMention(
171
+ config: Config,
172
+ chatType: "p2p" | "group",
173
+ groupId?: string | null
174
+ ): boolean {
175
+ // Never require mention in DMs
176
+ if (chatType === "p2p") {
177
+ return false;
178
+ }
179
+
180
+ // Check group-specific config
181
+ const groupConfig = resolveGroupConfig(config, groupId);
182
+ if (groupConfig?.requireMention !== undefined) {
183
+ return groupConfig.requireMention;
184
+ }
185
+
186
+ // Fall back to global config
187
+ return config.requireMention ?? true;
188
+ }
189
+
190
+ /**
191
+ * Get tool policy for a group.
192
+ */
193
+ export function resolveGroupToolPolicy(
194
+ config: Config,
195
+ groupId: string | null | undefined
196
+ ): { allow?: string[]; deny?: string[] } | undefined {
197
+ const groupConfig = resolveGroupConfig(config, groupId);
198
+ return groupConfig?.tools;
199
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Reply dispatcher for Feishu.
3
+ * Creates a dispatcher that sends agent replies back to Feishu.
4
+ */
5
+
6
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
7
+
8
+ import type { ClawdbotConfig, RuntimeEnv, ReplyPayload, PluginRuntime } from "clawdbot/plugin-sdk";
9
+ import {
10
+ createReplyPrefixContext,
11
+ createTypingCallbacks,
12
+ logTypingFailure,
13
+ } from "clawdbot/plugin-sdk";
14
+
15
+ import { getRuntime } from "./runtime.js";
16
+ import { sendTextMessage } from "../api/messages.js";
17
+ import { addReaction, removeReaction, Emoji } from "../api/reactions.js";
18
+ import type { Config } from "../config/schema.js";
19
+
20
+ // ============================================================================
21
+ // Types
22
+ // ============================================================================
23
+
24
+ export interface CreateReplyDispatcherParams {
25
+ cfg: ClawdbotConfig;
26
+ agentId: string;
27
+ runtime: RuntimeEnv;
28
+ chatId: string;
29
+ replyToMessageId?: string;
30
+ }
31
+
32
+ interface TypingIndicatorState {
33
+ messageId: string;
34
+ emoji: string;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Reply Dispatcher
39
+ // ============================================================================
40
+
41
+ export function createReplyDispatcher(params: CreateReplyDispatcherParams) {
42
+ const core = getRuntime() as PluginRuntime;
43
+ const { cfg, agentId, chatId, replyToMessageId } = params;
44
+ const feishuCfg = cfg.channels?.feishu as Config | undefined;
45
+
46
+ const prefixContext = createReplyPrefixContext({
47
+ cfg,
48
+ agentId,
49
+ });
50
+
51
+ // Typing indicator using reactions
52
+ let typingState: TypingIndicatorState | null = null;
53
+
54
+ const typingCallbacks = createTypingCallbacks({
55
+ start: async () => {
56
+ if (!replyToMessageId || !feishuCfg) return;
57
+ try {
58
+ const reactionId = await addReaction(feishuCfg, {
59
+ messageId: replyToMessageId,
60
+ emojiType: Emoji.TYPING,
61
+ });
62
+ typingState = { messageId: replyToMessageId, emoji: reactionId };
63
+ params.runtime.log?.(`Added typing indicator reaction`);
64
+ } catch (err) {
65
+ params.runtime.log?.(`Failed to add typing reaction: ${String(err)}`);
66
+ }
67
+ },
68
+ stop: async () => {
69
+ if (!typingState || !feishuCfg) return;
70
+ try {
71
+ await removeReaction(feishuCfg, {
72
+ messageId: typingState.messageId,
73
+ reactionId: typingState.emoji,
74
+ });
75
+ typingState = null;
76
+ params.runtime.log?.(`Removed typing indicator reaction`);
77
+ } catch (err) {
78
+ params.runtime.log?.(`Failed to remove typing reaction: ${String(err)}`);
79
+ }
80
+ },
81
+ onStartError: (err) => {
82
+ logTypingFailure({
83
+ log: (message) => params.runtime.log?.(message),
84
+ channel: "feishu",
85
+ action: "start",
86
+ error: err,
87
+ });
88
+ },
89
+ onStopError: (err) => {
90
+ logTypingFailure({
91
+ log: (message) => params.runtime.log?.(message),
92
+ channel: "feishu",
93
+ action: "stop",
94
+ error: err,
95
+ });
96
+ },
97
+ });
98
+
99
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit({
100
+ cfg,
101
+ channel: "feishu",
102
+ defaultLimit: 4000,
103
+ });
104
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
105
+ const tableMode = core.channel.text.resolveMarkdownTableMode({
106
+ cfg,
107
+ channel: "feishu",
108
+ });
109
+
110
+ const { dispatcher, replyOptions, markDispatchIdle } =
111
+ core.channel.reply.createReplyDispatcherWithTyping({
112
+ responsePrefix: prefixContext.responsePrefix,
113
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
114
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
115
+ onReplyStart: typingCallbacks.onReplyStart,
116
+ deliver: async (payload: ReplyPayload) => {
117
+ params.runtime.log?.(`Deliver called: text=${payload.text?.slice(0, 100)}`);
118
+ const text = payload.text ?? "";
119
+ if (!text.trim()) {
120
+ params.runtime.log?.(`Deliver: empty text, skipping`);
121
+ return;
122
+ }
123
+
124
+ const converted = core.channel.text.convertMarkdownTables(text, tableMode);
125
+ const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
126
+
127
+ params.runtime.log?.(`Deliver: sending ${chunks.length} chunks to ${chatId}`);
128
+ for (const chunk of chunks) {
129
+ await sendTextMessage(feishuCfg!, {
130
+ to: chatId,
131
+ text: chunk,
132
+ replyToMessageId,
133
+ });
134
+ }
135
+ },
136
+ onError: (err, info) => {
137
+ params.runtime.error?.(`${info.kind} reply failed: ${String(err)}`);
138
+ typingCallbacks.onIdle?.();
139
+ },
140
+ onIdle: typingCallbacks.onIdle,
141
+ });
142
+
143
+ return {
144
+ dispatcher,
145
+ replyOptions: {
146
+ ...replyOptions,
147
+ onModelSelected: prefixContext.onModelSelected,
148
+ },
149
+ markDispatchIdle,
150
+ };
151
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Runtime management for Feishu plugin.
3
+ * Separated to avoid circular dependencies between plugin and core modules.
4
+ */
5
+
6
+ import type { PluginRuntime } from "clawdbot/plugin-sdk";
7
+
8
+ let feishuRuntime: PluginRuntime | null = null;
9
+
10
+ /**
11
+ * Initialize the runtime for Feishu operations.
12
+ * Called during plugin registration.
13
+ */
14
+ export function initializeRuntime(runtime: PluginRuntime): void {
15
+ feishuRuntime = runtime;
16
+ }
17
+
18
+ /**
19
+ * Get the current runtime.
20
+ * @throws Error if runtime not initialized
21
+ */
22
+ export function getRuntime(): PluginRuntime {
23
+ if (!feishuRuntime) {
24
+ throw new Error("Feishu runtime not initialized");
25
+ }
26
+ return feishuRuntime;
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Package entry point.
3
+ * Exports all public APIs for the Feishu channel plugin.
4
+ */
5
+
6
+ // Plugin (default export)
7
+ export { default } from "./plugin/index.js";
8
+
9
+ // Channel plugin
10
+ export { feishuChannel } from "./plugin/channel.js";
11
+
12
+ // Runtime management
13
+ export { initializeRuntime, getRuntime } from "./plugin/index.js";
14
+
15
+ // API operations
16
+ export {
17
+ sendTextMessage,
18
+ sendCardMessage,
19
+ editMessage,
20
+ updateCard,
21
+ getMessage,
22
+ normalizeTarget,
23
+ isValidId,
24
+ } from "./api/messages.js";
25
+
26
+ export {
27
+ uploadImage,
28
+ uploadFile,
29
+ sendMedia,
30
+ sendImage,
31
+ sendFile,
32
+ detectFileType,
33
+ } from "./api/media.js";
34
+
35
+ export { addReaction, removeReaction, listReactions, Emoji } from "./api/reactions.js";
36
+
37
+ export { listUsers, listGroups } from "./api/directory.js";
38
+
39
+ export { probeConnection, getApiClient, clearClientCache } from "./api/client.js";
40
+
41
+ // Core utilities
42
+ export { startGateway, stopGateway, getBotOpenId } from "./core/gateway.js";
43
+
44
+ export { parseMessageEvent, isBotMentioned, stripMentions } from "./core/parser.js";
45
+
46
+ export {
47
+ checkDmPolicy,
48
+ checkGroupPolicy,
49
+ shouldRequireMention,
50
+ matchAllowlist,
51
+ } from "./core/policy.js";
52
+
53
+ export {
54
+ validateMessage,
55
+ sendReply,
56
+ sendChunkedReply,
57
+ addTypingIndicator,
58
+ removeTypingIndicator,
59
+ } from "./core/dispatcher.js";
60
+
61
+ // Configuration
62
+ export {
63
+ ConfigSchema,
64
+ resolveCredentials,
65
+ type Config,
66
+ type GroupConfig,
67
+ type Credentials,
68
+ } from "./config/schema.js";
69
+
70
+ // Types
71
+ export type {
72
+ // Events
73
+ MessageReceivedEvent,
74
+ BotAddedEvent,
75
+ BotRemovedEvent,
76
+ MessageSender,
77
+ MessagePayload,
78
+ MessageMention,
79
+ EventHandlers,
80
+ // Messages
81
+ SendTextParams,
82
+ SendCardParams,
83
+ EditMessageParams,
84
+ SendResult,
85
+ MessageInfo,
86
+ ParsedMessage,
87
+ ReceiveIdType,
88
+ ChatType,
89
+ // Media
90
+ UploadImageParams,
91
+ UploadFileParams,
92
+ SendMediaParams,
93
+ ImageUploadResult,
94
+ FileUploadResult,
95
+ FileType,
96
+ SendImageParams,
97
+ SendFileParams,
98
+ // Reactions
99
+ Reaction,
100
+ AddReactionParams,
101
+ RemoveReactionParams,
102
+ // Directory
103
+ DirectoryUser,
104
+ DirectoryGroup,
105
+ ListDirectoryParams,
106
+ // Probe
107
+ ProbeResult,
108
+ } from "./types/index.js";