feat: 支持群聊

This commit is contained in:
maxwell 2026-01-29 18:11:33 +08:00
parent 963c5b5a7a
commit e722c9233a
4 changed files with 55 additions and 11 deletions

View File

@ -57,3 +57,4 @@ Multi-account example:
- `channels.feishu.dm.allowFrom`: allowlist for DMs when policy is `allowlist` or `open`.
- `channels.feishu.groupPolicy`: `open`, `allowlist`, or `disabled`.
- `channels.feishu.groups`: per-chat overrides keyed by `chat_id` (supports `requireMention`, `tools`, `users`).
- `channels.feishu.sessionPerMessage`: start a new session for each inbound message (no history).

View File

@ -32,6 +32,12 @@ type FeishuSender = {
};
};
type FeishuMention = {
id?: { open_id?: string; user_id?: string; union_id?: string };
key?: string;
name?: string;
};
type FeishuMessage = {
chat_id?: string;
chat_type?: string;
@ -41,11 +47,13 @@ type FeishuMessage = {
create_time?: string;
root_id?: string;
parent_id?: string;
mentions?: FeishuMention[];
};
type FeishuMessageEvent = {
message?: FeishuMessage;
sender?: FeishuSender;
mentions?: FeishuMention[];
};
type FeishuCoreRuntime = ReturnType<typeof getFeishuRuntime>;
@ -91,18 +99,27 @@ function resolveSenderInfo(
return null;
}
function parseMessageText(params: {
type ParsedMessageContent = {
text: string;
hasAnyMention: boolean;
};
function parseMessageContent(params: {
content: string;
runtime: FeishuRuntimeEnv;
accountId: string;
}): string | null {
}): ParsedMessageContent | null {
const trimmed = params.content.trim();
if (!trimmed) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (!parsed || typeof parsed !== "object") return null;
const text = (parsed as { text?: unknown }).text;
return typeof text === "string" ? text : null;
if (typeof text !== "string") return null;
const mentions = (parsed as { mentions?: unknown }).mentions;
const hasMentions = Array.isArray(mentions) && mentions.length > 0;
const hasInlineMention = /<at\b/i.test(text);
return { text, hasAnyMention: hasMentions || hasInlineMention };
} catch (err) {
params.runtime.error?.(
`[${params.accountId}] Feishu message content parse failed: ${String(err)}`,
@ -111,8 +128,22 @@ function parseMessageText(params: {
}
}
function resolveMentionState(text: string): { hasAnyMention: boolean; wasMentioned: boolean } {
const hasAnyMention = /<at\b/i.test(text);
function hasMentionEntries(entries: FeishuMention[] | undefined): boolean {
return Array.isArray(entries) && entries.length > 0;
}
function resolvePerMessageSessionKey(baseKey: string, message: FeishuMessage): string {
const messageId = message.message_id?.trim();
const messageTime = message.create_time?.trim();
const suffix = messageId || messageTime || String(Date.now());
return `${baseKey}:msg:${suffix}`.toLowerCase();
}
function resolveMentionState(params: {
text: string;
hasAnyMention?: boolean;
}): { hasAnyMention: boolean; wasMentioned: boolean } {
const hasAnyMention = params.hasAnyMention ?? /<at\b/i.test(params.text);
return { hasAnyMention, wasMentioned: hasAnyMention };
}
@ -214,12 +245,13 @@ async function handleFeishuMessage(params: {
return;
}
if (typeof message.content !== "string") return;
const rawBody = parseMessageText({
const parsedContent = parseMessageContent({
content: message.content,
runtime,
accountId: account.accountId,
});
if (rawBody === null) return;
if (parsedContent === null) return;
const rawBody = parsedContent.text;
const chatType = resolveChatType(message.chat_type);
const isGroup = chatType !== "direct";
@ -284,7 +316,12 @@ async function handleFeishuMessage(params: {
let effectiveWasMentioned: boolean | undefined;
if (isGroup) {
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
const mentionState = resolveMentionState(rawBody);
const hasMentions = [
parsedContent.hasAnyMention,
hasMentionEntries(message.mentions),
hasMentionEntries(event.mentions),
].some(Boolean);
const mentionState = resolveMentionState({ text: rawBody, hasAnyMention: hasMentions });
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "feishu",
@ -364,6 +401,9 @@ async function handleFeishuMessage(params: {
},
});
const sessionKey = account.config.sessionPerMessage
? resolvePerMessageSessionKey(route.sessionKey, message)
: route.sessionKey;
const fromLabel = isGroup ? `chat:${chatId}` : `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
@ -371,7 +411,7 @@ async function handleFeishuMessage(params: {
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
sessionKey,
});
const timestampMs = Number(message.create_time);
const timestamp = Number.isFinite(timestampMs) ? timestampMs : undefined;
@ -392,7 +432,7 @@ async function handleFeishuMessage(params: {
CommandBody: rawBody,
From: `feishu:${senderId}`,
To: `feishu:${chatId}`,
SessionKey: route.sessionKey,
SessionKey: sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "channel" : "direct",
ConversationLabel: fromLabel,
@ -414,7 +454,7 @@ async function handleFeishuMessage(params: {
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
sessionKey: ctxPayload.SessionKey ?? sessionKey,
ctx: ctxPayload,
})
.catch((err) => {

View File

@ -78,6 +78,8 @@ export type FeishuAccountConfig = {
mediaMaxMb?: number;
/** Control reply threading when reply tags are present (off|first|all). */
replyToMode?: ReplyToMode;
/** Start a new session for every inbound message (no history). */
sessionPerMessage?: boolean;
dm?: FeishuDmConfig;
groups?: Record<string, FeishuGroupConfig>;
/** Heartbeat visibility settings for this channel. */

View File

@ -333,6 +333,7 @@ export const FeishuAccountSchema = z
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
mediaMaxMb: z.number().positive().optional(),
replyToMode: ReplyToModeSchema.optional(),
sessionPerMessage: z.boolean().optional(),
dm: FeishuDmSchema.optional(),
groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,