@openclaw/feishu 2026.3.12 → 2026.5.1-beta.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.
- package/api.ts +31 -0
- package/channel-entry.ts +20 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +16 -0
- package/index.ts +70 -53
- package/openclaw.plugin.json +1653 -4
- package/package.json +32 -7
- package/runtime-api.ts +55 -0
- package/secret-contract-api.ts +5 -0
- package/security-contract-api.ts +1 -0
- package/session-key-api.ts +1 -0
- package/setup-api.ts +3 -0
- package/setup-entry.test.ts +14 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.test.ts +115 -22
- package/src/accounts.ts +199 -117
- package/src/app-registration.ts +331 -0
- package/src/approval-auth.test.ts +24 -0
- package/src/approval-auth.ts +25 -0
- package/src/async.test.ts +35 -0
- package/src/async.ts +43 -1
- package/src/audio-preflight.runtime.ts +9 -0
- package/src/bitable.test.ts +131 -0
- package/src/bitable.ts +59 -22
- package/src/bot-content.ts +474 -0
- package/src/bot-group-name.test.ts +108 -0
- package/src/bot-runtime-api.ts +12 -0
- package/src/bot-sender-name.ts +125 -0
- package/src/bot.broadcast.test.ts +463 -0
- package/src/bot.card-action.test.ts +519 -5
- package/src/bot.checkBotMentioned.test.ts +92 -20
- package/src/bot.helpers.test.ts +118 -0
- package/src/bot.stripBotMention.test.ts +13 -21
- package/src/bot.test.ts +1334 -401
- package/src/bot.ts +798 -786
- package/src/card-action.ts +408 -40
- package/src/card-interaction.test.ts +129 -0
- package/src/card-interaction.ts +159 -0
- package/src/card-test-helpers.ts +47 -0
- package/src/card-ux-approval.ts +65 -0
- package/src/card-ux-launcher.test.ts +99 -0
- package/src/card-ux-launcher.ts +121 -0
- package/src/card-ux-shared.ts +33 -0
- package/src/channel-runtime-api.ts +16 -0
- package/src/channel.runtime.ts +47 -0
- package/src/channel.test.ts +914 -3
- package/src/channel.ts +1252 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +84 -28
- package/src/chat.ts +68 -10
- package/src/client.test.ts +212 -103
- package/src/client.ts +115 -21
- package/src/comment-dispatcher-runtime-api.ts +6 -0
- package/src/comment-dispatcher.test.ts +169 -0
- package/src/comment-dispatcher.ts +107 -0
- package/src/comment-handler-runtime-api.ts +3 -0
- package/src/comment-handler.test.ts +486 -0
- package/src/comment-handler.ts +309 -0
- package/src/comment-reaction.test.ts +166 -0
- package/src/comment-reaction.ts +259 -0
- package/src/comment-shared.test.ts +182 -0
- package/src/comment-shared.ts +365 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +77 -25
- package/src/config-schema.ts +31 -4
- package/src/conversation-id.test.ts +18 -0
- package/src/conversation-id.ts +199 -0
- package/src/dedup-runtime-api.ts +1 -0
- package/src/dedup.ts +76 -35
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +119 -20
- package/src/directory.ts +61 -91
- package/src/doc-schema.ts +1 -1
- package/src/docx-batch-insert.test.ts +39 -38
- package/src/docx-batch-insert.ts +55 -19
- package/src/docx-color-text.ts +9 -4
- package/src/docx-table-ops.test.ts +53 -0
- package/src/docx-table-ops.ts +52 -34
- package/src/docx-types.ts +38 -0
- package/src/docx.account-selection.test.ts +12 -3
- package/src/docx.test.ts +314 -74
- package/src/docx.ts +278 -122
- package/src/drive-schema.ts +47 -1
- package/src/drive.test.ts +1219 -0
- package/src/drive.ts +614 -13
- package/src/dynamic-agent.ts +10 -4
- package/src/event-types.ts +45 -0
- package/src/external-keys.ts +1 -1
- package/src/lifecycle.test-support.ts +220 -0
- package/src/media.test.ts +413 -87
- package/src/media.ts +488 -154
- package/src/mention-target.types.ts +5 -0
- package/src/mention.ts +32 -51
- package/src/message-action-contract.ts +13 -0
- package/src/monitor-state-runtime-api.ts +7 -0
- package/src/monitor-transport-runtime-api.ts +7 -0
- package/src/monitor.account.ts +220 -313
- package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
- package/src/monitor.bot-identity.ts +86 -0
- package/src/monitor.bot-menu-handler.ts +165 -0
- package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
- package/src/monitor.bot-menu.test.ts +178 -0
- package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
- package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
- package/src/monitor.cleanup.test.ts +376 -0
- package/src/monitor.comment-notice-handler.ts +105 -0
- package/src/monitor.comment.test.ts +937 -0
- package/src/monitor.comment.ts +1386 -0
- package/src/monitor.lifecycle.test.ts +4 -0
- package/src/monitor.message-handler.ts +339 -0
- package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
- package/src/monitor.reaction.test.ts +194 -92
- package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
- package/src/monitor.startup.test.ts +24 -36
- package/src/monitor.startup.ts +26 -16
- package/src/monitor.state.ts +20 -5
- package/src/monitor.synthetic-error.ts +18 -0
- package/src/monitor.test-mocks.ts +2 -2
- package/src/monitor.transport.ts +297 -39
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +272 -0
- package/src/monitor.webhook-security.test.ts +125 -91
- package/src/monitor.webhook.test-helpers.ts +116 -0
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +627 -53
- package/src/outbound.ts +623 -81
- package/src/perm-schema.ts +1 -1
- package/src/perm.ts +1 -7
- package/src/pins.ts +108 -0
- package/src/policy.test.ts +297 -117
- package/src/policy.ts +142 -29
- package/src/post.ts +7 -6
- package/src/probe.test.ts +122 -118
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +23 -60
- package/src/reasoning-preview.test.ts +59 -0
- package/src/reasoning-preview.ts +20 -0
- package/src/reply-dispatcher-runtime-api.ts +7 -0
- package/src/reply-dispatcher.test.ts +721 -168
- package/src/reply-dispatcher.ts +422 -172
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +145 -0
- package/src/secret-input.ts +1 -13
- package/src/security-audit-shared.ts +69 -0
- package/src/security-audit.test.ts +61 -0
- package/src/security-audit.ts +1 -0
- package/src/send-result.ts +1 -1
- package/src/send-target.test.ts +9 -3
- package/src/send-target.ts +10 -4
- package/src/send.reply-fallback.test.ts +127 -42
- package/src/send.test.ts +386 -4
- package/src/send.ts +486 -164
- package/src/sequential-key.test.ts +72 -0
- package/src/sequential-key.ts +28 -0
- package/src/sequential-queue.test.ts +92 -0
- package/src/sequential-queue.ts +16 -0
- package/src/session-conversation.ts +42 -0
- package/src/session-route.ts +48 -0
- package/src/setup-core.ts +51 -0
- package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
- package/src/setup-surface.ts +581 -0
- package/src/streaming-card.test.ts +138 -2
- package/src/streaming-card.ts +134 -18
- package/src/subagent-hooks.test.ts +603 -0
- package/src/subagent-hooks.ts +397 -0
- package/src/targets.ts +3 -13
- package/src/test-support/lifecycle-test-support.ts +479 -0
- package/src/thread-bindings.test.ts +143 -0
- package/src/thread-bindings.ts +330 -0
- package/src/tool-account-routing.test.ts +66 -8
- package/src/tool-account.test.ts +44 -0
- package/src/tool-account.ts +40 -17
- package/src/tool-factory-test-harness.ts +11 -8
- package/src/tool-result.ts +3 -1
- package/src/tools-config.ts +1 -1
- package/src/types.ts +16 -15
- package/src/typing.ts +10 -6
- package/src/wiki-schema.ts +1 -1
- package/src/wiki.ts +1 -7
- package/subagent-hooks-api.ts +31 -0
- package/tsconfig.json +16 -0
- package/src/feishu-command-handler.ts +0 -59
- package/src/onboarding.status.test.ts +0 -25
- package/src/onboarding.ts +0 -489
- package/src/send-message.ts +0 -71
- package/src/targets.test.ts +0 -70
package/src/bot.ts
CHANGED
|
@@ -1,742 +1,187 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
2
|
+
import {
|
|
3
|
+
ensureConfiguredBindingRouteReady,
|
|
4
|
+
resolveConfiguredBindingRoute,
|
|
5
|
+
resolveRuntimeConversationBindingRoute,
|
|
6
|
+
} from "openclaw/plugin-sdk/conversation-runtime";
|
|
7
|
+
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/outbound-runtime";
|
|
2
8
|
import {
|
|
3
|
-
buildAgentMediaPayload,
|
|
4
9
|
buildPendingHistoryContextFromMap,
|
|
5
10
|
clearHistoryEntriesIfEnabled,
|
|
6
|
-
createScopedPairingAccess,
|
|
7
11
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
8
|
-
type HistoryEntry,
|
|
9
|
-
issuePairingChallenge,
|
|
10
|
-
normalizeAgentId,
|
|
11
12
|
recordPendingHistoryEntryIfEnabled,
|
|
12
|
-
|
|
13
|
+
type HistoryEntry,
|
|
14
|
+
} from "openclaw/plugin-sdk/reply-history";
|
|
15
|
+
import {
|
|
13
16
|
resolveDefaultGroupPolicy,
|
|
17
|
+
resolveOpenProviderRuntimeGroupPolicy,
|
|
14
18
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
15
|
-
} from "openclaw/plugin-sdk/
|
|
16
|
-
import {
|
|
19
|
+
} from "openclaw/plugin-sdk/runtime-group-policy";
|
|
20
|
+
import { resolveOpenDmAllowlistAccess } from "openclaw/plugin-sdk/security-runtime";
|
|
21
|
+
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
|
22
|
+
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
|
23
|
+
import {
|
|
24
|
+
checkBotMentioned,
|
|
25
|
+
normalizeFeishuCommandProbeBody,
|
|
26
|
+
normalizeMentions,
|
|
27
|
+
parseMergeForwardContent,
|
|
28
|
+
parseMessageContent,
|
|
29
|
+
resolveFeishuGroupSession,
|
|
30
|
+
resolveFeishuMediaList,
|
|
31
|
+
toMessageResourceType,
|
|
32
|
+
} from "./bot-content.js";
|
|
33
|
+
import {
|
|
34
|
+
buildAgentMediaPayload,
|
|
35
|
+
evaluateSupplementalContextVisibility,
|
|
36
|
+
filterSupplementalContextItems,
|
|
37
|
+
normalizeAgentId,
|
|
38
|
+
resolveChannelContextVisibilityMode,
|
|
39
|
+
} from "./bot-runtime-api.js";
|
|
40
|
+
import type { ClawdbotConfig, RuntimeEnv } from "./bot-runtime-api.js";
|
|
41
|
+
import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
|
|
42
|
+
import { getChatInfo } from "./chat.js";
|
|
17
43
|
import { createFeishuClient } from "./client.js";
|
|
18
|
-
import {
|
|
44
|
+
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
|
|
19
45
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
20
|
-
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
21
|
-
import { downloadMessageResourceFeishu } from "./media.js";
|
|
22
46
|
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
|
23
47
|
import {
|
|
48
|
+
hasExplicitFeishuGroupConfig,
|
|
49
|
+
isFeishuGroupAllowed,
|
|
50
|
+
resolveFeishuAllowlistMatch,
|
|
24
51
|
resolveFeishuGroupConfig,
|
|
25
52
|
resolveFeishuReplyPolicy,
|
|
26
|
-
resolveFeishuAllowlistMatch,
|
|
27
|
-
isFeishuGroupAllowed,
|
|
28
53
|
} from "./policy.js";
|
|
29
|
-
import {
|
|
54
|
+
import { resolveFeishuReasoningPreviewEnabled } from "./reasoning-preview.js";
|
|
30
55
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
31
56
|
import { getFeishuRuntime } from "./runtime.js";
|
|
32
|
-
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
|
33
|
-
|
|
57
|
+
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
|
|
58
|
+
export type { FeishuBotAddedEvent, FeishuMessageEvent } from "./event-types.js";
|
|
59
|
+
import type { FeishuMessageEvent } from "./event-types.js";
|
|
60
|
+
import {
|
|
61
|
+
isFeishuGroupChatType,
|
|
62
|
+
type FeishuMessageContext,
|
|
63
|
+
type FeishuMediaInfo,
|
|
64
|
+
type FeishuMessageInfo,
|
|
65
|
+
type ResolvedFeishuAccount,
|
|
66
|
+
} from "./types.js";
|
|
34
67
|
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
35
68
|
|
|
36
|
-
|
|
37
|
-
// Extract permission grant URL from Feishu API error response.
|
|
38
|
-
type PermissionError = {
|
|
39
|
-
code: number;
|
|
40
|
-
message: string;
|
|
41
|
-
grantUrl?: string;
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
|
|
45
|
-
|
|
46
|
-
// Feishu API sometimes returns incorrect scope names in permission error
|
|
47
|
-
// responses (e.g. "contact:contact.base:readonly" instead of the valid
|
|
48
|
-
// "contact:user.base:readonly"). This map corrects known mismatches.
|
|
49
|
-
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
|
|
50
|
-
"contact:contact.base:readonly": "contact:user.base:readonly",
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
function correctFeishuScopeInUrl(url: string): string {
|
|
54
|
-
let corrected = url;
|
|
55
|
-
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
|
|
56
|
-
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
|
|
57
|
-
corrected = corrected.replaceAll(wrong, right);
|
|
58
|
-
}
|
|
59
|
-
return corrected;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
|
|
63
|
-
const message = permissionError.message.toLowerCase();
|
|
64
|
-
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function extractPermissionError(err: unknown): PermissionError | null {
|
|
68
|
-
if (!err || typeof err !== "object") return null;
|
|
69
|
-
|
|
70
|
-
// Axios error structure: err.response.data contains the Feishu error
|
|
71
|
-
const axiosErr = err as { response?: { data?: unknown } };
|
|
72
|
-
const data = axiosErr.response?.data;
|
|
73
|
-
if (!data || typeof data !== "object") return null;
|
|
74
|
-
|
|
75
|
-
const feishuErr = data as {
|
|
76
|
-
code?: number;
|
|
77
|
-
msg?: string;
|
|
78
|
-
error?: { permission_violations?: Array<{ uri?: string }> };
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
// Feishu permission error code: 99991672
|
|
82
|
-
if (feishuErr.code !== 99991672) return null;
|
|
83
|
-
|
|
84
|
-
// Extract the grant URL from the error message (contains the direct link)
|
|
85
|
-
const msg = feishuErr.msg ?? "";
|
|
86
|
-
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
|
|
87
|
-
const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
|
|
88
|
-
|
|
89
|
-
return {
|
|
90
|
-
code: feishuErr.code,
|
|
91
|
-
message: msg,
|
|
92
|
-
grantUrl,
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
|
97
|
-
// Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
|
|
98
|
-
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
|
99
|
-
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
69
|
+
export { toMessageResourceType } from "./bot-content.js";
|
|
100
70
|
|
|
101
71
|
// Cache permission errors to avoid spamming the user with repeated notifications.
|
|
102
72
|
// Key: appId or "default", Value: timestamp of last notification
|
|
103
73
|
const permissionErrorNotifiedAt = new Map<string, number>();
|
|
104
74
|
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
105
75
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
|
|
112
|
-
const trimmed = senderId.trim();
|
|
113
|
-
if (trimmed.startsWith("ou_")) {
|
|
114
|
-
return "open_id";
|
|
115
|
-
}
|
|
116
|
-
if (trimmed.startsWith("on_")) {
|
|
117
|
-
return "union_id";
|
|
118
|
-
}
|
|
119
|
-
return "user_id";
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function resolveFeishuSenderName(params: {
|
|
123
|
-
account: ResolvedFeishuAccount;
|
|
124
|
-
senderId: string;
|
|
125
|
-
log: (...args: any[]) => void;
|
|
126
|
-
}): Promise<SenderNameResult> {
|
|
127
|
-
const { account, senderId, log } = params;
|
|
128
|
-
if (!account.configured) return {};
|
|
129
|
-
|
|
130
|
-
const normalizedSenderId = senderId.trim();
|
|
131
|
-
if (!normalizedSenderId) return {};
|
|
76
|
+
const groupNameCache = new Map<string, { name: string; expiresAt: number }>();
|
|
77
|
+
const GROUP_NAME_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
78
|
+
const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap
|
|
132
79
|
|
|
133
|
-
|
|
80
|
+
function evictGroupNameCache(): void {
|
|
134
81
|
const now = Date.now();
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const client = createFeishuClient(account);
|
|
139
|
-
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
|
|
140
|
-
|
|
141
|
-
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
|
|
142
|
-
const res: any = await client.contact.user.get({
|
|
143
|
-
path: { user_id: normalizedSenderId },
|
|
144
|
-
params: { user_id_type: userIdType },
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
const name: string | undefined =
|
|
148
|
-
res?.data?.user?.name ||
|
|
149
|
-
res?.data?.user?.display_name ||
|
|
150
|
-
res?.data?.user?.nickname ||
|
|
151
|
-
res?.data?.user?.en_name;
|
|
152
|
-
|
|
153
|
-
if (name && typeof name === "string") {
|
|
154
|
-
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
|
155
|
-
return { name };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
return {};
|
|
159
|
-
} catch (err) {
|
|
160
|
-
// Check if this is a permission error
|
|
161
|
-
const permErr = extractPermissionError(err);
|
|
162
|
-
if (permErr) {
|
|
163
|
-
if (shouldSuppressPermissionErrorNotice(permErr)) {
|
|
164
|
-
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
|
|
165
|
-
return {};
|
|
166
|
-
}
|
|
167
|
-
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
|
|
168
|
-
return { permissionError: permErr };
|
|
82
|
+
for (const [key, val] of groupNameCache) {
|
|
83
|
+
if (val.expiresAt <= now) {
|
|
84
|
+
groupNameCache.delete(key);
|
|
169
85
|
}
|
|
170
|
-
|
|
171
|
-
// Best-effort. Don't fail message handling if name lookup fails.
|
|
172
|
-
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
|
|
173
|
-
return {};
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
export type FeishuMessageEvent = {
|
|
178
|
-
sender: {
|
|
179
|
-
sender_id: {
|
|
180
|
-
open_id?: string;
|
|
181
|
-
user_id?: string;
|
|
182
|
-
union_id?: string;
|
|
183
|
-
};
|
|
184
|
-
sender_type?: string;
|
|
185
|
-
tenant_key?: string;
|
|
186
|
-
};
|
|
187
|
-
message: {
|
|
188
|
-
message_id: string;
|
|
189
|
-
root_id?: string;
|
|
190
|
-
parent_id?: string;
|
|
191
|
-
thread_id?: string;
|
|
192
|
-
chat_id: string;
|
|
193
|
-
chat_type: "p2p" | "group" | "private";
|
|
194
|
-
message_type: string;
|
|
195
|
-
content: string;
|
|
196
|
-
create_time?: string;
|
|
197
|
-
mentions?: Array<{
|
|
198
|
-
key: string;
|
|
199
|
-
id: {
|
|
200
|
-
open_id?: string;
|
|
201
|
-
user_id?: string;
|
|
202
|
-
union_id?: string;
|
|
203
|
-
};
|
|
204
|
-
name: string;
|
|
205
|
-
tenant_key?: string;
|
|
206
|
-
}>;
|
|
207
|
-
};
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
export type FeishuBotAddedEvent = {
|
|
211
|
-
chat_id: string;
|
|
212
|
-
operator_id: {
|
|
213
|
-
open_id?: string;
|
|
214
|
-
user_id?: string;
|
|
215
|
-
union_id?: string;
|
|
216
|
-
};
|
|
217
|
-
external: boolean;
|
|
218
|
-
operator_tenant_key?: string;
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
|
222
|
-
|
|
223
|
-
type ResolvedFeishuGroupSession = {
|
|
224
|
-
peerId: string;
|
|
225
|
-
parentPeer: { kind: "group"; id: string } | null;
|
|
226
|
-
groupSessionScope: GroupSessionScope;
|
|
227
|
-
replyInThread: boolean;
|
|
228
|
-
threadReply: boolean;
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
function resolveFeishuGroupSession(params: {
|
|
232
|
-
chatId: string;
|
|
233
|
-
senderOpenId: string;
|
|
234
|
-
messageId: string;
|
|
235
|
-
rootId?: string;
|
|
236
|
-
threadId?: string;
|
|
237
|
-
groupConfig?: {
|
|
238
|
-
groupSessionScope?: GroupSessionScope;
|
|
239
|
-
topicSessionMode?: "enabled" | "disabled";
|
|
240
|
-
replyInThread?: "enabled" | "disabled";
|
|
241
|
-
};
|
|
242
|
-
feishuCfg?: {
|
|
243
|
-
groupSessionScope?: GroupSessionScope;
|
|
244
|
-
topicSessionMode?: "enabled" | "disabled";
|
|
245
|
-
replyInThread?: "enabled" | "disabled";
|
|
246
|
-
};
|
|
247
|
-
}): ResolvedFeishuGroupSession {
|
|
248
|
-
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
|
|
249
|
-
|
|
250
|
-
const normalizedThreadId = threadId?.trim();
|
|
251
|
-
const normalizedRootId = rootId?.trim();
|
|
252
|
-
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
|
|
253
|
-
const replyInThread =
|
|
254
|
-
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
|
|
255
|
-
threadReply;
|
|
256
|
-
|
|
257
|
-
const legacyTopicSessionMode =
|
|
258
|
-
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
259
|
-
const groupSessionScope: GroupSessionScope =
|
|
260
|
-
groupConfig?.groupSessionScope ??
|
|
261
|
-
feishuCfg?.groupSessionScope ??
|
|
262
|
-
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
|
|
263
|
-
|
|
264
|
-
// Keep topic session keys stable across the "first turn creates thread" flow:
|
|
265
|
-
// first turn may only have message_id, while the next turn carries root_id/thread_id.
|
|
266
|
-
// Prefer root_id first so both turns stay on the same peer key.
|
|
267
|
-
const topicScope =
|
|
268
|
-
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
|
|
269
|
-
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
|
|
270
|
-
: null;
|
|
271
|
-
|
|
272
|
-
let peerId = chatId;
|
|
273
|
-
switch (groupSessionScope) {
|
|
274
|
-
case "group_sender":
|
|
275
|
-
peerId = `${chatId}:sender:${senderOpenId}`;
|
|
276
|
-
break;
|
|
277
|
-
case "group_topic":
|
|
278
|
-
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
|
|
279
|
-
break;
|
|
280
|
-
case "group_topic_sender":
|
|
281
|
-
peerId = topicScope
|
|
282
|
-
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
|
|
283
|
-
: `${chatId}:sender:${senderOpenId}`;
|
|
284
|
-
break;
|
|
285
|
-
case "group":
|
|
286
|
-
default:
|
|
287
|
-
peerId = chatId;
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const parentPeer =
|
|
292
|
-
topicScope &&
|
|
293
|
-
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
|
294
|
-
? {
|
|
295
|
-
kind: "group" as const,
|
|
296
|
-
id: chatId,
|
|
297
|
-
}
|
|
298
|
-
: null;
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
peerId,
|
|
302
|
-
parentPeer,
|
|
303
|
-
groupSessionScope,
|
|
304
|
-
replyInThread,
|
|
305
|
-
threadReply,
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function parseMessageContent(content: string, messageType: string): string {
|
|
310
|
-
if (messageType === "post") {
|
|
311
|
-
// Extract text content from rich text post
|
|
312
|
-
const { textContent } = parsePostContent(content);
|
|
313
|
-
return textContent;
|
|
314
86
|
}
|
|
315
87
|
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
// Preserve available summary text for merged/forwarded chat messages.
|
|
323
|
-
if (parsed && typeof parsed === "object") {
|
|
324
|
-
const share = parsed as {
|
|
325
|
-
body?: unknown;
|
|
326
|
-
summary?: unknown;
|
|
327
|
-
share_chat_id?: unknown;
|
|
328
|
-
};
|
|
329
|
-
if (typeof share.body === "string" && share.body.trim().length > 0) {
|
|
330
|
-
return share.body.trim();
|
|
331
|
-
}
|
|
332
|
-
if (typeof share.summary === "string" && share.summary.trim().length > 0) {
|
|
333
|
-
return share.summary.trim();
|
|
334
|
-
}
|
|
335
|
-
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
|
|
336
|
-
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
|
|
337
|
-
}
|
|
88
|
+
if (groupNameCache.size > GROUP_NAME_CACHE_MAX_SIZE) {
|
|
89
|
+
const excess = groupNameCache.size - GROUP_NAME_CACHE_MAX_SIZE;
|
|
90
|
+
let removed = 0;
|
|
91
|
+
for (const key of groupNameCache.keys()) {
|
|
92
|
+
if (removed >= excess) {
|
|
93
|
+
break;
|
|
338
94
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (messageType === "merge_forward") {
|
|
342
|
-
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
|
|
343
|
-
return "[Merged and Forwarded Message - loading...]";
|
|
95
|
+
groupNameCache.delete(key);
|
|
96
|
+
removed++;
|
|
344
97
|
}
|
|
345
|
-
return content;
|
|
346
|
-
} catch {
|
|
347
|
-
return content;
|
|
348
98
|
}
|
|
349
99
|
}
|
|
350
100
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
*/
|
|
355
|
-
function parseMergeForwardContent(params: {
|
|
356
|
-
content: string;
|
|
357
|
-
log?: (...args: any[]) => void;
|
|
358
|
-
}): string {
|
|
359
|
-
const { content, log } = params;
|
|
360
|
-
const maxMessages = 50;
|
|
361
|
-
|
|
362
|
-
// For merge_forward, the API returns all sub-messages in items array
|
|
363
|
-
// with upper_message_id pointing to the merge_forward message.
|
|
364
|
-
// The 'content' parameter here is actually the full API response items array as JSON.
|
|
365
|
-
log?.(`feishu: parsing merge_forward sub-messages from API response`);
|
|
366
|
-
|
|
367
|
-
let items: Array<{
|
|
368
|
-
message_id?: string;
|
|
369
|
-
msg_type?: string;
|
|
370
|
-
body?: { content?: string };
|
|
371
|
-
sender?: { id?: string };
|
|
372
|
-
upper_message_id?: string;
|
|
373
|
-
create_time?: string;
|
|
374
|
-
}>;
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
items = JSON.parse(content);
|
|
378
|
-
} catch {
|
|
379
|
-
log?.(`feishu: merge_forward items parse failed`);
|
|
380
|
-
return "[Merged and Forwarded Message - parse error]";
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
if (!Array.isArray(items) || items.length === 0) {
|
|
384
|
-
return "[Merged and Forwarded Message - no sub-messages]";
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
|
|
388
|
-
const subMessages = items.filter((item) => item.upper_message_id);
|
|
389
|
-
|
|
390
|
-
if (subMessages.length === 0) {
|
|
391
|
-
return "[Merged and Forwarded Message - no sub-messages found]";
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
|
|
395
|
-
|
|
396
|
-
// Sort by create_time
|
|
397
|
-
subMessages.sort((a, b) => {
|
|
398
|
-
const timeA = parseInt(a.create_time || "0", 10);
|
|
399
|
-
const timeB = parseInt(b.create_time || "0", 10);
|
|
400
|
-
return timeA - timeB;
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
// Format output
|
|
404
|
-
const lines: string[] = ["[Merged and Forwarded Messages]"];
|
|
405
|
-
const limitedMessages = subMessages.slice(0, maxMessages);
|
|
406
|
-
|
|
407
|
-
for (const item of limitedMessages) {
|
|
408
|
-
const msgContent = item.body?.content || "";
|
|
409
|
-
const msgType = item.msg_type || "text";
|
|
410
|
-
const formatted = formatSubMessageContent(msgContent, msgType);
|
|
411
|
-
lines.push(`- ${formatted}`);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (subMessages.length > maxMessages) {
|
|
415
|
-
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return lines.join("\n");
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Format sub-message content based on message type.
|
|
423
|
-
*/
|
|
424
|
-
function formatSubMessageContent(content: string, contentType: string): string {
|
|
425
|
-
try {
|
|
426
|
-
const parsed = JSON.parse(content);
|
|
427
|
-
switch (contentType) {
|
|
428
|
-
case "text":
|
|
429
|
-
return parsed.text || content;
|
|
430
|
-
case "post": {
|
|
431
|
-
const { textContent } = parsePostContent(content);
|
|
432
|
-
return textContent;
|
|
433
|
-
}
|
|
434
|
-
case "image":
|
|
435
|
-
return "[Image]";
|
|
436
|
-
case "file":
|
|
437
|
-
return `[File: ${parsed.file_name || "unknown"}]`;
|
|
438
|
-
case "audio":
|
|
439
|
-
return "[Audio]";
|
|
440
|
-
case "video":
|
|
441
|
-
return "[Video]";
|
|
442
|
-
case "sticker":
|
|
443
|
-
return "[Sticker]";
|
|
444
|
-
case "merge_forward":
|
|
445
|
-
return "[Nested Merged Forward]";
|
|
446
|
-
default:
|
|
447
|
-
return `[${contentType}]`;
|
|
448
|
-
}
|
|
449
|
-
} catch {
|
|
450
|
-
return content;
|
|
451
|
-
}
|
|
101
|
+
function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
|
|
102
|
+
groupNameCache.delete(key);
|
|
103
|
+
groupNameCache.set(key, value);
|
|
452
104
|
}
|
|
453
105
|
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
// Check for @all (@_all in Feishu) — treat as mentioning every bot
|
|
457
|
-
const rawContent = event.message.content ?? "";
|
|
458
|
-
if (rawContent.includes("@_all")) return true;
|
|
459
|
-
const mentions = event.message.mentions ?? [];
|
|
460
|
-
if (mentions.length > 0) {
|
|
461
|
-
// Rely on Feishu mention IDs; display names can vary by alias/context.
|
|
462
|
-
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
463
|
-
}
|
|
464
|
-
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
|
|
465
|
-
if (event.message.message_type === "post") {
|
|
466
|
-
const { mentionedOpenIds } = parsePostContent(event.message.content);
|
|
467
|
-
return mentionedOpenIds.some((id) => id === botOpenId);
|
|
468
|
-
}
|
|
469
|
-
return false;
|
|
106
|
+
export function clearGroupNameCache(): void {
|
|
107
|
+
groupNameCache.clear();
|
|
470
108
|
}
|
|
471
109
|
|
|
472
|
-
function
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
): string {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
const escapeName = (value: string) => value.replace(/</g, "<").replace(/>/g, ">");
|
|
481
|
-
let result = text;
|
|
482
|
-
|
|
483
|
-
for (const mention of mentions) {
|
|
484
|
-
const mentionId = mention.id.open_id;
|
|
485
|
-
const replacement =
|
|
486
|
-
botStripId && mentionId === botStripId
|
|
487
|
-
? ""
|
|
488
|
-
: mentionId
|
|
489
|
-
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
|
|
490
|
-
: `@${mention.name}`;
|
|
491
|
-
|
|
492
|
-
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
|
|
110
|
+
export async function resolveGroupName(params: {
|
|
111
|
+
account: ResolvedFeishuAccount;
|
|
112
|
+
chatId: string;
|
|
113
|
+
log: (...args: unknown[]) => void;
|
|
114
|
+
}): Promise<string | undefined> {
|
|
115
|
+
const { account, chatId, log } = params;
|
|
116
|
+
if (!account.configured) {
|
|
117
|
+
return undefined;
|
|
493
118
|
}
|
|
494
119
|
|
|
495
|
-
|
|
496
|
-
}
|
|
120
|
+
const cacheKey = `${account.accountId}:${chatId}`;
|
|
497
121
|
|
|
498
|
-
|
|
499
|
-
if (
|
|
500
|
-
return
|
|
122
|
+
const cached = groupNameCache.get(cacheKey);
|
|
123
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
124
|
+
return cached.name || undefined;
|
|
501
125
|
}
|
|
502
|
-
return text
|
|
503
|
-
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
|
|
504
|
-
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
|
|
505
|
-
.replace(/\s+/g, " ")
|
|
506
|
-
.trim();
|
|
507
|
-
}
|
|
508
126
|
|
|
509
|
-
/**
|
|
510
|
-
* Parse media keys from message content based on message type.
|
|
511
|
-
*/
|
|
512
|
-
function parseMediaKeys(
|
|
513
|
-
content: string,
|
|
514
|
-
messageType: string,
|
|
515
|
-
): {
|
|
516
|
-
imageKey?: string;
|
|
517
|
-
fileKey?: string;
|
|
518
|
-
fileName?: string;
|
|
519
|
-
} {
|
|
520
127
|
try {
|
|
521
|
-
const
|
|
522
|
-
const
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
return { fileKey, imageKey };
|
|
535
|
-
case "sticker":
|
|
536
|
-
return { fileKey };
|
|
537
|
-
default:
|
|
538
|
-
return {};
|
|
128
|
+
const client = createFeishuClient(account);
|
|
129
|
+
const chatInfo = await getChatInfo(client, chatId);
|
|
130
|
+
const name = chatInfo?.name?.trim();
|
|
131
|
+
if (name) {
|
|
132
|
+
setCacheEntry(cacheKey, {
|
|
133
|
+
name,
|
|
134
|
+
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
|
135
|
+
});
|
|
136
|
+
} else {
|
|
137
|
+
setCacheEntry(cacheKey, {
|
|
138
|
+
name: "",
|
|
139
|
+
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
|
140
|
+
});
|
|
539
141
|
}
|
|
540
|
-
} catch {
|
|
541
|
-
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`);
|
|
144
|
+
setCacheEntry(cacheKey, {
|
|
145
|
+
name: "",
|
|
146
|
+
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
|
147
|
+
});
|
|
542
148
|
}
|
|
543
|
-
}
|
|
544
149
|
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
* Feishu messageResource API supports only: image | file.
|
|
548
|
-
*/
|
|
549
|
-
export function toMessageResourceType(messageType: string): "image" | "file" {
|
|
550
|
-
return messageType === "image" ? "image" : "file";
|
|
551
|
-
}
|
|
150
|
+
const result = groupNameCache.get(cacheKey)?.name || undefined;
|
|
151
|
+
evictGroupNameCache();
|
|
552
152
|
|
|
553
|
-
|
|
554
|
-
* Infer placeholder text based on message type.
|
|
555
|
-
*/
|
|
556
|
-
function inferPlaceholder(messageType: string): string {
|
|
557
|
-
switch (messageType) {
|
|
558
|
-
case "image":
|
|
559
|
-
return "<media:image>";
|
|
560
|
-
case "file":
|
|
561
|
-
return "<media:document>";
|
|
562
|
-
case "audio":
|
|
563
|
-
return "<media:audio>";
|
|
564
|
-
case "video":
|
|
565
|
-
case "media":
|
|
566
|
-
return "<media:video>";
|
|
567
|
-
case "sticker":
|
|
568
|
-
return "<media:sticker>";
|
|
569
|
-
default:
|
|
570
|
-
return "<media:document>";
|
|
571
|
-
}
|
|
153
|
+
return result;
|
|
572
154
|
}
|
|
573
155
|
|
|
574
|
-
|
|
575
|
-
* Resolve media from a Feishu message, downloading and saving to disk.
|
|
576
|
-
* Similar to Discord's resolveMediaList().
|
|
577
|
-
*/
|
|
578
|
-
async function resolveFeishuMediaList(params: {
|
|
156
|
+
async function resolveFeishuAudioPreflightTranscript(params: {
|
|
579
157
|
cfg: ClawdbotConfig;
|
|
580
|
-
|
|
581
|
-
messageType: string;
|
|
158
|
+
mediaList: FeishuMediaInfo[];
|
|
582
159
|
content: string;
|
|
583
|
-
|
|
584
|
-
log
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
// Only process media message types (including post for embedded images)
|
|
590
|
-
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
|
|
591
|
-
if (!mediaTypes.includes(messageType)) {
|
|
592
|
-
return [];
|
|
160
|
+
chatType: "direct" | "group";
|
|
161
|
+
log: (msg: string) => void;
|
|
162
|
+
}): Promise<string | undefined> {
|
|
163
|
+
if (params.content.trim() !== "<media:audio>") {
|
|
164
|
+
return undefined;
|
|
593
165
|
}
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// Handle post (rich text) messages with embedded images/media.
|
|
599
|
-
if (messageType === "post") {
|
|
600
|
-
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
|
|
601
|
-
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
|
|
602
|
-
return [];
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (imageKeys.length > 0) {
|
|
606
|
-
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
607
|
-
}
|
|
608
|
-
if (postMediaKeys.length > 0) {
|
|
609
|
-
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
for (const imageKey of imageKeys) {
|
|
613
|
-
try {
|
|
614
|
-
// Embedded images in post use messageResource API with image_key as file_key
|
|
615
|
-
const result = await downloadMessageResourceFeishu({
|
|
616
|
-
cfg,
|
|
617
|
-
messageId,
|
|
618
|
-
fileKey: imageKey,
|
|
619
|
-
type: "image",
|
|
620
|
-
accountId,
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
let contentType = result.contentType;
|
|
624
|
-
if (!contentType) {
|
|
625
|
-
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
const saved = await core.channel.media.saveMediaBuffer(
|
|
629
|
-
result.buffer,
|
|
630
|
-
contentType,
|
|
631
|
-
"inbound",
|
|
632
|
-
maxBytes,
|
|
633
|
-
);
|
|
634
|
-
|
|
635
|
-
out.push({
|
|
636
|
-
path: saved.path,
|
|
637
|
-
contentType: saved.contentType,
|
|
638
|
-
placeholder: "<media:image>",
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
|
|
642
|
-
} catch (err) {
|
|
643
|
-
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
for (const media of postMediaKeys) {
|
|
648
|
-
try {
|
|
649
|
-
const result = await downloadMessageResourceFeishu({
|
|
650
|
-
cfg,
|
|
651
|
-
messageId,
|
|
652
|
-
fileKey: media.fileKey,
|
|
653
|
-
type: "file",
|
|
654
|
-
accountId,
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
let contentType = result.contentType;
|
|
658
|
-
if (!contentType) {
|
|
659
|
-
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
const saved = await core.channel.media.saveMediaBuffer(
|
|
663
|
-
result.buffer,
|
|
664
|
-
contentType,
|
|
665
|
-
"inbound",
|
|
666
|
-
maxBytes,
|
|
667
|
-
);
|
|
668
|
-
|
|
669
|
-
out.push({
|
|
670
|
-
path: saved.path,
|
|
671
|
-
contentType: saved.contentType,
|
|
672
|
-
placeholder: "<media:video>",
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
|
|
676
|
-
} catch (err) {
|
|
677
|
-
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
return out;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
// Handle other media types
|
|
685
|
-
const mediaKeys = parseMediaKeys(content, messageType);
|
|
686
|
-
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
|
687
|
-
return [];
|
|
166
|
+
const audioMedia = params.mediaList.filter((media) => media.contentType?.startsWith("audio/"));
|
|
167
|
+
if (audioMedia.length === 0) {
|
|
168
|
+
return undefined;
|
|
688
169
|
}
|
|
689
170
|
|
|
690
171
|
try {
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
return [];
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
const resourceType = toMessageResourceType(messageType);
|
|
703
|
-
const result = await downloadMessageResourceFeishu({
|
|
704
|
-
cfg,
|
|
705
|
-
messageId,
|
|
706
|
-
fileKey,
|
|
707
|
-
type: resourceType,
|
|
708
|
-
accountId,
|
|
709
|
-
});
|
|
710
|
-
buffer = result.buffer;
|
|
711
|
-
contentType = result.contentType;
|
|
712
|
-
fileName = result.fileName || mediaKeys.fileName;
|
|
713
|
-
|
|
714
|
-
// Detect mime type if not provided
|
|
715
|
-
if (!contentType) {
|
|
716
|
-
contentType = await core.media.detectMime({ buffer });
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
// Save to disk using core's saveMediaBuffer
|
|
720
|
-
const saved = await core.channel.media.saveMediaBuffer(
|
|
721
|
-
buffer,
|
|
722
|
-
contentType,
|
|
723
|
-
"inbound",
|
|
724
|
-
maxBytes,
|
|
725
|
-
fileName,
|
|
726
|
-
);
|
|
727
|
-
|
|
728
|
-
out.push({
|
|
729
|
-
path: saved.path,
|
|
730
|
-
contentType: saved.contentType,
|
|
731
|
-
placeholder: inferPlaceholder(messageType),
|
|
172
|
+
const { transcribeFirstAudio } = await import("./audio-preflight.runtime.js");
|
|
173
|
+
return await transcribeFirstAudio({
|
|
174
|
+
ctx: {
|
|
175
|
+
MediaPaths: audioMedia.map((media) => media.path),
|
|
176
|
+
MediaTypes: audioMedia.map((media) => media.contentType).filter(Boolean) as string[],
|
|
177
|
+
ChatType: params.chatType,
|
|
178
|
+
},
|
|
179
|
+
cfg: params.cfg,
|
|
732
180
|
});
|
|
733
|
-
|
|
734
|
-
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
|
|
735
181
|
} catch (err) {
|
|
736
|
-
log
|
|
182
|
+
params.log(`feishu: audio preflight transcription failed: ${String(err)}`);
|
|
183
|
+
return undefined;
|
|
737
184
|
}
|
|
738
|
-
|
|
739
|
-
return out;
|
|
740
185
|
}
|
|
741
186
|
|
|
742
187
|
// --- Broadcast support ---
|
|
@@ -744,9 +189,13 @@ async function resolveFeishuMediaList(params: {
|
|
|
744
189
|
// Returns null if no broadcast config exists or the peer is not in the broadcast list.
|
|
745
190
|
export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
|
|
746
191
|
const broadcast = (cfg as Record<string, unknown>).broadcast;
|
|
747
|
-
if (!broadcast || typeof broadcast !== "object")
|
|
192
|
+
if (!broadcast || typeof broadcast !== "object") {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
748
195
|
const agents = (broadcast as Record<string, unknown>)[peerId];
|
|
749
|
-
if (!Array.isArray(agents) || agents.length === 0)
|
|
196
|
+
if (!Array.isArray(agents) || agents.length === 0) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
750
199
|
return agents as string[];
|
|
751
200
|
}
|
|
752
201
|
|
|
@@ -789,6 +238,8 @@ export function parseFeishuMessageEvent(
|
|
|
789
238
|
const ctx: FeishuMessageContext = {
|
|
790
239
|
chatId: event.message.chat_id,
|
|
791
240
|
messageId: event.message.message_id,
|
|
241
|
+
replyTargetMessageId: event.message.reply_target_message_id?.trim() || undefined,
|
|
242
|
+
suppressReplyTarget: event.message.suppress_reply_target === true,
|
|
792
243
|
senderId: senderUserId || senderOpenId || "",
|
|
793
244
|
// Keep the historical field name, but fall back to user_id when open_id is unavailable
|
|
794
245
|
// (common in some mobile app deliveries).
|
|
@@ -820,7 +271,7 @@ export function buildFeishuAgentBody(params: {
|
|
|
820
271
|
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
|
|
821
272
|
>;
|
|
822
273
|
quotedContent?: string;
|
|
823
|
-
permissionErrorForAgent?:
|
|
274
|
+
permissionErrorForAgent?: FeishuPermissionError;
|
|
824
275
|
botOpenId?: string;
|
|
825
276
|
}): string {
|
|
826
277
|
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
|
|
@@ -859,6 +310,76 @@ export function buildFeishuAgentBody(params: {
|
|
|
859
310
|
return messageBody;
|
|
860
311
|
}
|
|
861
312
|
|
|
313
|
+
function isFetchedGroupContextSenderAllowed(params: {
|
|
314
|
+
isGroup: boolean;
|
|
315
|
+
allowFrom: Array<string | number>;
|
|
316
|
+
senderId?: string;
|
|
317
|
+
senderType?: string;
|
|
318
|
+
}): boolean {
|
|
319
|
+
if (!params.isGroup || params.allowFrom.length === 0) {
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
if (params.senderType === "app") {
|
|
323
|
+
return true;
|
|
324
|
+
}
|
|
325
|
+
const senderId = params.senderId?.trim();
|
|
326
|
+
const senderAllowed =
|
|
327
|
+
!!senderId &&
|
|
328
|
+
isFeishuGroupAllowed({
|
|
329
|
+
groupPolicy: "allowlist",
|
|
330
|
+
allowFrom: params.allowFrom,
|
|
331
|
+
senderId,
|
|
332
|
+
senderName: undefined,
|
|
333
|
+
});
|
|
334
|
+
return senderAllowed;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function shouldIncludeFetchedGroupContextMessage(params: {
|
|
338
|
+
isGroup: boolean;
|
|
339
|
+
allowFrom: Array<string | number>;
|
|
340
|
+
mode: "all" | "allowlist" | "allowlist_quote";
|
|
341
|
+
kind: "quote" | "thread" | "history";
|
|
342
|
+
senderId?: string;
|
|
343
|
+
senderType?: string;
|
|
344
|
+
}): boolean {
|
|
345
|
+
const senderAllowed = isFetchedGroupContextSenderAllowed({
|
|
346
|
+
isGroup: params.isGroup,
|
|
347
|
+
allowFrom: params.allowFrom,
|
|
348
|
+
senderId: params.senderId,
|
|
349
|
+
senderType: params.senderType,
|
|
350
|
+
});
|
|
351
|
+
return evaluateSupplementalContextVisibility({
|
|
352
|
+
mode: params.mode,
|
|
353
|
+
kind: params.kind,
|
|
354
|
+
senderAllowed,
|
|
355
|
+
}).include;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function filterFetchedGroupContextMessages<
|
|
359
|
+
T extends Pick<FeishuMessageInfo, "senderId" | "senderType">,
|
|
360
|
+
>(
|
|
361
|
+
messages: readonly T[],
|
|
362
|
+
params: {
|
|
363
|
+
isGroup: boolean;
|
|
364
|
+
allowFrom: Array<string | number>;
|
|
365
|
+
mode: "all" | "allowlist" | "allowlist_quote";
|
|
366
|
+
kind: "quote" | "thread" | "history";
|
|
367
|
+
},
|
|
368
|
+
): T[] {
|
|
369
|
+
return filterSupplementalContextItems({
|
|
370
|
+
items: messages,
|
|
371
|
+
mode: params.mode,
|
|
372
|
+
kind: params.kind,
|
|
373
|
+
isSenderAllowed: (message) =>
|
|
374
|
+
isFetchedGroupContextSenderAllowed({
|
|
375
|
+
isGroup: params.isGroup,
|
|
376
|
+
allowFrom: params.allowFrom,
|
|
377
|
+
senderId: message.senderId,
|
|
378
|
+
senderType: message.senderType,
|
|
379
|
+
}),
|
|
380
|
+
}).items;
|
|
381
|
+
}
|
|
382
|
+
|
|
862
383
|
export async function handleFeishuMessage(params: {
|
|
863
384
|
cfg: ClawdbotConfig;
|
|
864
385
|
event: FeishuMessageEvent;
|
|
@@ -867,34 +388,43 @@ export async function handleFeishuMessage(params: {
|
|
|
867
388
|
runtime?: RuntimeEnv;
|
|
868
389
|
chatHistories?: Map<string, HistoryEntry[]>;
|
|
869
390
|
accountId?: string;
|
|
391
|
+
processingClaimHeld?: boolean;
|
|
870
392
|
}): Promise<void> {
|
|
871
|
-
const {
|
|
393
|
+
const {
|
|
394
|
+
cfg,
|
|
395
|
+
event,
|
|
396
|
+
botOpenId,
|
|
397
|
+
botName,
|
|
398
|
+
runtime,
|
|
399
|
+
chatHistories,
|
|
400
|
+
accountId,
|
|
401
|
+
processingClaimHeld = false,
|
|
402
|
+
} = params;
|
|
872
403
|
|
|
873
404
|
// Resolve account with merged config
|
|
874
|
-
const account =
|
|
405
|
+
const account = resolveFeishuRuntimeAccount({ cfg, accountId });
|
|
875
406
|
const feishuCfg = account.config;
|
|
876
407
|
|
|
877
408
|
const log = runtime?.log ?? console.log;
|
|
878
409
|
const error = runtime?.error ?? console.error;
|
|
879
410
|
|
|
880
|
-
// Dedup: synchronous memory guard prevents concurrent duplicate dispatch
|
|
881
|
-
// before the async persistent check completes.
|
|
882
411
|
const messageId = event.message.message_id;
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
412
|
+
if (
|
|
413
|
+
!(await finalizeFeishuMessageProcessing({
|
|
414
|
+
messageId,
|
|
415
|
+
namespace: account.accountId,
|
|
416
|
+
log,
|
|
417
|
+
claimHeld: processingClaimHeld,
|
|
418
|
+
}))
|
|
419
|
+
) {
|
|
890
420
|
log(`feishu: skipping duplicate message ${messageId}`);
|
|
891
421
|
return;
|
|
892
422
|
}
|
|
893
423
|
|
|
894
424
|
let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
|
|
895
|
-
const isGroup = ctx.chatType
|
|
425
|
+
const isGroup = isFeishuGroupChatType(ctx.chatType);
|
|
896
426
|
const isDirect = !isGroup;
|
|
897
|
-
const senderUserId = event.sender.sender_id.user_id
|
|
427
|
+
const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id);
|
|
898
428
|
|
|
899
429
|
// Handle merge_forward messages: fetch full message via API then expand sub-messages
|
|
900
430
|
if (event.message.message_type === "merge_forward") {
|
|
@@ -930,14 +460,16 @@ export async function handleFeishuMessage(params: {
|
|
|
930
460
|
|
|
931
461
|
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
932
462
|
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
|
|
933
|
-
let permissionErrorForAgent:
|
|
463
|
+
let permissionErrorForAgent: FeishuPermissionError | undefined;
|
|
934
464
|
if (feishuCfg?.resolveSenderNames ?? true) {
|
|
935
465
|
const senderResult = await resolveFeishuSenderName({
|
|
936
466
|
account,
|
|
937
467
|
senderId: ctx.senderOpenId,
|
|
938
468
|
log,
|
|
939
469
|
});
|
|
940
|
-
if (senderResult.name)
|
|
470
|
+
if (senderResult.name) {
|
|
471
|
+
ctx = { ...ctx, senderName: senderResult.name };
|
|
472
|
+
}
|
|
941
473
|
|
|
942
474
|
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
943
475
|
if (senderResult.permissionError) {
|
|
@@ -969,6 +501,11 @@ export async function handleFeishuMessage(params: {
|
|
|
969
501
|
const groupConfig = isGroup
|
|
970
502
|
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
|
971
503
|
: undefined;
|
|
504
|
+
const effectiveGroupSenderAllowFrom = isGroup
|
|
505
|
+
? (groupConfig?.allowFrom?.length ?? 0) > 0
|
|
506
|
+
? (groupConfig?.allowFrom ?? [])
|
|
507
|
+
: (feishuCfg?.groupSenderAllowFrom ?? [])
|
|
508
|
+
: [];
|
|
972
509
|
const groupSession = isGroup
|
|
973
510
|
? resolveFeishuGroupSession({
|
|
974
511
|
chatId: ctx.chatId,
|
|
@@ -976,6 +513,7 @@ export async function handleFeishuMessage(params: {
|
|
|
976
513
|
messageId: ctx.messageId,
|
|
977
514
|
rootId: ctx.rootId,
|
|
978
515
|
threadId: ctx.threadId,
|
|
516
|
+
chatType: ctx.chatType,
|
|
979
517
|
groupConfig,
|
|
980
518
|
feishuCfg,
|
|
981
519
|
})
|
|
@@ -989,6 +527,14 @@ export async function handleFeishuMessage(params: {
|
|
|
989
527
|
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
|
|
990
528
|
: null;
|
|
991
529
|
|
|
530
|
+
// Parse message create_time early so every downstream consumer (pending
|
|
531
|
+
// history, inbound payload, etc.) uses the original authoring timestamp
|
|
532
|
+
// instead of the delivery/processing time. Feishu uses a millisecond
|
|
533
|
+
// epoch string; fall back to Date.now() only when the field is absent.
|
|
534
|
+
const messageCreateTimeMs = event.message.create_time
|
|
535
|
+
? Number.parseInt(event.message.create_time, 10)
|
|
536
|
+
: Date.now();
|
|
537
|
+
|
|
992
538
|
let requireMention = false; // DMs never require mention; groups may override below
|
|
993
539
|
if (isGroup) {
|
|
994
540
|
if (groupConfig?.enabled === false) {
|
|
@@ -1010,14 +556,26 @@ export async function handleFeishuMessage(params: {
|
|
|
1010
556
|
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
1011
557
|
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
|
|
1012
558
|
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
559
|
+
// A group explicitly configured under `channels.feishu.groups.<chat_id>` is
|
|
560
|
+
// treated as admitted in allowlist mode even when `groupAllowFrom` is empty.
|
|
561
|
+
// Wildcard defaults still configure matching groups, but they are not an
|
|
562
|
+
// admission signal by themselves.
|
|
563
|
+
const groupExplicitlyConfigured = hasExplicitFeishuGroupConfig({
|
|
564
|
+
cfg: feishuCfg,
|
|
565
|
+
groupId: ctx.chatId,
|
|
1019
566
|
});
|
|
1020
567
|
|
|
568
|
+
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
|
569
|
+
const groupAllowed =
|
|
570
|
+
groupPolicy !== "disabled" &&
|
|
571
|
+
(groupExplicitlyConfigured ||
|
|
572
|
+
isFeishuGroupAllowed({
|
|
573
|
+
groupPolicy,
|
|
574
|
+
allowFrom: groupAllowFrom,
|
|
575
|
+
senderId: ctx.chatId, // Check group ID, not sender ID
|
|
576
|
+
senderName: undefined,
|
|
577
|
+
}));
|
|
578
|
+
|
|
1021
579
|
if (!groupAllowed) {
|
|
1022
580
|
log(
|
|
1023
581
|
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
|
@@ -1026,14 +584,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1026
584
|
}
|
|
1027
585
|
|
|
1028
586
|
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
|
1029
|
-
|
|
1030
|
-
const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
|
|
1031
|
-
const effectiveSenderAllowFrom =
|
|
1032
|
-
perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
|
|
1033
|
-
if (effectiveSenderAllowFrom.length > 0) {
|
|
587
|
+
if (effectiveGroupSenderAllowFrom.length > 0) {
|
|
1034
588
|
const senderAllowed = isFeishuGroupAllowed({
|
|
1035
589
|
groupPolicy: "allowlist",
|
|
1036
|
-
allowFrom:
|
|
590
|
+
allowFrom: effectiveGroupSenderAllowFrom,
|
|
1037
591
|
senderId: ctx.senderOpenId,
|
|
1038
592
|
senderIds: [senderUserId],
|
|
1039
593
|
senderName: ctx.senderName,
|
|
@@ -1046,8 +600,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1046
600
|
|
|
1047
601
|
({ requireMention } = resolveFeishuReplyPolicy({
|
|
1048
602
|
isDirectMessage: false,
|
|
1049
|
-
|
|
1050
|
-
|
|
603
|
+
cfg,
|
|
604
|
+
accountId: account.accountId,
|
|
605
|
+
groupId: ctx.chatId,
|
|
606
|
+
groupPolicy,
|
|
1051
607
|
}));
|
|
1052
608
|
|
|
1053
609
|
if (requireMention && !ctx.mentionedBot) {
|
|
@@ -1064,19 +620,18 @@ export async function handleFeishuMessage(params: {
|
|
|
1064
620
|
entry: {
|
|
1065
621
|
sender: ctx.senderOpenId,
|
|
1066
622
|
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
|
1067
|
-
timestamp:
|
|
623
|
+
timestamp: messageCreateTimeMs,
|
|
1068
624
|
messageId: ctx.messageId,
|
|
1069
625
|
},
|
|
1070
626
|
});
|
|
1071
627
|
}
|
|
1072
628
|
return;
|
|
1073
629
|
}
|
|
1074
|
-
} else {
|
|
1075
630
|
}
|
|
1076
631
|
|
|
1077
632
|
try {
|
|
1078
633
|
const core = getFeishuRuntime();
|
|
1079
|
-
const pairing =
|
|
634
|
+
const pairing = createChannelPairingController({
|
|
1080
635
|
core,
|
|
1081
636
|
channel: "feishu",
|
|
1082
637
|
accountId: account.accountId,
|
|
@@ -1087,9 +642,7 @@ export async function handleFeishuMessage(params: {
|
|
|
1087
642
|
cfg,
|
|
1088
643
|
);
|
|
1089
644
|
const storeAllowFrom =
|
|
1090
|
-
!isGroup &&
|
|
1091
|
-
dmPolicy !== "allowlist" &&
|
|
1092
|
-
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
645
|
+
!isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
|
|
1093
646
|
? await pairing.readAllowFromStore().catch(() => [])
|
|
1094
647
|
: [];
|
|
1095
648
|
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
@@ -1100,14 +653,26 @@ export async function handleFeishuMessage(params: {
|
|
|
1100
653
|
senderName: ctx.senderName,
|
|
1101
654
|
}).allowed;
|
|
1102
655
|
|
|
1103
|
-
|
|
656
|
+
const dmAccessAllowed =
|
|
657
|
+
dmPolicy === "open"
|
|
658
|
+
? resolveOpenDmAllowlistAccess({
|
|
659
|
+
effectiveAllowFrom: effectiveDmAllowFrom,
|
|
660
|
+
isSenderAllowed: (allowFrom) =>
|
|
661
|
+
resolveFeishuAllowlistMatch({
|
|
662
|
+
allowFrom,
|
|
663
|
+
senderId: ctx.senderOpenId,
|
|
664
|
+
senderIds: [senderUserId],
|
|
665
|
+
senderName: ctx.senderName,
|
|
666
|
+
}).allowed,
|
|
667
|
+
}).decision === "allow"
|
|
668
|
+
: dmAllowed;
|
|
669
|
+
|
|
670
|
+
if (isDirect && !dmAccessAllowed) {
|
|
1104
671
|
if (dmPolicy === "pairing") {
|
|
1105
|
-
await
|
|
1106
|
-
channel: "feishu",
|
|
672
|
+
await pairing.issueChallenge({
|
|
1107
673
|
senderId: ctx.senderOpenId,
|
|
1108
674
|
senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
1109
675
|
meta: { name: ctx.senderName },
|
|
1110
|
-
upsertPairingRequest: pairing.upsertPairingRequest,
|
|
1111
676
|
onCreated: () => {
|
|
1112
677
|
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
|
|
1113
678
|
},
|
|
@@ -1142,14 +707,6 @@ export async function handleFeishuMessage(params: {
|
|
|
1142
707
|
senderIds: [senderUserId],
|
|
1143
708
|
senderName: ctx.senderName,
|
|
1144
709
|
}).allowed;
|
|
1145
|
-
const commandAuthorized = shouldComputeCommandAuthorized
|
|
1146
|
-
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
1147
|
-
useAccessGroups,
|
|
1148
|
-
authorizers: [
|
|
1149
|
-
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
1150
|
-
],
|
|
1151
|
-
})
|
|
1152
|
-
: undefined;
|
|
1153
710
|
|
|
1154
711
|
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
|
1155
712
|
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
@@ -1158,6 +715,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1158
715
|
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
|
1159
716
|
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
|
1160
717
|
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
|
718
|
+
const feishuAcpConversationSupported =
|
|
719
|
+
!isGroup ||
|
|
720
|
+
groupSession?.groupSessionScope === "group_topic" ||
|
|
721
|
+
groupSession?.groupSessionScope === "group_topic_sender";
|
|
1161
722
|
|
|
1162
723
|
if (isGroup && groupSession) {
|
|
1163
724
|
log(
|
|
@@ -1206,10 +767,81 @@ export async function handleFeishuMessage(params: {
|
|
|
1206
767
|
}
|
|
1207
768
|
}
|
|
1208
769
|
|
|
770
|
+
const currentConversationId = peerId;
|
|
771
|
+
const parentConversationId = isGroup ? (parentPeer?.id ?? ctx.chatId) : undefined;
|
|
772
|
+
let configuredBinding = null;
|
|
773
|
+
if (feishuAcpConversationSupported) {
|
|
774
|
+
const configuredRoute = resolveConfiguredBindingRoute({
|
|
775
|
+
cfg: effectiveCfg,
|
|
776
|
+
route,
|
|
777
|
+
conversation: {
|
|
778
|
+
channel: "feishu",
|
|
779
|
+
accountId: account.accountId,
|
|
780
|
+
conversationId: currentConversationId,
|
|
781
|
+
parentConversationId,
|
|
782
|
+
},
|
|
783
|
+
});
|
|
784
|
+
configuredBinding = configuredRoute.bindingResolution;
|
|
785
|
+
route = configuredRoute.route;
|
|
786
|
+
|
|
787
|
+
// Bound Feishu conversations intentionally require an exact live conversation-id match.
|
|
788
|
+
// Sender-scoped topic sessions therefore bind on `chat:topic:root:sender:user`, while
|
|
789
|
+
// configured ACP bindings may still inherit the shared `chat:topic:root` topic session.
|
|
790
|
+
const runtimeRoute = resolveRuntimeConversationBindingRoute({
|
|
791
|
+
route,
|
|
792
|
+
conversation: {
|
|
793
|
+
channel: "feishu",
|
|
794
|
+
accountId: account.accountId,
|
|
795
|
+
conversationId: currentConversationId,
|
|
796
|
+
...(parentConversationId ? { parentConversationId } : {}),
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
route = runtimeRoute.route;
|
|
800
|
+
if (runtimeRoute.bindingRecord) {
|
|
801
|
+
configuredBinding = null;
|
|
802
|
+
log(
|
|
803
|
+
runtimeRoute.boundSessionKey
|
|
804
|
+
? `feishu[${account.accountId}]: routed via bound conversation ${currentConversationId} -> ${runtimeRoute.boundSessionKey}`
|
|
805
|
+
: `feishu[${account.accountId}]: plugin-bound conversation ${currentConversationId}`,
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (configuredBinding) {
|
|
811
|
+
const ensured = await ensureConfiguredBindingRouteReady({
|
|
812
|
+
cfg: effectiveCfg,
|
|
813
|
+
bindingResolution: configuredBinding,
|
|
814
|
+
});
|
|
815
|
+
if (!ensured.ok) {
|
|
816
|
+
const replyTargetMessageId =
|
|
817
|
+
isGroup &&
|
|
818
|
+
(groupSession?.groupSessionScope === "group_topic" ||
|
|
819
|
+
groupSession?.groupSessionScope === "group_topic_sender")
|
|
820
|
+
? (ctx.rootId ?? ctx.messageId)
|
|
821
|
+
: ctx.messageId;
|
|
822
|
+
await sendMessageFeishu({
|
|
823
|
+
cfg: effectiveCfg,
|
|
824
|
+
to: `chat:${ctx.chatId}`,
|
|
825
|
+
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
|
|
826
|
+
replyToMessageId: replyTargetMessageId,
|
|
827
|
+
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
|
|
828
|
+
accountId: account.accountId,
|
|
829
|
+
}).catch((err) => {
|
|
830
|
+
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
|
|
831
|
+
});
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
1209
836
|
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
1210
837
|
const inboundLabel = isGroup
|
|
1211
838
|
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
1212
839
|
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
840
|
+
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
|
841
|
+
cfg: effectiveCfg,
|
|
842
|
+
channel: "feishu",
|
|
843
|
+
accountId: account.accountId,
|
|
844
|
+
});
|
|
1213
845
|
|
|
1214
846
|
// Do not enqueue inbound user previews as system events.
|
|
1215
847
|
// System events are prepended to future prompts and can be misread as
|
|
@@ -1227,31 +859,101 @@ export async function handleFeishuMessage(params: {
|
|
|
1227
859
|
log,
|
|
1228
860
|
accountId: account.accountId,
|
|
1229
861
|
});
|
|
862
|
+
// Skip messages with no text content and no media attachments. Feishu can
|
|
863
|
+
// deliver empty-text events (e.g. `{"text":""}`) when a user sends a blank
|
|
864
|
+
// message or when media parsing produces an empty string. Writing a blank
|
|
865
|
+
// user turn to the session causes downstream LLM providers (e.g. MiniMax)
|
|
866
|
+
// to reject the request with "messages must not be empty" errors. Logging
|
|
867
|
+
// the skip avoids silent loss without polluting the agent session.
|
|
868
|
+
if (!ctx.content.trim() && mediaList.length === 0) {
|
|
869
|
+
log(
|
|
870
|
+
`feishu[${account.accountId}]: skipping empty message (no text, no media) from ${ctx.senderOpenId}`,
|
|
871
|
+
);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
1230
875
|
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
876
|
+
const audioTranscript = await resolveFeishuAudioPreflightTranscript({
|
|
877
|
+
cfg: effectiveCfg,
|
|
878
|
+
mediaList,
|
|
879
|
+
content: ctx.content,
|
|
880
|
+
chatType: isGroup ? "group" : "direct",
|
|
881
|
+
log,
|
|
882
|
+
});
|
|
883
|
+
const preflightAudioIndex =
|
|
884
|
+
audioTranscript === undefined
|
|
885
|
+
? -1
|
|
886
|
+
: mediaList.findIndex((media) => media.contentType?.startsWith("audio/"));
|
|
887
|
+
const agentFacingContent = audioTranscript ?? ctx.content;
|
|
888
|
+
const agentFacingCtx =
|
|
889
|
+
audioTranscript === undefined
|
|
890
|
+
? ctx
|
|
891
|
+
: {
|
|
892
|
+
...ctx,
|
|
893
|
+
content: audioTranscript,
|
|
894
|
+
};
|
|
895
|
+
const effectiveCommandProbeBody =
|
|
896
|
+
audioTranscript === undefined
|
|
897
|
+
? commandProbeBody
|
|
898
|
+
: isGroup
|
|
899
|
+
? normalizeFeishuCommandProbeBody(audioTranscript)
|
|
900
|
+
: audioTranscript;
|
|
901
|
+
const shouldComputeEffectiveCommandAuthorized =
|
|
902
|
+
audioTranscript === undefined
|
|
903
|
+
? shouldComputeCommandAuthorized
|
|
904
|
+
: core.channel.commands.shouldComputeCommandAuthorized(effectiveCommandProbeBody, cfg);
|
|
905
|
+
const commandAuthorized = shouldComputeEffectiveCommandAuthorized
|
|
906
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
907
|
+
useAccessGroups,
|
|
908
|
+
authorizers: [
|
|
909
|
+
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
910
|
+
],
|
|
911
|
+
})
|
|
912
|
+
: undefined;
|
|
1231
913
|
|
|
1232
914
|
// Fetch quoted/replied message content if parentId exists
|
|
915
|
+
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
|
|
1233
916
|
let quotedContent: string | undefined;
|
|
1234
917
|
if (ctx.parentId) {
|
|
1235
918
|
try {
|
|
1236
|
-
|
|
919
|
+
quotedMessageInfo = await getMessageFeishu({
|
|
1237
920
|
cfg,
|
|
1238
921
|
messageId: ctx.parentId,
|
|
1239
922
|
accountId: account.accountId,
|
|
1240
923
|
});
|
|
1241
|
-
if (
|
|
1242
|
-
|
|
924
|
+
if (
|
|
925
|
+
quotedMessageInfo &&
|
|
926
|
+
shouldIncludeFetchedGroupContextMessage({
|
|
927
|
+
isGroup,
|
|
928
|
+
allowFrom: effectiveGroupSenderAllowFrom,
|
|
929
|
+
mode: contextVisibilityMode,
|
|
930
|
+
kind: "quote",
|
|
931
|
+
senderId: quotedMessageInfo.senderId,
|
|
932
|
+
senderType: quotedMessageInfo.senderType,
|
|
933
|
+
})
|
|
934
|
+
) {
|
|
935
|
+
quotedContent = quotedMessageInfo.content;
|
|
1243
936
|
log(
|
|
1244
937
|
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
|
|
1245
938
|
);
|
|
939
|
+
} else if (quotedMessageInfo) {
|
|
940
|
+
log(
|
|
941
|
+
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
|
|
942
|
+
);
|
|
1246
943
|
}
|
|
1247
944
|
} catch (err) {
|
|
1248
945
|
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
|
|
1249
946
|
}
|
|
1250
947
|
}
|
|
1251
948
|
|
|
949
|
+
const isTopicSessionForThread =
|
|
950
|
+
isGroup &&
|
|
951
|
+
(groupSession?.groupSessionScope === "group_topic" ||
|
|
952
|
+
groupSession?.groupSessionScope === "group_topic_sender");
|
|
953
|
+
|
|
1252
954
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1253
955
|
const messageBody = buildFeishuAgentBody({
|
|
1254
|
-
ctx,
|
|
956
|
+
ctx: agentFacingCtx,
|
|
1255
957
|
quotedContent,
|
|
1256
958
|
permissionErrorForAgent,
|
|
1257
959
|
botOpenId,
|
|
@@ -1300,45 +1002,226 @@ export async function handleFeishuMessage(params: {
|
|
|
1300
1002
|
}))
|
|
1301
1003
|
: undefined;
|
|
1302
1004
|
|
|
1005
|
+
const threadContextBySessionKey = new Map<
|
|
1006
|
+
string,
|
|
1007
|
+
{
|
|
1008
|
+
threadStarterBody?: string;
|
|
1009
|
+
threadHistoryBody?: string;
|
|
1010
|
+
threadLabel?: string;
|
|
1011
|
+
}
|
|
1012
|
+
>();
|
|
1013
|
+
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
|
|
1014
|
+
let rootMessageThreadId: string | undefined;
|
|
1015
|
+
let rootMessageFetched = false;
|
|
1016
|
+
const getRootMessageInfo = async () => {
|
|
1017
|
+
if (!ctx.rootId) {
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1020
|
+
if (!rootMessageFetched) {
|
|
1021
|
+
rootMessageFetched = true;
|
|
1022
|
+
if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
|
|
1023
|
+
rootMessageInfo = quotedMessageInfo;
|
|
1024
|
+
} else {
|
|
1025
|
+
try {
|
|
1026
|
+
rootMessageInfo = await getMessageFeishu({
|
|
1027
|
+
cfg,
|
|
1028
|
+
messageId: ctx.rootId,
|
|
1029
|
+
accountId: account.accountId,
|
|
1030
|
+
});
|
|
1031
|
+
} catch (err) {
|
|
1032
|
+
log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
|
|
1033
|
+
rootMessageInfo = null;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
rootMessageThreadId = rootMessageInfo?.threadId;
|
|
1037
|
+
if (
|
|
1038
|
+
rootMessageInfo &&
|
|
1039
|
+
!shouldIncludeFetchedGroupContextMessage({
|
|
1040
|
+
isGroup,
|
|
1041
|
+
allowFrom: effectiveGroupSenderAllowFrom,
|
|
1042
|
+
mode: contextVisibilityMode,
|
|
1043
|
+
kind: "thread",
|
|
1044
|
+
senderId: rootMessageInfo.senderId,
|
|
1045
|
+
senderType: rootMessageInfo.senderType,
|
|
1046
|
+
})
|
|
1047
|
+
) {
|
|
1048
|
+
log(
|
|
1049
|
+
`feishu[${account.accountId}]: skipped thread starter from sender ${rootMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
|
|
1050
|
+
);
|
|
1051
|
+
rootMessageInfo = null;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return rootMessageInfo ?? null;
|
|
1055
|
+
};
|
|
1056
|
+
let groupNamePromise: Promise<string | undefined> | undefined;
|
|
1057
|
+
const resolveGroupNameForLabel = (): Promise<string | undefined> => {
|
|
1058
|
+
if (!isGroup) {
|
|
1059
|
+
return Promise.resolve(undefined);
|
|
1060
|
+
}
|
|
1061
|
+
groupNamePromise ??= resolveGroupName({ account, chatId: ctx.chatId, log });
|
|
1062
|
+
return groupNamePromise;
|
|
1063
|
+
};
|
|
1064
|
+
|
|
1065
|
+
const resolveThreadContextForAgent = async (
|
|
1066
|
+
agentId: string,
|
|
1067
|
+
agentSessionKey: string,
|
|
1068
|
+
groupName: string | undefined,
|
|
1069
|
+
) => {
|
|
1070
|
+
const cached = threadContextBySessionKey.get(agentSessionKey);
|
|
1071
|
+
if (cached) {
|
|
1072
|
+
return cached;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const threadContext: {
|
|
1076
|
+
threadStarterBody?: string;
|
|
1077
|
+
threadHistoryBody?: string;
|
|
1078
|
+
threadLabel?: string;
|
|
1079
|
+
} = {
|
|
1080
|
+
threadLabel:
|
|
1081
|
+
(ctx.rootId || ctx.threadId) && isTopicSessionForThread
|
|
1082
|
+
? `Feishu thread in ${groupName ?? ctx.chatId}`
|
|
1083
|
+
: undefined,
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
|
|
1087
|
+
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
|
1088
|
+
return threadContext;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
|
|
1092
|
+
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
1093
|
+
storePath,
|
|
1094
|
+
sessionKey: agentSessionKey,
|
|
1095
|
+
});
|
|
1096
|
+
if (previousThreadSessionTimestamp) {
|
|
1097
|
+
log(
|
|
1098
|
+
`feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
|
|
1099
|
+
);
|
|
1100
|
+
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
|
1101
|
+
return threadContext;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
const rootMsg = await getRootMessageInfo();
|
|
1105
|
+
let feishuThreadId = ctx.threadId ?? rootMessageThreadId ?? rootMsg?.threadId;
|
|
1106
|
+
if (feishuThreadId) {
|
|
1107
|
+
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
|
|
1108
|
+
}
|
|
1109
|
+
if (!feishuThreadId) {
|
|
1110
|
+
log(
|
|
1111
|
+
`feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
|
|
1112
|
+
);
|
|
1113
|
+
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
|
1114
|
+
return threadContext;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
try {
|
|
1118
|
+
const threadMessages = await listFeishuThreadMessages({
|
|
1119
|
+
cfg,
|
|
1120
|
+
threadId: feishuThreadId,
|
|
1121
|
+
currentMessageId: ctx.messageId,
|
|
1122
|
+
rootMessageId: ctx.rootId,
|
|
1123
|
+
limit: 20,
|
|
1124
|
+
accountId: account.accountId,
|
|
1125
|
+
});
|
|
1126
|
+
const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
|
|
1127
|
+
const senderIds = new Set(
|
|
1128
|
+
[ctx.senderOpenId, senderUserId]
|
|
1129
|
+
.map((id) => id?.trim())
|
|
1130
|
+
.filter((id): id is string => id !== undefined && id.length > 0),
|
|
1131
|
+
);
|
|
1132
|
+
const allowlistedMessages = filterFetchedGroupContextMessages(threadMessages, {
|
|
1133
|
+
isGroup,
|
|
1134
|
+
allowFrom: effectiveGroupSenderAllowFrom,
|
|
1135
|
+
mode: contextVisibilityMode,
|
|
1136
|
+
kind: "history",
|
|
1137
|
+
});
|
|
1138
|
+
const relevantMessages =
|
|
1139
|
+
(senderScoped
|
|
1140
|
+
? allowlistedMessages.filter(
|
|
1141
|
+
(msg) =>
|
|
1142
|
+
msg.senderType === "app" ||
|
|
1143
|
+
(msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
|
|
1144
|
+
)
|
|
1145
|
+
: allowlistedMessages) ?? [];
|
|
1146
|
+
|
|
1147
|
+
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
|
|
1148
|
+
const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);
|
|
1149
|
+
const historyMessages = includeStarterInHistory
|
|
1150
|
+
? relevantMessages
|
|
1151
|
+
: relevantMessages.slice(1);
|
|
1152
|
+
const historyParts = historyMessages.map((msg) => {
|
|
1153
|
+
const role = msg.senderType === "app" ? "assistant" : "user";
|
|
1154
|
+
return core.channel.reply.formatAgentEnvelope({
|
|
1155
|
+
channel: "Feishu",
|
|
1156
|
+
from: `${msg.senderId ?? "Unknown"} (${role})`,
|
|
1157
|
+
timestamp: msg.createTime,
|
|
1158
|
+
body: msg.content,
|
|
1159
|
+
envelope: envelopeOptions,
|
|
1160
|
+
});
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
threadContext.threadStarterBody = threadStarterBody;
|
|
1164
|
+
threadContext.threadHistoryBody =
|
|
1165
|
+
historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
|
|
1166
|
+
log(
|
|
1167
|
+
`feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
|
|
1168
|
+
);
|
|
1169
|
+
} catch (err) {
|
|
1170
|
+
log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
|
1174
|
+
return threadContext;
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1303
1177
|
// --- Shared context builder for dispatch ---
|
|
1304
|
-
const buildCtxPayloadForAgent = (
|
|
1178
|
+
const buildCtxPayloadForAgent = async (
|
|
1179
|
+
agentId: string,
|
|
1305
1180
|
agentSessionKey: string,
|
|
1306
1181
|
agentAccountId: string,
|
|
1307
1182
|
wasMentioned: boolean,
|
|
1308
|
-
) =>
|
|
1309
|
-
|
|
1183
|
+
) => {
|
|
1184
|
+
const groupName = await resolveGroupNameForLabel();
|
|
1185
|
+
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
|
|
1186
|
+
return core.channel.reply.finalizeInboundContext({
|
|
1310
1187
|
Body: combinedBody,
|
|
1311
1188
|
BodyForAgent: messageBody,
|
|
1312
1189
|
InboundHistory: inboundHistory,
|
|
1313
1190
|
ReplyToId: ctx.parentId,
|
|
1314
1191
|
RootMessageId: ctx.rootId,
|
|
1315
|
-
RawBody:
|
|
1316
|
-
CommandBody:
|
|
1192
|
+
RawBody: agentFacingContent,
|
|
1193
|
+
CommandBody: agentFacingContent,
|
|
1194
|
+
Transcript: audioTranscript,
|
|
1317
1195
|
From: feishuFrom,
|
|
1318
1196
|
To: feishuTo,
|
|
1319
1197
|
SessionKey: agentSessionKey,
|
|
1320
1198
|
AccountId: agentAccountId,
|
|
1321
1199
|
ChatType: isGroup ? "group" : "direct",
|
|
1322
|
-
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
1200
|
+
GroupSubject: isGroup ? groupName || ctx.chatId : undefined,
|
|
1201
|
+
ConversationLabel: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined,
|
|
1323
1202
|
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
|
1324
1203
|
SenderId: ctx.senderOpenId,
|
|
1325
1204
|
Provider: "feishu" as const,
|
|
1326
1205
|
Surface: "feishu" as const,
|
|
1327
1206
|
MessageSid: ctx.messageId,
|
|
1328
1207
|
ReplyToBody: quotedContent ?? undefined,
|
|
1329
|
-
|
|
1208
|
+
ThreadStarterBody: threadContext.threadStarterBody,
|
|
1209
|
+
ThreadHistoryBody: threadContext.threadHistoryBody,
|
|
1210
|
+
ThreadLabel: threadContext.threadLabel,
|
|
1211
|
+
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
|
|
1212
|
+
// ID and would produce invalid reply targets downstream.
|
|
1213
|
+
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
|
|
1214
|
+
Timestamp: messageCreateTimeMs,
|
|
1330
1215
|
WasMentioned: wasMentioned,
|
|
1331
1216
|
CommandAuthorized: commandAuthorized,
|
|
1332
1217
|
OriginatingChannel: "feishu" as const,
|
|
1333
1218
|
OriginatingTo: feishuTo,
|
|
1334
|
-
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt
|
|
1219
|
+
GroupSystemPrompt: isGroup ? normalizeOptionalString(groupConfig?.systemPrompt) : undefined,
|
|
1335
1220
|
...mediaPayload,
|
|
1221
|
+
...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}),
|
|
1336
1222
|
});
|
|
1223
|
+
};
|
|
1337
1224
|
|
|
1338
|
-
// Parse message create_time (Feishu uses millisecond epoch string).
|
|
1339
|
-
const messageCreateTimeMs = event.message.create_time
|
|
1340
|
-
? parseInt(event.message.create_time, 10)
|
|
1341
|
-
: undefined;
|
|
1342
1225
|
// Determine reply target based on group session mode:
|
|
1343
1226
|
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
|
|
1344
1227
|
// root so the bot stays in the same thread.
|
|
@@ -1355,7 +1238,11 @@ export async function handleFeishuMessage(params: {
|
|
|
1355
1238
|
isGroup &&
|
|
1356
1239
|
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
|
1357
1240
|
const replyTargetMessageId =
|
|
1358
|
-
isTopicSession || configReplyInThread
|
|
1241
|
+
isTopicSession || configReplyInThread
|
|
1242
|
+
? (ctx.rootId ??
|
|
1243
|
+
ctx.replyTargetMessageId ??
|
|
1244
|
+
(ctx.suppressReplyTarget ? undefined : ctx.messageId))
|
|
1245
|
+
: (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
|
|
1359
1246
|
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
|
|
1360
1247
|
|
|
1361
1248
|
if (broadcastAgents) {
|
|
@@ -1372,9 +1259,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1372
1259
|
}
|
|
1373
1260
|
|
|
1374
1261
|
// --- Broadcast dispatch: send message to all configured agents ---
|
|
1375
|
-
const
|
|
1376
|
-
(
|
|
1377
|
-
|
|
1262
|
+
const rawStrategy = (
|
|
1263
|
+
(cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined
|
|
1264
|
+
)?.strategy;
|
|
1265
|
+
const strategy = rawStrategy === "sequential" ? "sequential" : "parallel";
|
|
1378
1266
|
const activeAgentId =
|
|
1379
1267
|
ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
|
|
1380
1268
|
const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
|
|
@@ -1393,7 +1281,22 @@ export async function handleFeishuMessage(params: {
|
|
|
1393
1281
|
}
|
|
1394
1282
|
|
|
1395
1283
|
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
|
1396
|
-
const
|
|
1284
|
+
const agentStorePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
1285
|
+
agentId,
|
|
1286
|
+
});
|
|
1287
|
+
const agentRecord = {
|
|
1288
|
+
onRecordError: (err: unknown) => {
|
|
1289
|
+
log(
|
|
1290
|
+
`feishu[${account.accountId}]: failed to record broadcast inbound session ${agentSessionKey}: ${String(err)}`,
|
|
1291
|
+
);
|
|
1292
|
+
},
|
|
1293
|
+
};
|
|
1294
|
+
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
|
|
1295
|
+
storePath: agentStorePath,
|
|
1296
|
+
sessionKey: agentSessionKey,
|
|
1297
|
+
});
|
|
1298
|
+
const agentCtx = await buildCtxPayloadForAgent(
|
|
1299
|
+
agentId,
|
|
1397
1300
|
agentSessionKey,
|
|
1398
1301
|
route.accountId,
|
|
1399
1302
|
ctx.mentionedBot && agentId === activeAgentId,
|
|
@@ -1401,11 +1304,13 @@ export async function handleFeishuMessage(params: {
|
|
|
1401
1304
|
|
|
1402
1305
|
if (agentId === activeAgentId) {
|
|
1403
1306
|
// Active agent: real Feishu dispatcher (responds on Feishu)
|
|
1307
|
+
const identity = resolveAgentOutboundIdentity(cfg, agentId);
|
|
1404
1308
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1405
1309
|
cfg,
|
|
1406
1310
|
agentId,
|
|
1407
1311
|
runtime: runtime as RuntimeEnv,
|
|
1408
1312
|
chatId: ctx.chatId,
|
|
1313
|
+
allowReasoningPreview,
|
|
1409
1314
|
replyToMessageId: replyTargetMessageId,
|
|
1410
1315
|
skipReplyToInMessages: !isGroup,
|
|
1411
1316
|
replyInThread,
|
|
@@ -1413,22 +1318,53 @@ export async function handleFeishuMessage(params: {
|
|
|
1413
1318
|
threadReply,
|
|
1414
1319
|
mentionTargets: ctx.mentionTargets,
|
|
1415
1320
|
accountId: account.accountId,
|
|
1321
|
+
identity,
|
|
1416
1322
|
messageCreateTimeMs,
|
|
1417
1323
|
});
|
|
1418
1324
|
|
|
1419
1325
|
log(
|
|
1420
1326
|
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1421
1327
|
);
|
|
1422
|
-
await core.channel.
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1328
|
+
await core.channel.turn.run({
|
|
1329
|
+
channel: "feishu",
|
|
1330
|
+
accountId: route.accountId,
|
|
1331
|
+
raw: ctx,
|
|
1332
|
+
adapter: {
|
|
1333
|
+
ingest: () => ({
|
|
1334
|
+
id: ctx.messageId,
|
|
1335
|
+
timestamp: messageCreateTimeMs,
|
|
1336
|
+
rawText: ctx.content,
|
|
1337
|
+
textForAgent: agentCtx.BodyForAgent,
|
|
1338
|
+
textForCommands: agentCtx.CommandBody,
|
|
1339
|
+
raw: ctx,
|
|
1431
1340
|
}),
|
|
1341
|
+
resolveTurn: () => ({
|
|
1342
|
+
channel: "feishu",
|
|
1343
|
+
accountId: route.accountId,
|
|
1344
|
+
routeSessionKey: agentSessionKey,
|
|
1345
|
+
storePath: agentStorePath,
|
|
1346
|
+
ctxPayload: agentCtx,
|
|
1347
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
1348
|
+
record: agentRecord,
|
|
1349
|
+
onPreDispatchFailure: () =>
|
|
1350
|
+
core.channel.reply.settleReplyDispatcher({
|
|
1351
|
+
dispatcher,
|
|
1352
|
+
onSettled: () => markDispatchIdle(),
|
|
1353
|
+
}),
|
|
1354
|
+
runDispatch: () =>
|
|
1355
|
+
core.channel.reply.withReplyDispatcher({
|
|
1356
|
+
dispatcher,
|
|
1357
|
+
onSettled: () => markDispatchIdle(),
|
|
1358
|
+
run: () =>
|
|
1359
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1360
|
+
ctx: agentCtx,
|
|
1361
|
+
cfg,
|
|
1362
|
+
dispatcher,
|
|
1363
|
+
replyOptions,
|
|
1364
|
+
}),
|
|
1365
|
+
}),
|
|
1366
|
+
}),
|
|
1367
|
+
},
|
|
1432
1368
|
});
|
|
1433
1369
|
} else {
|
|
1434
1370
|
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
|
@@ -1441,20 +1377,46 @@ export async function handleFeishuMessage(params: {
|
|
|
1441
1377
|
sendFinalReply: () => false,
|
|
1442
1378
|
waitForIdle: async () => {},
|
|
1443
1379
|
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
1380
|
+
getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
1444
1381
|
markComplete: () => {},
|
|
1445
1382
|
};
|
|
1446
1383
|
|
|
1447
1384
|
log(
|
|
1448
1385
|
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1449
1386
|
);
|
|
1450
|
-
await core.channel.
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1387
|
+
await core.channel.turn.run({
|
|
1388
|
+
channel: "feishu",
|
|
1389
|
+
accountId: route.accountId,
|
|
1390
|
+
raw: ctx,
|
|
1391
|
+
adapter: {
|
|
1392
|
+
ingest: () => ({
|
|
1393
|
+
id: ctx.messageId,
|
|
1394
|
+
timestamp: messageCreateTimeMs,
|
|
1395
|
+
rawText: ctx.content,
|
|
1396
|
+
textForAgent: agentCtx.BodyForAgent,
|
|
1397
|
+
textForCommands: agentCtx.CommandBody,
|
|
1398
|
+
raw: ctx,
|
|
1457
1399
|
}),
|
|
1400
|
+
resolveTurn: () => ({
|
|
1401
|
+
channel: "feishu",
|
|
1402
|
+
accountId: route.accountId,
|
|
1403
|
+
routeSessionKey: agentSessionKey,
|
|
1404
|
+
storePath: agentStorePath,
|
|
1405
|
+
ctxPayload: agentCtx,
|
|
1406
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
1407
|
+
record: agentRecord,
|
|
1408
|
+
runDispatch: () =>
|
|
1409
|
+
core.channel.reply.withReplyDispatcher({
|
|
1410
|
+
dispatcher: noopDispatcher,
|
|
1411
|
+
run: () =>
|
|
1412
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1413
|
+
ctx: agentCtx,
|
|
1414
|
+
cfg,
|
|
1415
|
+
dispatcher: noopDispatcher,
|
|
1416
|
+
}),
|
|
1417
|
+
}),
|
|
1418
|
+
}),
|
|
1419
|
+
},
|
|
1458
1420
|
});
|
|
1459
1421
|
}
|
|
1460
1422
|
};
|
|
@@ -1493,17 +1455,27 @@ export async function handleFeishuMessage(params: {
|
|
|
1493
1455
|
);
|
|
1494
1456
|
} else {
|
|
1495
1457
|
// --- Single-agent dispatch (existing behavior) ---
|
|
1496
|
-
const ctxPayload = buildCtxPayloadForAgent(
|
|
1458
|
+
const ctxPayload = await buildCtxPayloadForAgent(
|
|
1459
|
+
route.agentId,
|
|
1497
1460
|
route.sessionKey,
|
|
1498
1461
|
route.accountId,
|
|
1499
1462
|
ctx.mentionedBot,
|
|
1500
1463
|
);
|
|
1501
1464
|
|
|
1465
|
+
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
|
|
1466
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
1467
|
+
agentId: route.agentId,
|
|
1468
|
+
});
|
|
1469
|
+
const allowReasoningPreview = resolveFeishuReasoningPreviewEnabled({
|
|
1470
|
+
storePath,
|
|
1471
|
+
sessionKey: route.sessionKey,
|
|
1472
|
+
});
|
|
1502
1473
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1503
1474
|
cfg,
|
|
1504
1475
|
agentId: route.agentId,
|
|
1505
1476
|
runtime: runtime as RuntimeEnv,
|
|
1506
1477
|
chatId: ctx.chatId,
|
|
1478
|
+
allowReasoningPreview,
|
|
1507
1479
|
replyToMessageId: replyTargetMessageId,
|
|
1508
1480
|
skipReplyToInMessages: !isGroup,
|
|
1509
1481
|
replyInThread,
|
|
@@ -1511,31 +1483,71 @@ export async function handleFeishuMessage(params: {
|
|
|
1511
1483
|
threadReply,
|
|
1512
1484
|
mentionTargets: ctx.mentionTargets,
|
|
1513
1485
|
accountId: account.accountId,
|
|
1486
|
+
identity,
|
|
1514
1487
|
messageCreateTimeMs,
|
|
1515
1488
|
});
|
|
1516
1489
|
|
|
1517
1490
|
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
1518
|
-
const
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1491
|
+
const turnResult = await core.channel.turn.run({
|
|
1492
|
+
channel: "feishu",
|
|
1493
|
+
accountId: route.accountId,
|
|
1494
|
+
raw: ctx,
|
|
1495
|
+
adapter: {
|
|
1496
|
+
ingest: () => ({
|
|
1497
|
+
id: ctx.messageId,
|
|
1498
|
+
timestamp: messageCreateTimeMs,
|
|
1499
|
+
rawText: ctx.content,
|
|
1500
|
+
textForAgent: ctxPayload.BodyForAgent,
|
|
1501
|
+
textForCommands: ctxPayload.CommandBody,
|
|
1502
|
+
raw: ctx,
|
|
1529
1503
|
}),
|
|
1504
|
+
resolveTurn: () => ({
|
|
1505
|
+
channel: "feishu",
|
|
1506
|
+
accountId: route.accountId,
|
|
1507
|
+
routeSessionKey: route.sessionKey,
|
|
1508
|
+
storePath,
|
|
1509
|
+
ctxPayload,
|
|
1510
|
+
recordInboundSession: core.channel.session.recordInboundSession,
|
|
1511
|
+
record: {
|
|
1512
|
+
onRecordError: (err) => {
|
|
1513
|
+
log(
|
|
1514
|
+
`feishu[${account.accountId}]: failed to record inbound session ${route.sessionKey}: ${String(err)}`,
|
|
1515
|
+
);
|
|
1516
|
+
},
|
|
1517
|
+
},
|
|
1518
|
+
history: {
|
|
1519
|
+
isGroup,
|
|
1520
|
+
historyKey,
|
|
1521
|
+
historyMap: chatHistories,
|
|
1522
|
+
limit: historyLimit,
|
|
1523
|
+
},
|
|
1524
|
+
onPreDispatchFailure: () =>
|
|
1525
|
+
core.channel.reply.settleReplyDispatcher({
|
|
1526
|
+
dispatcher,
|
|
1527
|
+
onSettled: () => markDispatchIdle(),
|
|
1528
|
+
}),
|
|
1529
|
+
runDispatch: () =>
|
|
1530
|
+
core.channel.reply.withReplyDispatcher({
|
|
1531
|
+
dispatcher,
|
|
1532
|
+
onSettled: () => {
|
|
1533
|
+
markDispatchIdle();
|
|
1534
|
+
},
|
|
1535
|
+
run: () =>
|
|
1536
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1537
|
+
ctx: ctxPayload,
|
|
1538
|
+
cfg,
|
|
1539
|
+
dispatcher,
|
|
1540
|
+
replyOptions,
|
|
1541
|
+
}),
|
|
1542
|
+
}),
|
|
1543
|
+
}),
|
|
1544
|
+
},
|
|
1530
1545
|
});
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
clearHistoryEntriesIfEnabled({
|
|
1534
|
-
historyMap: chatHistories,
|
|
1535
|
-
historyKey,
|
|
1536
|
-
limit: historyLimit,
|
|
1537
|
-
});
|
|
1546
|
+
if (!turnResult.dispatched) {
|
|
1547
|
+
return;
|
|
1538
1548
|
}
|
|
1549
|
+
const { dispatchResult } = turnResult;
|
|
1550
|
+
const { queuedFinal, counts } = dispatchResult;
|
|
1539
1551
|
|
|
1540
1552
|
log(
|
|
1541
1553
|
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|