diff --git a/src/bot-registry.ts b/src/bot-registry.ts
index b27659d0f..3712372c2 100644
--- a/src/bot-registry.ts
+++ b/src/bot-registry.ts
@@ -1318,6 +1318,14 @@ export interface SessionGroupConfig {
workingDir?: string;
/** Send a DM receipt linking the freshly-created group (true). */
dmReceipt?: boolean;
+ /**
+ * Forward the DM that spawned the group into the group as its first
+ * message (true). This is what makes the group self-explaining: a text
+ * seed can be quoted inline, but an image / file / 合并转发消息 cannot be
+ * re-created from the event payload — only forwarded. Set false to keep
+ * just the intro line (non-text seeds then read「(非文本消息)」).
+ */
+ forwardOrigin?: boolean;
/**
* What to do with the group when its session is closed:
* 'keep' (default) — leave the group and registry entry; a later message in
diff --git a/src/core/session-group-birth.ts b/src/core/session-group-birth.ts
index 5c966d393..bd5b10570 100644
--- a/src/core/session-group-birth.ts
+++ b/src/core/session-group-birth.ts
@@ -41,7 +41,7 @@ import { registerSessionGroup } from '../services/session-groups-store.js';
import { scheduleSessionGroupTitle } from '../services/session-group-title.js';
import { tagSessionGroup } from '../services/feed-group-tagger.js';
import { applySessionGroupAvatar } from '../services/session-group-avatar.js';
-import { sendMessage, replyMessage } from '../im/lark/client.js';
+import { sendMessage, replyMessage, forwardMessage } from '../im/lark/client.js';
import { evaluateTalk, extractMessageTextForRouting, type RoutingContext } from '../im/lark/event-dispatcher.js';
import { stripLeadingMentions } from '../im/lark/message-parser.js';
import { t, localeForBot, type Locale } from '../i18n/index.js';
@@ -182,19 +182,44 @@ export async function maybeBirthSessionGroup(
originChatId: dmChatId,
});
- // Intro message: quote the user's DM text so the group is self-explaining.
- // Its message_id becomes the turn's IN-GROUP anchor (ctx.messageId below):
- // the streaming card / replies quote THIS message, keeping every output in
- // the group — anchoring on the original DM message would leak them to the DM.
- const excerpt = trimmed
- ? Array.from(trimmed).slice(0, 500).join('')
- : t('sg.intro_no_text', undefined, locale);
+ // Origin forward (default on): replay the DM that spawned this group as the
+ // group's own first message. An excerpt can only ever quote TEXT, so an
+ // image / file / 合并转发消息 seed degraded to a bare「(非文本消息)」 and the
+ // group lost every trace of why it exists. Forwarding carries the original
+ // body over verbatim — attachments and forward trees included — which is
+ // the only faithful way to replay a non-text seed into another chat.
+ //
+ // The copy is sent BY THE BOT, so its echo comes back as a self-message and
+ // the dispatcher drops it (only `/close` is routed for self senders): it
+ // can never re-trigger a turn.
+ let forwardedMessageId: string | undefined;
+ if (sg.forwardOrigin !== false) {
+ try {
+ forwardedMessageId = await forwardMessage(larkAppId, messageId, newChatId);
+ } catch (err) {
+ logger.info(`[session-group] origin forward failed for ${newChatId.substring(0, 12)}; falling back to an inline excerpt: ${err}`);
+ }
+ }
+
+ // Intro message: says who started this and where it came from. Its
+ // message_id becomes the turn's IN-GROUP anchor (see replyAnchorMessageId
+ // below) so the streaming card / replies quote a message that lives in the
+ // group — anchoring on the original DM message would leak them to the DM.
+ //
+ // With the origin forward above it is a one-liner pointing at that message;
+ // without it (disabled, or the forward failed) it keeps the legacy shape
+ // and quotes the DM text inline so text seeds never lose their context.
+ const introBody = forwardedMessageId
+ ? t('sg.intro_forwarded', undefined, locale)
+ : `${t('sg.intro', undefined, locale)}\n${trimmed
+ ? Array.from(trimmed).slice(0, 500).join('')
+ : t('sg.intro_no_text', undefined, locale)}`;
let introMessageId: string | undefined;
try {
introMessageId = await sendMessage(
larkAppId,
newChatId,
- `📥 ${t('sg.intro', undefined, locale)}\n${excerpt}`,
+ `📥 ${introBody}`,
'text',
);
} catch (err) {
@@ -224,11 +249,26 @@ export async function maybeBirthSessionGroup(
void applySessionGroupAvatar(larkAppId, newChatId);
// T1 of two-phase naming: async AI title → rename (fire-and-forget).
- scheduleSessionGroupTitle({ larkAppId, chatId: newChatId, userText: trimmed });
+ //
+ // ONLY when the routing peek actually produced text. That peek understands
+ // text/post and nothing else, so an image / file / 合并转发消息 seed reaches
+ // here with an empty string — and scheduling on '' is NOT a harmless no-op:
+ // the title service bails inside its async body, i.e. AFTER the attempt is
+ // registered, so the empty call burns one of the three bounded rounds and
+ // arms a 30s backoff. That is exactly why forwarded-message groups used to
+ // sit on「新会话」 and never reach the rename logic.
+ //
+ // Non-text seeds are titled by the recursed handleNewTopic instead, right
+ // after it parses (and, for 合并转发, expands) the message: that path holds
+ // the real content, so the title is summarised from the forwarded
+ // conversation itself rather than from an empty string.
+ const titleScheduled = !!trimmed;
+ if (titleScheduled) scheduleSessionGroupTitle({ larkAppId, chatId: newChatId, userText: trimmed });
logger.info(
`[session-group] born chat=${newChatId.substring(0, 12)} for dm=${dmChatId.substring(0, 12)} ` +
- `msg=${messageId.substring(0, 12)} intro=${introMessageId?.substring(0, 12) ?? '-'} ` +
+ `msg=${messageId.substring(0, 12)} fwd=${forwardedMessageId?.substring(0, 12) ?? '-'} ` +
+ `intro=${introMessageId?.substring(0, 12) ?? '-'} ` +
`name="${placeholder}" workingDir=${workingDir ?? '-'} origin=${originEv.reason}`,
);
@@ -240,12 +280,16 @@ export async function maybeBirthSessionGroup(
anchor: newChatId,
// `messageId` stays the ORIGINAL DM message id: resource keys /
// merge-forward sub-messages belong to it, so downloads must keep using
- // it. The in-group intro message rides separately as the REPLY anchor
- // (quote target / session rootMessageId) so the first turn's outputs
- // land in the group. When the intro failed to send the anchor is left
- // unset and replies degrade to the DM, but the session still lives in
- // the group.
- replyAnchorMessageId: introMessageId,
+ // it. An IN-GROUP message rides separately as the REPLY anchor (quote
+ // target / session rootMessageId) so the first turn's outputs land in
+ // the group: the intro line first, else the forwarded original — both
+ // live in the new chat. Only when BOTH failed is the anchor left unset
+ // and replies degrade to the DM, while the session still lives in the
+ // group.
+ replyAnchorMessageId: introMessageId ?? forwardedMessageId,
+ // Tells the recursed handleNewTopic whether the AI title still needs
+ // scheduling from the fully parsed content (non-text seeds only).
+ sessionGroupTitleScheduled: titleScheduled,
replyRootId: undefined,
sessionGroupBirth: true,
};
diff --git a/src/daemon.ts b/src/daemon.ts
index 7e88ac68f..4d25a17f0 100644
--- a/src/daemon.ts
+++ b/src/daemon.ts
@@ -18208,6 +18208,23 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise、语音已转写),parsed.content 必然非空——这才是这类
+ // 群唯一能用的标题来源,也是「转发消息集合开的群停在占位名」的修复点。
+ //
+ // 文本种子照旧由 birth 侧调度(出生瞬间就开跑,早几百毫秒改名),并用
+ // sessionGroupTitleScheduled 告诉这里「已经调过了」——两处严格二选一。别指望
+ // title 服务自己去重:它的 titled / in-flight 闸在**异步体内**,出生侧刚发起的
+ // 那次此刻既没置 titled 也可能还没进 in-flight,重复调用会白烧一轮有限重试。
+ if (ctx.sessionGroupBirth && !ctx.sessionGroupTitleScheduled && isSessionGroup(chatId)) {
+ const sgEntry = getSessionGroup(chatId);
+ if (sgEntry && !sgEntry.titled && parsed.content.trim() && !parsed.content.trim().startsWith('/')) {
+ scheduleSessionGroupTitle({ larkAppId, chatId, userText: parsed.content });
+ }
+ }
+
const senderOpenId: string | undefined = data.sender?.sender_id?.open_id;
const isBotSenderType = data.sender?.sender_type === 'app' || data.sender?.sender_type === 'bot';
const isForeignBotSender = isBotSenderType
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 39b786d68..91eb4a855 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -1572,6 +1572,7 @@ export const messages: Record = {
'sg.placeholder_untitled': 'New conversation',
'sg.intro': 'started this conversation from DM:',
'sg.intro_no_text': '(non-text message)',
+ 'sg.intro_forwarded': 'started this conversation from DM — the original message is forwarded above ⬆️',
'sg.receipt': '✅ Created a dedicated group for this conversation — continue there: {link}',
'sg.birth_failed': '⚠️ Group creation failed ({error}); falling back to a DM topic for this conversation.',
'sg.cmd_unsupported': '⚠️ {cmd} is not supported in session groups: they are auto-created and managed by the bot with a fixed continuous-session mode.',
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index 68aad120f..63529a552 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -1570,6 +1570,9 @@ export const messages: Record = {
'sg.placeholder_untitled': '新会话',
'sg.intro': '发起的私聊会话:',
'sg.intro_no_text': '(非文本消息)',
+ // 原消息已转发到群里(就在这条上面),所以引言只负责点明来历,不再复述正文——
+ // 图片 / 文件 / 合并转发消息本来就复述不了。
+ 'sg.intro_forwarded': '从私聊发起了本次会话,原消息已转发到本群(见上 ⬆️)。',
'sg.receipt': '✅ 已为本次会话创建专属群,后续请在群里继续:{link}',
'sg.birth_failed': '⚠️ 建群失败({error}),本次会话回退为私聊话题。',
'sg.cmd_unsupported': '⚠️ 会话群不支持 {cmd}:会话群由 bot 自动创建和管理,固定为连续会话模式。',
diff --git a/src/im/lark/client.ts b/src/im/lark/client.ts
index f530a4280..2fdee659d 100644
--- a/src/im/lark/client.ts
+++ b/src/im/lark/client.ts
@@ -399,6 +399,58 @@ export async function replyMessage(
});
}
+/**
+ * Forward an existing message into another chat (im.v1.message.forward).
+ *
+ * Unlike send/reply this carries the ORIGINAL message over verbatim — sender
+ * name, message type and all — which is the only faithful way to replay a
+ * non-text seed (image / file / 合并转发消息) into a different chat: those
+ * bodies cannot be re-created from an event payload, only pointed at.
+ *
+ * Used by session-group birth to make the freshly-created group
+ * self-explaining: the DM that spawned it is forwarded in as the group's first
+ * message, so the conversation carries its own origin instead of a
+ * "(非文本消息)" placeholder.
+ *
+ * Emits no outbound hook (same as {@link sendUserMessage}): the hook event
+ * union is closed and a forward is not one of its members.
+ */
+export async function forwardMessage(
+ larkAppId: string,
+ messageId: string,
+ chatId: string,
+ uuid?: string,
+): Promise {
+ assertLarkTransport(larkAppId, 'forwardMessage');
+ return executeWithLarkGate(larkAppId, 'forwardMessage', async () => {
+ const c = getBotClient(larkAppId);
+ let res: any;
+ try {
+ res = await (c as any).im.v1.message.forward({
+ path: { message_id: messageId },
+ // NOTE: forward takes `uuid` in params (not data) — unlike create/reply.
+ params: { receive_id_type: 'chat_id', ...(uuid ? { uuid } : {}) },
+ data: { receive_id: chatId },
+ });
+ } catch (err: any) {
+ if (getLarkErrorCode(err) === LARK_CODE_MESSAGE_WITHDRAWN) {
+ throw new MessageWithdrawnError(messageId);
+ }
+ throw err;
+ }
+
+ if (res.code !== 0) {
+ if (res.code === LARK_CODE_MESSAGE_WITHDRAWN) throw new MessageWithdrawnError(messageId);
+ throw new Error(`Failed to forward message: ${res.msg} (code: ${res.code})`);
+ }
+
+ const forwardedId = res.data?.message_id;
+ if (!forwardedId) throw new Error('No message_id in forward response');
+ logger.info(`Forwarded message ${messageId} to chat ${chatId} as ${forwardedId}`);
+ return forwardedId;
+ });
+}
+
export async function addReaction(larkAppId: string, messageId: string, emojiType: string): Promise {
assertLarkTransport(larkAppId, 'addReaction');
return executeWithLarkGate(larkAppId, 'addReaction', async () => {
diff --git a/src/im/lark/event-dispatcher.ts b/src/im/lark/event-dispatcher.ts
index 7b2d016d3..df82c88c9 100644
--- a/src/im/lark/event-dispatcher.ts
+++ b/src/im/lark/event-dispatcher.ts
@@ -2318,6 +2318,14 @@ export interface RoutingContext {
* an already-charged turn is exactly the "charged, then lost the task"
* failure. See enforceMessageQuotaForCliInput's alreadyAuthorizedAndCharged. */
sessionGroupQuotaConsumed?: boolean;
+ /** Session-group birth only: the birth flow already scheduled the AI title
+ * from its own text peek (a text/post seed). Non-text seeds leave it unset,
+ * because that peek yields nothing for them — the recursed handleNewTopic
+ * schedules from the FULLY PARSED content instead (merge_forward expanded,
+ * audio transcribed). Exactly one of the two sites runs per birth: the title
+ * service is idempotent, but only via its async in-flight/titled guards, so
+ * a duplicate call still burns a bounded retry round. */
+ sessionGroupTitleScheduled?: boolean;
/** Session-group birth only: the in-group intro message id used as the
* turn's REPLY anchor (quote target / session rootMessageId), so the first
* turn's outputs land in the group. `messageId` stays the ORIGINAL inbound
diff --git a/test/lark-transport-boundary.test.ts b/test/lark-transport-boundary.test.ts
index 2e25fdc09..3ba3f8dd4 100644
--- a/test/lark-transport-boundary.test.ts
+++ b/test/lark-transport-boundary.test.ts
@@ -17,7 +17,7 @@ const getBotMock = vi.fn();
const fakeClient = {
im: {
v1: {
- message: { create: vi.fn(async () => ({ code: 0, data: { message_id: 'om_x' } })), patch: vi.fn(async () => ({ code: 0 })) },
+ message: { create: vi.fn(async () => ({ code: 0, data: { message_id: 'om_x' } })), patch: vi.fn(async () => ({ code: 0 })), forward: vi.fn(async () => ({ code: 0, data: { message_id: 'om_fwd' } })) },
pin: { create: vi.fn(async () => ({ code: 0 })), delete: vi.fn(async () => ({ code: 0 })) },
messageReaction: { create: vi.fn(async () => ({ code: 0, data: { reaction_id: 'r' } })), delete: vi.fn(async () => ({ code: 0 })) },
},
@@ -54,7 +54,7 @@ vi.mock('../src/bot-registry.js', async (importOriginal) => {
});
import {
- sendMessage, replyMessage, updateMessage, deleteMessage,
+ sendMessage, replyMessage, forwardMessage, updateMessage, deleteMessage,
pinMessage, unpinMessage,
resolveCardKitId, updateCardStreamingSettings, updateCardStreamElementContent, patchCardStreamElement,
addReaction, removeReaction, sendUserMessage, sendEphemeralCard,
@@ -76,6 +76,7 @@ describe('assertLarkTransport — bot-level outbound gate', () => {
getBotMock.mockReturnValue(bot(true));
await expect(sendMessage(APIONLY, 'oc', 'hi')).rejects.toBeInstanceOf(LarkTransportDisabledError);
await expect(replyMessage(APIONLY, 'om', 'hi')).rejects.toBeInstanceOf(LarkTransportDisabledError);
+ await expect(forwardMessage(APIONLY, 'om', 'oc')).rejects.toBeInstanceOf(LarkTransportDisabledError);
await expect(updateMessage(APIONLY, 'om', '{}')).rejects.toBeInstanceOf(LarkTransportDisabledError);
await expect(resolveCardKitId(APIONLY, 'om')).rejects.toBeInstanceOf(LarkTransportDisabledError);
await expect(updateCardStreamingSettings(APIONLY, 'card', {
@@ -104,6 +105,7 @@ describe('assertLarkTransport — bot-level outbound gate', () => {
it('a normal bot is unaffected — sendMessage/updateMessage proceed to the client', async () => {
getBotMock.mockReturnValue(bot(false));
await expect(sendMessage(NORMAL, 'oc', 'hi')).resolves.toBeDefined();
+ await expect(forwardMessage(NORMAL, 'om', 'oc')).resolves.toBe('om_fwd');
await expect(updateMessage(NORMAL, 'om', '{}')).resolves.toBeUndefined();
await expect(resolveCardKitId(NORMAL, 'om')).resolves.toBe('card_x');
await expect(updateCardStreamingSettings(NORMAL, 'card_x', {
@@ -119,6 +121,11 @@ describe('assertLarkTransport — bot-level outbound gate', () => {
NORMAL, 'card_x', 'loader', { img_key: 'img_x' }, 3, 'u3',
)).resolves.toBeUndefined();
expect(fakeClient.im.v1.message.create).toHaveBeenCalled();
+ expect(fakeClient.im.v1.message.forward).toHaveBeenCalledWith({
+ path: { message_id: 'om' },
+ params: { receive_id_type: 'chat_id' },
+ data: { receive_id: 'oc' },
+ });
expect(fakeClient.im.v1.message.patch).toHaveBeenCalled();
expect(fakeClient.cardkit.v1.card.idConvert).toHaveBeenCalledWith({ data: { message_id: 'om' } });
expect(fakeClient.cardkit.v1.card.settings).toHaveBeenCalledWith({
diff --git a/test/session-group-birth-forward-seed.test.ts b/test/session-group-birth-forward-seed.test.ts
new file mode 100644
index 000000000..e71f2af24
--- /dev/null
+++ b/test/session-group-birth-forward-seed.test.ts
@@ -0,0 +1,377 @@
+/**
+ * 私聊 group 模式下「非文本种子」(转发消息集合 / 图片 / 文件)开群的两件事:
+ *
+ * 1. **群要自解释**:出生时把私聊原消息**转发**进新群当第一条消息。原来只发一条
+ * 引言 + 正文摘录,而摘录只能摘 text —— 合并转发消息在那里被渲染成
+ * 「(非文本消息)」,群里从此看不出自己是怎么来的。
+ * 2. **AI 命名必须真的跑到**:birth 里唯一的文本来源是
+ * extractMessageTextForRouting,它只认 text/post,合并转发消息拿到的是 null。
+ * 于是 scheduleSessionGroupTitle 收到空串 —— 而空串不是「白调一次」:title
+ * 服务在**异步体内部**才 return,调用已经记了一次 attempt 并布下退避,三轮额度
+ * 白烧一轮。表现就是转发消息开的群永远停在占位名,只能等后续文本消息自愈。
+ * 修复后:出生侧对空串**不调度**,改由递归回来的 handleNewTopic 在消息**完整
+ * 解析(合并转发已展开成 XML)之后**用真实内容调度一次。
+ *
+ * 用例跑**真实的建群递归**,只替身 createGroupWithBots(唯一的建群外部副作用)、
+ * forwardMessage/sendMessage(飞书写接口)与 expandMergeForward(要联网拉子消息)。
+ *
+ * Run: pnpm vitest run test/session-group-birth-forward-seed.test.ts
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+const FORWARDED_XML = [
+ '',
+ ' ',
+ ' ',
+ ' ',
+ ' 同学帮忙添加下设备',
+ ' 文档的步骤三',
+ '',
+].join('\n');
+
+const mocks = vi.hoisted(() => {
+ const dataDir = `${process.env.TMPDIR ?? '/tmp'}/botmux-sg-fwd-${process.pid}`;
+ process.env.SESSION_DATA_DIR = dataDir;
+ process.env.BOTS_CONFIG = `${dataDir}/bots.json`;
+ delete process.env.BOTMUX_SESSION_ID;
+ delete process.env.BOTMUX_LARK_APP_ID;
+ let seq = 0;
+ return {
+ dataDir,
+ createGroupWithBots: vi.fn(),
+ replyMessage: vi.fn(async () => 'om_reply'),
+ sendMessage: vi.fn(async () => 'om_intro'),
+ forwardMessage: vi.fn(async () => 'om_forwarded'),
+ getChatMode: vi.fn(async () => 'group' as 'group' | 'topic' | 'p2p'),
+ getChatNameAndMode: vi.fn(async () => ({ name: null, mode: 'group' as const })),
+ resolveSender: vi.fn(async (_appId: string, openId?: string) => (
+ openId ? { openId, type: 'user' as const } : undefined
+ )),
+ forkWorker: vi.fn(),
+ downloadResources: vi.fn(async () => ({ attachments: [], needLogin: false })),
+ // 真身要按父 message_id 逐条拉子消息;替身只复刻它的**契约**:把
+ // parsed.content 换成渲染好的 XML 并改 msgType。
+ expandMergeForward: vi.fn(async (_appId: string, _msgId: string, parsed: any) => {
+ parsed.content = FORWARDED_XML;
+ parsed.msgType = 'merge_forward_expanded';
+ return { extraResources: [] };
+ }),
+ scheduleSessionGroupTitle: vi.fn(),
+ createdSessions: [] as any[],
+ createSession: vi.fn(function (chatId: string, rootMessageId: string, title: string, chatType?: 'group' | 'p2p') {
+ const session = {
+ sessionId: `sess-fwd-${++seq}`,
+ chatId,
+ rootMessageId,
+ title,
+ status: 'active' as const,
+ createdAt: new Date().toISOString(),
+ chatType,
+ };
+ mocks.createdSessions.push(session);
+ return session;
+ }),
+ updateSession: vi.fn(),
+ };
+});
+
+vi.mock('@larksuiteoapi/node-sdk', () => {
+ class FakeClient { constructor(public opts: Record) {} }
+ return { Client: FakeClient };
+});
+
+vi.mock('node-pty', () => ({
+ spawn: vi.fn(() => ({
+ onData: vi.fn(), onExit: vi.fn(), write: vi.fn(), resize: vi.fn(), kill: vi.fn(),
+ })),
+}));
+
+vi.mock('../src/services/group-creator.js', async () => {
+ const actual = await vi.importActual('../src/services/group-creator.js');
+ return { ...actual, createGroupWithBots: (...args: any[]) => mocks.createGroupWithBots(...args) };
+});
+
+vi.mock('../src/im/lark/client.js', async () => {
+ const actual = await vi.importActual('../src/im/lark/client.js');
+ return {
+ ...actual,
+ replyMessage: mocks.replyMessage,
+ sendMessage: mocks.sendMessage,
+ forwardMessage: (...args: any[]) => mocks.forwardMessage(...args),
+ getChatMode: mocks.getChatMode,
+ getChatNameAndMode: mocks.getChatNameAndMode,
+ getChatInfo: vi.fn(async () => ({ userCount: 1, botCount: 1 })),
+ listChatBotMembers: vi.fn(async () => []),
+ resolveAllowedUsersWithMap: vi.fn(async (_appId: string, users: string[]) => ({ resolved: users, map: new Map() })),
+ sendUserMessage: vi.fn(async () => 'om_dm'),
+ updateMessage: vi.fn(async () => undefined),
+ };
+});
+
+vi.mock('../src/im/lark/merge-forward.js', async () => {
+ const actual = await vi.importActual('../src/im/lark/merge-forward.js');
+ return { ...actual, expandMergeForward: (...args: any[]) => mocks.expandMergeForward(...args) };
+});
+
+vi.mock('../src/services/session-store.js', async () => {
+ const actual = await vi.importActual('../src/services/session-store.js');
+ return { ...actual, createSession: mocks.createSession, updateSession: mocks.updateSession };
+});
+
+vi.mock('../src/im/lark/identity-cache.js', async () => {
+ const actual = await vi.importActual('../src/im/lark/identity-cache.js');
+ return { ...actual, resolveSender: (...args: any[]) => mocks.resolveSender(...args) };
+});
+
+vi.mock('../src/core/worker-pool.js', async () => {
+ const actual = await vi.importActual('../src/core/worker-pool.js');
+ return { ...actual, forkWorker: (...args: any[]) => mocks.forkWorker(...args) };
+});
+
+vi.mock('../src/core/session-manager.js', async () => {
+ const actual = await vi.importActual('../src/core/session-manager.js');
+ return { ...actual, downloadResources: (...args: any[]) => mocks.downloadResources(...args) };
+});
+
+// 替身掉是为了能**数调用次数**:真身内部对 titled / in-flight 幂等,会把
+// 「出生侧 + 解析后」重复调度这种回归掩盖掉。
+vi.mock('../src/services/session-group-title.js', async () => {
+ const actual = await vi.importActual('../src/services/session-group-title.js');
+ return { ...actual, scheduleSessionGroupTitle: (...args: any[]) => mocks.scheduleSessionGroupTitle(...args) };
+});
+
+import { registerBot, getBot } from '../src/bot-registry.js';
+import {
+ __testOnly_activeSessions as activeSessions,
+ __testOnly_handleNewTopic as handleNewTopic,
+} from '../src/daemon.js';
+import { initSessionGroups, getSessionGroup } from '../src/services/session-groups-store.js';
+import type { RoutingContext } from '../src/im/lark/event-dispatcher.js';
+
+const APP = 'sg_fwd_app';
+const DM_CHAT = 'oc_dm_fwd_source';
+const BORN_GROUP = 'oc_born_fwd_group';
+const OWNER = 'ou_fwd_owner';
+const DM_MSG = 'om_dm_forward_seed';
+
+function mergeForwardDmEvent(messageId = DM_MSG): any {
+ return {
+ sender: { sender_id: { open_id: OWNER }, sender_type: 'user' },
+ message: {
+ message_id: messageId,
+ chat_id: DM_CHAT,
+ chat_type: 'p2p',
+ message_type: 'merge_forward',
+ content: JSON.stringify({ content: '[合并转发]' }),
+ create_time: String(Date.now()),
+ },
+ };
+}
+
+function textDmEvent(text: string, messageId: string): any {
+ return {
+ sender: { sender_id: { open_id: OWNER }, sender_type: 'user' },
+ message: {
+ message_id: messageId,
+ chat_id: DM_CHAT,
+ chat_type: 'p2p',
+ message_type: 'text',
+ content: JSON.stringify({ text }),
+ create_time: String(Date.now()),
+ },
+ };
+}
+
+function dmCtx(messageId: string): RoutingContext {
+ return {
+ chatId: DM_CHAT,
+ messageId,
+ chatType: 'p2p',
+ scope: 'thread',
+ anchor: messageId,
+ larkAppId: APP,
+ };
+}
+
+function createGroupResult(): any {
+ return {
+ ok: true,
+ chatId: BORN_GROUP,
+ creator: APP,
+ invalidBotIds: [],
+ invalidUserIds: [],
+ invalidOwnerUnionIds: [],
+ ownerTransferredTo: null,
+ transferError: null,
+ notifyMessageId: null,
+ notifyError: null,
+ shareLink: null,
+ shareLinkError: null,
+ oncallBindings: [],
+ roleProfileBootstrapMessageId: null,
+ roleProfileBootstrapError: null,
+ kickoffMessageId: null,
+ kickoffError: null,
+ };
+}
+
+/** 群里发出去的引言正文(sendMessage 的第三个参数)。 */
+function introTexts(): string[] {
+ return mocks.sendMessage.mock.calls
+ .filter(c => c[1] === BORN_GROUP && (c[3] ?? 'text') === 'text')
+ .map(c => String(c[2]));
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mkdirSync(mocks.dataDir, { recursive: true });
+ writeFileSync(process.env.BOTS_CONFIG!, JSON.stringify([]));
+ rmSync(join(mocks.dataDir, `session-groups-${APP}.json`), { force: true });
+ initSessionGroups(APP);
+ activeSessions.clear();
+ mocks.createdSessions.length = 0;
+ mocks.replyMessage.mockResolvedValue('om_reply');
+ mocks.sendMessage.mockResolvedValue('om_intro');
+ mocks.forwardMessage.mockResolvedValue('om_forwarded');
+ mocks.getChatMode.mockResolvedValue('group');
+ mocks.getChatNameAndMode.mockResolvedValue({ name: null, mode: 'group' });
+ mocks.downloadResources.mockResolvedValue({ attachments: [], needLogin: false });
+ mocks.resolveSender.mockImplementation(async (_appId: string, openId?: string) => (
+ openId ? { openId, type: 'user' as const } : undefined
+ ));
+ mocks.expandMergeForward.mockImplementation(async (_appId: string, _msgId: string, parsed: any) => {
+ parsed.content = FORWARDED_XML;
+ parsed.msgType = 'merge_forward_expanded';
+ return { extraResources: [] };
+ });
+ mocks.createGroupWithBots.mockResolvedValue(createGroupResult());
+
+ const workDir = join(mocks.dataDir, 'workdir');
+ mkdirSync(workDir, { recursive: true });
+ registerBot({
+ larkAppId: APP,
+ larkAppSecret: 'secret',
+ cliId: 'claude-code',
+ p2pMode: 'group',
+ defaultWorkingDir: workDir,
+ allowedUsers: [OWNER],
+ // 群标签 / 群头像是 fire-and-forget 装饰步骤,与本用例无关且要联网。
+ sessionGroup: { tag: { mode: 'off' }, avatar: 'off' },
+ } as any);
+ getBot(APP).resolvedAllowedUsers = [OWNER];
+});
+
+describe('会话群出生:转发消息集合种子', () => {
+ it('把私聊原消息转发进新群,引言指向它而不是「(非文本消息)」', async () => {
+ await handleNewTopic(mergeForwardDmEvent(), dmCtx(DM_MSG));
+
+ // 转发用的是**原私聊消息 id**(资源/子消息都挂在它上面)+ 新群 chat_id。
+ expect(mocks.forwardMessage).toHaveBeenCalledTimes(1);
+ expect(mocks.forwardMessage.mock.calls[0].slice(0, 3)).toEqual([APP, DM_MSG, BORN_GROUP]);
+
+ const intro = introTexts()[0];
+ expect(intro).toContain('原消息已转发到本群');
+ expect(intro).not.toContain('(非文本消息)');
+ // 引言仍然 @ 发起人,群成员一眼看出是谁开的。
+ expect(intro).toContain(``);
+ });
+
+ it('AI 命名用展开后的转发正文调度一次(旧行为是空串 → 永远停在占位名)', async () => {
+ await handleNewTopic(mergeForwardDmEvent(), dmCtx(DM_MSG));
+
+ // 合并转发按**原私聊消息 id** 展开(子消息只认父 id)。
+ expect(mocks.expandMergeForward).toHaveBeenCalledTimes(1);
+ expect(mocks.expandMergeForward.mock.calls[0][1]).toBe(DM_MSG);
+
+ expect(mocks.scheduleSessionGroupTitle).toHaveBeenCalledTimes(1);
+ const arg = mocks.scheduleSessionGroupTitle.mock.calls[0][0];
+ expect(arg).toMatchObject({ larkAppId: APP, chatId: BORN_GROUP });
+ // 关键回归点:不是空串,且确实是展开后的转发内容。
+ expect(arg.userText.trim()).not.toBe('');
+ expect(arg.userText).toContain('同学帮忙添加下设备');
+ });
+
+ it('出生时的占位群名仍可接受(真名由 AI 命名异步替换)', async () => {
+ await handleNewTopic(mergeForwardDmEvent(), dmCtx(DM_MSG));
+
+ expect(mocks.createGroupWithBots).toHaveBeenCalledTimes(1);
+ expect(mocks.createGroupWithBots.mock.calls[0][0].name).toBe('新会话');
+ // 群已登记且仍未命名 —— 命名任务刚被调度出去。
+ expect(getSessionGroup(BORN_GROUP)?.titled).toBeUndefined();
+ });
+
+ it('转发失败时降级成内联摘录引言,会话照常落在新群', async () => {
+ mocks.forwardMessage.mockRejectedValue(new Error('forward not permitted'));
+
+ await handleNewTopic(mergeForwardDmEvent(), dmCtx(DM_MSG));
+
+ const intro = introTexts()[0];
+ expect(intro).toContain('(非文本消息)');
+ expect(mocks.createdSessions).toHaveLength(1);
+ expect(mocks.createdSessions[0].chatId).toBe(BORN_GROUP);
+ // 引言仍在群里 → 首轮回复锚点还在群内,不会漏回私聊。
+ expect(mocks.createdSessions[0].rootMessageId).toBe('om_intro');
+ // 命名照常用展开后的正文,跟转发成败无关。
+ expect(mocks.scheduleSessionGroupTitle).toHaveBeenCalledTimes(1);
+ expect(mocks.scheduleSessionGroupTitle.mock.calls[0][0].userText).toContain('同学帮忙添加下设备');
+ });
+
+ it('转发成功但引言发失败时,锚点回落到转发进来的那条群内消息', async () => {
+ mocks.sendMessage.mockRejectedValue(new Error('send blocked'));
+
+ await handleNewTopic(mergeForwardDmEvent(), dmCtx(DM_MSG));
+
+ expect(mocks.createdSessions).toHaveLength(1);
+ // 两条群内消息里只剩转发那条 —— 锚点必须落在它身上,而不是回落到私聊消息。
+ expect(mocks.createdSessions[0].rootMessageId).toBe('om_forwarded');
+ expect(mocks.createdSessions[0].rootMessageId).not.toBe(DM_MSG);
+ });
+});
+
+describe('会话群出生:文本种子(对照)', () => {
+ it('照旧在出生侧用原文调度一次,解析后不再重复调度', async () => {
+ await handleNewTopic(textDmEvent('帮我修个登录超时的 bug', 'om_text_seed'), dmCtx('om_text_seed'));
+
+ expect(mocks.scheduleSessionGroupTitle).toHaveBeenCalledTimes(1);
+ expect(mocks.scheduleSessionGroupTitle.mock.calls[0][0]).toEqual({
+ larkAppId: APP,
+ chatId: BORN_GROUP,
+ userText: '帮我修个登录超时的 bug',
+ });
+ // 文本种子的占位名仍取原文前缀,不落到「新会话」。
+ expect(mocks.createGroupWithBots.mock.calls[0][0].name).toBe('帮我修个登录超时的 bug');
+ });
+
+ it('文本种子也转发原消息(群里留下用户自己发的那一条)', async () => {
+ await handleNewTopic(textDmEvent('帮我看看这个报错', 'om_text_fwd'), dmCtx('om_text_fwd'));
+
+ expect(mocks.forwardMessage).toHaveBeenCalledTimes(1);
+ expect(mocks.forwardMessage.mock.calls[0].slice(0, 3)).toEqual([APP, 'om_text_fwd', BORN_GROUP]);
+ expect(introTexts()[0]).toContain('原消息已转发到本群');
+ });
+});
+
+describe('会话群出生:sessionGroup.forwardOrigin=false', () => {
+ it('关掉转发后只发引言,非文本种子回到「(非文本消息)」摘录', async () => {
+ registerBot({
+ larkAppId: APP,
+ larkAppSecret: 'secret',
+ cliId: 'claude-code',
+ p2pMode: 'group',
+ defaultWorkingDir: join(mocks.dataDir, 'workdir'),
+ allowedUsers: [OWNER],
+ sessionGroup: { tag: { mode: 'off' }, avatar: 'off', forwardOrigin: false },
+ } as any);
+ getBot(APP).resolvedAllowedUsers = [OWNER];
+
+ await handleNewTopic(mergeForwardDmEvent('om_no_forward'), dmCtx('om_no_forward'));
+
+ expect(mocks.forwardMessage).not.toHaveBeenCalled();
+ expect(introTexts()[0]).toContain('(非文本消息)');
+ // 命名不受开关影响:解析后的转发正文照样喂给 AI。
+ expect(mocks.scheduleSessionGroupTitle).toHaveBeenCalledTimes(1);
+ expect(mocks.scheduleSessionGroupTitle.mock.calls[0][0].userText).toContain('同学帮忙添加下设备');
+ });
+});
diff --git a/test/session-group-birth-quota.test.ts b/test/session-group-birth-quota.test.ts
index ba70f93c2..a932e52c5 100644
--- a/test/session-group-birth-quota.test.ts
+++ b/test/session-group-birth-quota.test.ts
@@ -93,6 +93,7 @@ vi.mock('../src/im/lark/client.js', async () => {
...actual,
replyMessage: mocks.replyMessage,
sendMessage: mocks.sendMessage,
+ forwardMessage: vi.fn(async () => 'om_forwarded'),
getChatMode: mocks.getChatMode,
getChatNameAndMode: mocks.getChatNameAndMode,
getChatInfo: vi.fn(async () => ({ userCount: 1, botCount: 1 })),
diff --git a/test/session-group-birth-workingdir.test.ts b/test/session-group-birth-workingdir.test.ts
index ee6c26c90..c5dac7554 100644
--- a/test/session-group-birth-workingdir.test.ts
+++ b/test/session-group-birth-workingdir.test.ts
@@ -39,6 +39,7 @@ const mocks = vi.hoisted(() => {
runAutoWorktreeCommit: vi.fn(async () => undefined),
replyMessage: vi.fn(async () => 'om_reply'),
sendMessage: vi.fn(async () => 'om_intro'),
+ forwardMessage: vi.fn(async () => 'om_forwarded'),
getChatMode: vi.fn(async () => 'group' as 'group' | 'topic' | 'p2p'),
getChatNameAndMode: vi.fn(async () => ({ name: null, mode: 'group' as const })),
resolveSender: vi.fn(async (_appId: string, openId?: string) => (
@@ -98,6 +99,7 @@ vi.mock('../src/im/lark/client.js', async () => {
...actual,
replyMessage: mocks.replyMessage,
sendMessage: mocks.sendMessage,
+ forwardMessage: (...args: any[]) => mocks.forwardMessage(...args),
getChatMode: mocks.getChatMode,
getChatNameAndMode: mocks.getChatNameAndMode,
getChatInfo: vi.fn(async () => ({ userCount: 1, botCount: 1 })),
@@ -224,6 +226,7 @@ beforeEach(() => {
mocks.createdSessions.length = 0;
mocks.replyMessage.mockResolvedValue('om_reply');
mocks.sendMessage.mockResolvedValue('om_intro');
+ mocks.forwardMessage.mockResolvedValue('om_forwarded');
mocks.getChatMode.mockResolvedValue('group');
mocks.getChatNameAndMode.mockResolvedValue({ name: null, mode: 'group' });
mocks.downloadResources.mockResolvedValue({ attachments: [], needLogin: false });