Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 61 additions & 17 deletions src/core/session-group-birth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
`📥 <at user_id="${senderOpenId}"></at> ${t('sg.intro', undefined, locale)}\n${excerpt}`,
`📥 <at user_id="${senderOpenId}"></at> ${introBody}`,
'text',
);
} catch (err) {
Expand Down Expand Up @@ -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}`,
);

Expand All @@ -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,
};
Expand Down
17 changes: 17 additions & 0 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18208,6 +18208,23 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise<v
}
}

// 会话群出生命名(非文本种子):birth 只拿得到 extractMessageTextForRouting 的
// 文本窥视结果(只认 text/post),图片/文件/合并转发消息的种子在那里是空串,
// 于是 AI 命名在出生侧被跳过。到这里消息已经被**完整解析**(合并转发也已展开
// 成 <forwarded_messages>、语音已转写),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
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,7 @@ export const messages: Record<string, string> = {
'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.',
Expand Down
3 changes: 3 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,9 @@ export const messages: Record<string, string> = {
'sg.placeholder_untitled': '新会话',
'sg.intro': '发起的私聊会话:',
'sg.intro_no_text': '(非文本消息)',
// 原消息已转发到群里(就在这条上面),所以引言只负责点明来历,不再复述正文——
// 图片 / 文件 / 合并转发消息本来就复述不了。
'sg.intro_forwarded': '从私聊发起了本次会话,原消息已转发到本群(见上 ⬆️)。',
'sg.receipt': '✅ 已为本次会话创建专属群,后续请在群里继续:{link}',
'sg.birth_failed': '⚠️ 建群失败({error}),本次会话回退为私聊话题。',
'sg.cmd_unsupported': '⚠️ 会话群不支持 {cmd}:会话群由 bot 自动创建和管理,固定为连续会话模式。',
Expand Down
52 changes: 52 additions & 0 deletions src/im/lark/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
assertLarkTransport(larkAppId, 'addReaction');
return executeWithLarkGate(larkAppId, 'addReaction', async () => {
Expand Down
8 changes: 8 additions & 0 deletions src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions test/lark-transport-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })) },
},
Expand Down Expand Up @@ -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,
Expand All @@ -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', {
Expand Down Expand Up @@ -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', {
Expand All @@ -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({
Expand Down
Loading