@openclaw/feishu 2026.3.13 → 2026.5.2-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 +1827 -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 +95 -7
- 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 +778 -775
- 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 +1253 -309
- package/src/chat-schema.ts +5 -4
- package/src/chat.test.ts +135 -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 +406 -0
- package/src/comment-target.ts +44 -0
- package/src/config-schema.test.ts +63 -1
- 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 +33 -95
- package/src/directory.static.ts +61 -0
- package/src/directory.test.ts +116 -20
- package/src/directory.ts +60 -92
- 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 +403 -26
- package/src/media.ts +509 -132
- 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 +218 -312
- 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 +108 -48
- package/src/monitor.startup.test.ts +11 -9
- 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 +220 -60
- package/src/monitor.ts +15 -10
- package/src/monitor.webhook-e2e.test.ts +65 -7
- package/src/monitor.webhook-security.test.ts +122 -0
- package/src/monitor.webhook.test-helpers.ts +44 -26
- package/src/outbound-runtime-api.ts +1 -0
- package/src/outbound.test.ts +616 -37
- 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 +14 -9
- package/src/probe.ts +26 -16
- package/src/processing-claims.ts +59 -0
- package/src/qr-terminal.ts +1 -0
- package/src/reactions.ts +4 -34
- 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 +660 -29
- package/src/reply-dispatcher.ts +407 -154
- 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 +105 -2
- package/src/send.test.ts +386 -4
- package/src/send.ts +414 -95
- 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 +453 -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
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
86
|
}
|
|
290
87
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
}
|
|
315
|
-
|
|
316
|
-
try {
|
|
317
|
-
const parsed = JSON.parse(content);
|
|
318
|
-
if (messageType === "text") {
|
|
319
|
-
return parsed.text || "";
|
|
320
|
-
}
|
|
321
|
-
if (messageType === "share_chat") {
|
|
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");
|
|
101
|
+
function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
|
|
102
|
+
groupNameCache.delete(key);
|
|
103
|
+
groupNameCache.set(key, value);
|
|
419
104
|
}
|
|
420
105
|
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
}
|
|
106
|
+
export function clearGroupNameCache(): void {
|
|
107
|
+
groupNameCache.clear();
|
|
452
108
|
}
|
|
453
109
|
|
|
454
|
-
function
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
if (
|
|
461
|
-
|
|
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;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
function normalizeMentions(
|
|
473
|
-
text: string,
|
|
474
|
-
mentions?: FeishuMessageEvent["message"]["mentions"],
|
|
475
|
-
botStripId?: string,
|
|
476
|
-
): string {
|
|
477
|
-
if (!mentions || mentions.length === 0) return text;
|
|
478
|
-
|
|
479
|
-
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
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;
|
|
@@ -881,7 +402,7 @@ export async function handleFeishuMessage(params: {
|
|
|
881
402
|
} = params;
|
|
882
403
|
|
|
883
404
|
// Resolve account with merged config
|
|
884
|
-
const account =
|
|
405
|
+
const account = resolveFeishuRuntimeAccount({ cfg, accountId });
|
|
885
406
|
const feishuCfg = account.config;
|
|
886
407
|
|
|
887
408
|
const log = runtime?.log ?? console.log;
|
|
@@ -901,9 +422,9 @@ export async function handleFeishuMessage(params: {
|
|
|
901
422
|
}
|
|
902
423
|
|
|
903
424
|
let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
|
|
904
|
-
const isGroup = ctx.chatType
|
|
425
|
+
const isGroup = isFeishuGroupChatType(ctx.chatType);
|
|
905
426
|
const isDirect = !isGroup;
|
|
906
|
-
const senderUserId = event.sender.sender_id.user_id
|
|
427
|
+
const senderUserId = normalizeOptionalString(event.sender.sender_id.user_id);
|
|
907
428
|
|
|
908
429
|
// Handle merge_forward messages: fetch full message via API then expand sub-messages
|
|
909
430
|
if (event.message.message_type === "merge_forward") {
|
|
@@ -939,14 +460,16 @@ export async function handleFeishuMessage(params: {
|
|
|
939
460
|
|
|
940
461
|
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
941
462
|
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
|
|
942
|
-
let permissionErrorForAgent:
|
|
463
|
+
let permissionErrorForAgent: FeishuPermissionError | undefined;
|
|
943
464
|
if (feishuCfg?.resolveSenderNames ?? true) {
|
|
944
465
|
const senderResult = await resolveFeishuSenderName({
|
|
945
466
|
account,
|
|
946
467
|
senderId: ctx.senderOpenId,
|
|
947
468
|
log,
|
|
948
469
|
});
|
|
949
|
-
if (senderResult.name)
|
|
470
|
+
if (senderResult.name) {
|
|
471
|
+
ctx = { ...ctx, senderName: senderResult.name };
|
|
472
|
+
}
|
|
950
473
|
|
|
951
474
|
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
952
475
|
if (senderResult.permissionError) {
|
|
@@ -978,6 +501,11 @@ export async function handleFeishuMessage(params: {
|
|
|
978
501
|
const groupConfig = isGroup
|
|
979
502
|
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
|
980
503
|
: undefined;
|
|
504
|
+
const effectiveGroupSenderAllowFrom = isGroup
|
|
505
|
+
? (groupConfig?.allowFrom?.length ?? 0) > 0
|
|
506
|
+
? (groupConfig?.allowFrom ?? [])
|
|
507
|
+
: (feishuCfg?.groupSenderAllowFrom ?? [])
|
|
508
|
+
: [];
|
|
981
509
|
const groupSession = isGroup
|
|
982
510
|
? resolveFeishuGroupSession({
|
|
983
511
|
chatId: ctx.chatId,
|
|
@@ -985,6 +513,7 @@ export async function handleFeishuMessage(params: {
|
|
|
985
513
|
messageId: ctx.messageId,
|
|
986
514
|
rootId: ctx.rootId,
|
|
987
515
|
threadId: ctx.threadId,
|
|
516
|
+
chatType: ctx.chatType,
|
|
988
517
|
groupConfig,
|
|
989
518
|
feishuCfg,
|
|
990
519
|
})
|
|
@@ -998,6 +527,14 @@ export async function handleFeishuMessage(params: {
|
|
|
998
527
|
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
|
|
999
528
|
: null;
|
|
1000
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
|
+
|
|
1001
538
|
let requireMention = false; // DMs never require mention; groups may override below
|
|
1002
539
|
if (isGroup) {
|
|
1003
540
|
if (groupConfig?.enabled === false) {
|
|
@@ -1019,14 +556,26 @@ export async function handleFeishuMessage(params: {
|
|
|
1019
556
|
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
1020
557
|
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
|
|
1021
558
|
|
|
1022
|
-
//
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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,
|
|
1028
566
|
});
|
|
1029
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
|
+
|
|
1030
579
|
if (!groupAllowed) {
|
|
1031
580
|
log(
|
|
1032
581
|
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
|
@@ -1035,14 +584,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1035
584
|
}
|
|
1036
585
|
|
|
1037
586
|
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
|
1038
|
-
|
|
1039
|
-
const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
|
|
1040
|
-
const effectiveSenderAllowFrom =
|
|
1041
|
-
perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
|
|
1042
|
-
if (effectiveSenderAllowFrom.length > 0) {
|
|
587
|
+
if (effectiveGroupSenderAllowFrom.length > 0) {
|
|
1043
588
|
const senderAllowed = isFeishuGroupAllowed({
|
|
1044
589
|
groupPolicy: "allowlist",
|
|
1045
|
-
allowFrom:
|
|
590
|
+
allowFrom: effectiveGroupSenderAllowFrom,
|
|
1046
591
|
senderId: ctx.senderOpenId,
|
|
1047
592
|
senderIds: [senderUserId],
|
|
1048
593
|
senderName: ctx.senderName,
|
|
@@ -1055,8 +600,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1055
600
|
|
|
1056
601
|
({ requireMention } = resolveFeishuReplyPolicy({
|
|
1057
602
|
isDirectMessage: false,
|
|
1058
|
-
|
|
1059
|
-
|
|
603
|
+
cfg,
|
|
604
|
+
accountId: account.accountId,
|
|
605
|
+
groupId: ctx.chatId,
|
|
606
|
+
groupPolicy,
|
|
1060
607
|
}));
|
|
1061
608
|
|
|
1062
609
|
if (requireMention && !ctx.mentionedBot) {
|
|
@@ -1073,19 +620,18 @@ export async function handleFeishuMessage(params: {
|
|
|
1073
620
|
entry: {
|
|
1074
621
|
sender: ctx.senderOpenId,
|
|
1075
622
|
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
|
1076
|
-
timestamp:
|
|
623
|
+
timestamp: messageCreateTimeMs,
|
|
1077
624
|
messageId: ctx.messageId,
|
|
1078
625
|
},
|
|
1079
626
|
});
|
|
1080
627
|
}
|
|
1081
628
|
return;
|
|
1082
629
|
}
|
|
1083
|
-
} else {
|
|
1084
630
|
}
|
|
1085
631
|
|
|
1086
632
|
try {
|
|
1087
633
|
const core = getFeishuRuntime();
|
|
1088
|
-
const pairing =
|
|
634
|
+
const pairing = createChannelPairingController({
|
|
1089
635
|
core,
|
|
1090
636
|
channel: "feishu",
|
|
1091
637
|
accountId: account.accountId,
|
|
@@ -1096,9 +642,7 @@ export async function handleFeishuMessage(params: {
|
|
|
1096
642
|
cfg,
|
|
1097
643
|
);
|
|
1098
644
|
const storeAllowFrom =
|
|
1099
|
-
!isGroup &&
|
|
1100
|
-
dmPolicy !== "allowlist" &&
|
|
1101
|
-
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
645
|
+
!isGroup && dmPolicy !== "allowlist" && dmPolicy !== "open"
|
|
1102
646
|
? await pairing.readAllowFromStore().catch(() => [])
|
|
1103
647
|
: [];
|
|
1104
648
|
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
@@ -1109,14 +653,26 @@ export async function handleFeishuMessage(params: {
|
|
|
1109
653
|
senderName: ctx.senderName,
|
|
1110
654
|
}).allowed;
|
|
1111
655
|
|
|
1112
|
-
|
|
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) {
|
|
1113
671
|
if (dmPolicy === "pairing") {
|
|
1114
|
-
await
|
|
1115
|
-
channel: "feishu",
|
|
672
|
+
await pairing.issueChallenge({
|
|
1116
673
|
senderId: ctx.senderOpenId,
|
|
1117
674
|
senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
1118
675
|
meta: { name: ctx.senderName },
|
|
1119
|
-
upsertPairingRequest: pairing.upsertPairingRequest,
|
|
1120
676
|
onCreated: () => {
|
|
1121
677
|
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
|
|
1122
678
|
},
|
|
@@ -1151,14 +707,6 @@ export async function handleFeishuMessage(params: {
|
|
|
1151
707
|
senderIds: [senderUserId],
|
|
1152
708
|
senderName: ctx.senderName,
|
|
1153
709
|
}).allowed;
|
|
1154
|
-
const commandAuthorized = shouldComputeCommandAuthorized
|
|
1155
|
-
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
1156
|
-
useAccessGroups,
|
|
1157
|
-
authorizers: [
|
|
1158
|
-
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
1159
|
-
],
|
|
1160
|
-
})
|
|
1161
|
-
: undefined;
|
|
1162
710
|
|
|
1163
711
|
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
|
1164
712
|
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
@@ -1167,6 +715,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1167
715
|
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
|
1168
716
|
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
|
1169
717
|
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
|
718
|
+
const feishuAcpConversationSupported =
|
|
719
|
+
!isGroup ||
|
|
720
|
+
groupSession?.groupSessionScope === "group_topic" ||
|
|
721
|
+
groupSession?.groupSessionScope === "group_topic_sender";
|
|
1170
722
|
|
|
1171
723
|
if (isGroup && groupSession) {
|
|
1172
724
|
log(
|
|
@@ -1215,10 +767,81 @@ export async function handleFeishuMessage(params: {
|
|
|
1215
767
|
}
|
|
1216
768
|
}
|
|
1217
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
|
+
|
|
1218
836
|
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
1219
837
|
const inboundLabel = isGroup
|
|
1220
838
|
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
1221
839
|
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
840
|
+
const contextVisibilityMode = resolveChannelContextVisibilityMode({
|
|
841
|
+
cfg: effectiveCfg,
|
|
842
|
+
channel: "feishu",
|
|
843
|
+
accountId: account.accountId,
|
|
844
|
+
});
|
|
1222
845
|
|
|
1223
846
|
// Do not enqueue inbound user previews as system events.
|
|
1224
847
|
// System events are prepended to future prompts and can be misread as
|
|
@@ -1236,31 +859,101 @@ export async function handleFeishuMessage(params: {
|
|
|
1236
859
|
log,
|
|
1237
860
|
accountId: account.accountId,
|
|
1238
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
|
+
|
|
1239
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;
|
|
1240
913
|
|
|
1241
914
|
// Fetch quoted/replied message content if parentId exists
|
|
915
|
+
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
|
|
1242
916
|
let quotedContent: string | undefined;
|
|
1243
917
|
if (ctx.parentId) {
|
|
1244
918
|
try {
|
|
1245
|
-
|
|
919
|
+
quotedMessageInfo = await getMessageFeishu({
|
|
1246
920
|
cfg,
|
|
1247
921
|
messageId: ctx.parentId,
|
|
1248
922
|
accountId: account.accountId,
|
|
1249
923
|
});
|
|
1250
|
-
if (
|
|
1251
|
-
|
|
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;
|
|
1252
936
|
log(
|
|
1253
937
|
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
|
|
1254
938
|
);
|
|
939
|
+
} else if (quotedMessageInfo) {
|
|
940
|
+
log(
|
|
941
|
+
`feishu[${account.accountId}]: skipped quoted message from sender ${quotedMessageInfo.senderId ?? "unknown"} (mode=${contextVisibilityMode})`,
|
|
942
|
+
);
|
|
1255
943
|
}
|
|
1256
944
|
} catch (err) {
|
|
1257
945
|
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
|
|
1258
946
|
}
|
|
1259
947
|
}
|
|
1260
948
|
|
|
949
|
+
const isTopicSessionForThread =
|
|
950
|
+
isGroup &&
|
|
951
|
+
(groupSession?.groupSessionScope === "group_topic" ||
|
|
952
|
+
groupSession?.groupSessionScope === "group_topic_sender");
|
|
953
|
+
|
|
1261
954
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1262
955
|
const messageBody = buildFeishuAgentBody({
|
|
1263
|
-
ctx,
|
|
956
|
+
ctx: agentFacingCtx,
|
|
1264
957
|
quotedContent,
|
|
1265
958
|
permissionErrorForAgent,
|
|
1266
959
|
botOpenId,
|
|
@@ -1309,45 +1002,226 @@ export async function handleFeishuMessage(params: {
|
|
|
1309
1002
|
}))
|
|
1310
1003
|
: undefined;
|
|
1311
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
|
+
|
|
1312
1177
|
// --- Shared context builder for dispatch ---
|
|
1313
|
-
const buildCtxPayloadForAgent = (
|
|
1178
|
+
const buildCtxPayloadForAgent = async (
|
|
1179
|
+
agentId: string,
|
|
1314
1180
|
agentSessionKey: string,
|
|
1315
1181
|
agentAccountId: string,
|
|
1316
1182
|
wasMentioned: boolean,
|
|
1317
|
-
) =>
|
|
1318
|
-
|
|
1183
|
+
) => {
|
|
1184
|
+
const groupName = await resolveGroupNameForLabel();
|
|
1185
|
+
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey, groupName);
|
|
1186
|
+
return core.channel.reply.finalizeInboundContext({
|
|
1319
1187
|
Body: combinedBody,
|
|
1320
1188
|
BodyForAgent: messageBody,
|
|
1321
1189
|
InboundHistory: inboundHistory,
|
|
1322
1190
|
ReplyToId: ctx.parentId,
|
|
1323
1191
|
RootMessageId: ctx.rootId,
|
|
1324
|
-
RawBody:
|
|
1325
|
-
CommandBody:
|
|
1192
|
+
RawBody: agentFacingContent,
|
|
1193
|
+
CommandBody: agentFacingContent,
|
|
1194
|
+
Transcript: audioTranscript,
|
|
1326
1195
|
From: feishuFrom,
|
|
1327
1196
|
To: feishuTo,
|
|
1328
1197
|
SessionKey: agentSessionKey,
|
|
1329
1198
|
AccountId: agentAccountId,
|
|
1330
1199
|
ChatType: isGroup ? "group" : "direct",
|
|
1331
|
-
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
1200
|
+
GroupSubject: isGroup ? groupName || ctx.chatId : undefined,
|
|
1201
|
+
ConversationLabel: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined,
|
|
1332
1202
|
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
|
1333
1203
|
SenderId: ctx.senderOpenId,
|
|
1334
1204
|
Provider: "feishu" as const,
|
|
1335
1205
|
Surface: "feishu" as const,
|
|
1336
1206
|
MessageSid: ctx.messageId,
|
|
1337
1207
|
ReplyToBody: quotedContent ?? undefined,
|
|
1338
|
-
|
|
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,
|
|
1339
1215
|
WasMentioned: wasMentioned,
|
|
1340
1216
|
CommandAuthorized: commandAuthorized,
|
|
1341
1217
|
OriginatingChannel: "feishu" as const,
|
|
1342
1218
|
OriginatingTo: feishuTo,
|
|
1343
|
-
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt
|
|
1219
|
+
GroupSystemPrompt: isGroup ? normalizeOptionalString(groupConfig?.systemPrompt) : undefined,
|
|
1344
1220
|
...mediaPayload,
|
|
1221
|
+
...(preflightAudioIndex >= 0 ? { MediaTranscribedIndexes: [preflightAudioIndex] } : {}),
|
|
1345
1222
|
});
|
|
1223
|
+
};
|
|
1346
1224
|
|
|
1347
|
-
// Parse message create_time (Feishu uses millisecond epoch string).
|
|
1348
|
-
const messageCreateTimeMs = event.message.create_time
|
|
1349
|
-
? parseInt(event.message.create_time, 10)
|
|
1350
|
-
: undefined;
|
|
1351
1225
|
// Determine reply target based on group session mode:
|
|
1352
1226
|
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
|
|
1353
1227
|
// root so the bot stays in the same thread.
|
|
@@ -1364,7 +1238,11 @@ export async function handleFeishuMessage(params: {
|
|
|
1364
1238
|
isGroup &&
|
|
1365
1239
|
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
|
1366
1240
|
const replyTargetMessageId =
|
|
1367
|
-
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));
|
|
1368
1246
|
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
|
|
1369
1247
|
|
|
1370
1248
|
if (broadcastAgents) {
|
|
@@ -1381,9 +1259,10 @@ export async function handleFeishuMessage(params: {
|
|
|
1381
1259
|
}
|
|
1382
1260
|
|
|
1383
1261
|
// --- Broadcast dispatch: send message to all configured agents ---
|
|
1384
|
-
const
|
|
1385
|
-
(
|
|
1386
|
-
|
|
1262
|
+
const rawStrategy = (
|
|
1263
|
+
(cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined
|
|
1264
|
+
)?.strategy;
|
|
1265
|
+
const strategy = rawStrategy === "sequential" ? "sequential" : "parallel";
|
|
1387
1266
|
const activeAgentId =
|
|
1388
1267
|
ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
|
|
1389
1268
|
const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
|
|
@@ -1402,7 +1281,22 @@ export async function handleFeishuMessage(params: {
|
|
|
1402
1281
|
}
|
|
1403
1282
|
|
|
1404
1283
|
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
|
1405
|
-
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,
|
|
1406
1300
|
agentSessionKey,
|
|
1407
1301
|
route.accountId,
|
|
1408
1302
|
ctx.mentionedBot && agentId === activeAgentId,
|
|
@@ -1410,11 +1304,13 @@ export async function handleFeishuMessage(params: {
|
|
|
1410
1304
|
|
|
1411
1305
|
if (agentId === activeAgentId) {
|
|
1412
1306
|
// Active agent: real Feishu dispatcher (responds on Feishu)
|
|
1307
|
+
const identity = resolveAgentOutboundIdentity(cfg, agentId);
|
|
1413
1308
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1414
1309
|
cfg,
|
|
1415
1310
|
agentId,
|
|
1416
1311
|
runtime: runtime as RuntimeEnv,
|
|
1417
1312
|
chatId: ctx.chatId,
|
|
1313
|
+
allowReasoningPreview,
|
|
1418
1314
|
replyToMessageId: replyTargetMessageId,
|
|
1419
1315
|
skipReplyToInMessages: !isGroup,
|
|
1420
1316
|
replyInThread,
|
|
@@ -1422,22 +1318,53 @@ export async function handleFeishuMessage(params: {
|
|
|
1422
1318
|
threadReply,
|
|
1423
1319
|
mentionTargets: ctx.mentionTargets,
|
|
1424
1320
|
accountId: account.accountId,
|
|
1321
|
+
identity,
|
|
1425
1322
|
messageCreateTimeMs,
|
|
1426
1323
|
});
|
|
1427
1324
|
|
|
1428
1325
|
log(
|
|
1429
1326
|
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1430
1327
|
);
|
|
1431
|
-
await core.channel.
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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,
|
|
1440
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
|
+
},
|
|
1441
1368
|
});
|
|
1442
1369
|
} else {
|
|
1443
1370
|
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
|
@@ -1450,20 +1377,46 @@ export async function handleFeishuMessage(params: {
|
|
|
1450
1377
|
sendFinalReply: () => false,
|
|
1451
1378
|
waitForIdle: async () => {},
|
|
1452
1379
|
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
1380
|
+
getFailedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
1453
1381
|
markComplete: () => {},
|
|
1454
1382
|
};
|
|
1455
1383
|
|
|
1456
1384
|
log(
|
|
1457
1385
|
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1458
1386
|
);
|
|
1459
|
-
await core.channel.
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
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,
|
|
1466
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
|
+
},
|
|
1467
1420
|
});
|
|
1468
1421
|
}
|
|
1469
1422
|
};
|
|
@@ -1502,17 +1455,27 @@ export async function handleFeishuMessage(params: {
|
|
|
1502
1455
|
);
|
|
1503
1456
|
} else {
|
|
1504
1457
|
// --- Single-agent dispatch (existing behavior) ---
|
|
1505
|
-
const ctxPayload = buildCtxPayloadForAgent(
|
|
1458
|
+
const ctxPayload = await buildCtxPayloadForAgent(
|
|
1459
|
+
route.agentId,
|
|
1506
1460
|
route.sessionKey,
|
|
1507
1461
|
route.accountId,
|
|
1508
1462
|
ctx.mentionedBot,
|
|
1509
1463
|
);
|
|
1510
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
|
+
});
|
|
1511
1473
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1512
1474
|
cfg,
|
|
1513
1475
|
agentId: route.agentId,
|
|
1514
1476
|
runtime: runtime as RuntimeEnv,
|
|
1515
1477
|
chatId: ctx.chatId,
|
|
1478
|
+
allowReasoningPreview,
|
|
1516
1479
|
replyToMessageId: replyTargetMessageId,
|
|
1517
1480
|
skipReplyToInMessages: !isGroup,
|
|
1518
1481
|
replyInThread,
|
|
@@ -1520,31 +1483,71 @@ export async function handleFeishuMessage(params: {
|
|
|
1520
1483
|
threadReply,
|
|
1521
1484
|
mentionTargets: ctx.mentionTargets,
|
|
1522
1485
|
accountId: account.accountId,
|
|
1486
|
+
identity,
|
|
1523
1487
|
messageCreateTimeMs,
|
|
1524
1488
|
});
|
|
1525
1489
|
|
|
1526
1490
|
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
1527
|
-
const
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
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,
|
|
1538
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
|
+
},
|
|
1539
1545
|
});
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
clearHistoryEntriesIfEnabled({
|
|
1543
|
-
historyMap: chatHistories,
|
|
1544
|
-
historyKey,
|
|
1545
|
-
limit: historyLimit,
|
|
1546
|
-
});
|
|
1546
|
+
if (!turnResult.dispatched) {
|
|
1547
|
+
return;
|
|
1547
1548
|
}
|
|
1549
|
+
const { dispatchResult } = turnResult;
|
|
1550
|
+
const { queuedFinal, counts } = dispatchResult;
|
|
1548
1551
|
|
|
1549
1552
|
log(
|
|
1550
1553
|
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|