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 });