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
1 change: 1 addition & 0 deletions src/core/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export const FeishuAccountConfigSchema = z.object({
dedup: DedupSchema,
reactionNotifications: ReactionNotificationModeSchema,
threadSession: z.boolean().optional(),
replyFallbackOnWithdrawn: z.enum(['top-level', 'silent']).optional(),
allowBots: AllowBotsSchema,
uat: UATConfigSchema,
});
Expand Down
65 changes: 49 additions & 16 deletions src/messaging/outbound/deliver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import { threadScopedKey } from '../../channel/chat-queue';
import { normalizeFeishuTarget, resolveReceiveIdType } from '../../core/targets';
import { parseFeishuCommentTarget } from '../../core/comment-target';
import { optimizeMarkdownStyle } from '../../card/markdown-style';
import { formatLarkError } from '../../core/api-error';
import { extractLarkApiCode, formatLarkError } from '../../core/api-error';
import { isTerminalMessageApiCode, markMessageUnavailable } from '../../core/message-unavailable';
import { larkLogger } from '../../core/lark-logger';
import { getSentinelStore } from '../inbound/sentinel-store';
import { uploadAndSendMediaLark } from './media';
Expand Down Expand Up @@ -114,6 +115,12 @@ function recordSentinelsForChat(
getSentinelStore(resolvedAccountId).recordSentinels(threadScopedKey(to, threadId), sentinels);
}

/** Read the replyFallbackOnWithdrawn setting from account-scoped channel config. */
function getReplyFallbackMode(cfg: ClawdbotConfig, accountId?: string): 'top-level' | 'silent' {
const scopedCfg = createAccountScopedConfig(cfg, accountId);
return (scopedCfg.channels?.feishu as Record<string, unknown> | undefined)?.replyFallbackOnWithdrawn as 'top-level' | 'silent' ?? 'silent';
}

/**
* Unified IM message sender — handles both reply and create paths for any
* `msg_type`. Replaces the former `replyPostMessage`, `createPostMessage`,
Expand All @@ -126,23 +133,46 @@ async function sendImMessage(params: {
msgType: 'post' | 'interactive';
replyToMessageId?: string;
replyInThread?: boolean;
replyFallbackOnWithdrawn?: 'top-level' | 'silent';
}): Promise<FeishuSendResult> {
const { client, to, content, msgType, replyToMessageId, replyInThread } = params;
const { client, to, content, msgType, replyToMessageId, replyInThread, replyFallbackOnWithdrawn } = params;

// --- Reply path ---
if (replyToMessageId) {
log.info(`replying to message ${replyToMessageId} ` + `(msg_type=${msgType}, thread=${replyInThread ?? false})`);
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: msgType, reply_in_thread: replyInThread },
});
log.info(`replying to message ${replyToMessageId} (msg_type=${msgType}, thread=${replyInThread ?? false})`);
try {
const response = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: msgType, reply_in_thread: replyInThread },
});

const result: FeishuSendResult = {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
log.debug(`reply sent: messageId=${result.messageId}`);
return result;
const result: FeishuSendResult = {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
log.debug(`reply sent: messageId=${result.messageId}`);
return result;
} catch (err) {
// When the reply target has been recalled (230011) or deleted (231003),
// behaviour depends on the replyFallbackOnWithdrawn config:
// 'silent' — silently discard the reply (default)
// 'top-level' — fall back to sending as a new message
// Other errors are propagated as-is.
const apiCode = extractLarkApiCode(err);
if (isTerminalMessageApiCode(apiCode)) {
// Mark in the unavailable cache so subsequent operations on the same
// messageId skip the API call (symmetric with send.ts which uses
// runWithMessageUnavailableGuard).
markMessageUnavailable({ messageId: replyToMessageId, apiCode, operation: `im.message.reply(${msgType})` });
if (replyFallbackOnWithdrawn === 'silent') {
log.warn(`reply target ${replyToMessageId} unavailable, silently discarding (config: replyFallbackOnWithdrawn=silent)`);
return { messageId: '', chatId: '' };
}
log.warn(`reply target ${replyToMessageId} unavailable, falling back to top-level send`);
} else {
throw err;
}
}
}

// --- Create path ---
Expand Down Expand Up @@ -292,8 +322,11 @@ export async function sendTextLark(params: SendTextLarkParams): Promise<FeishuSe
const prepared = await prepareTextForLark(cfg, text, to, accountId);
const content = buildPostContent(prepared.text);

const result = await sendImMessage({ client, to, content, msgType: 'post', replyToMessageId, replyInThread });
recordSentinelsForChat(prepared.resolvedAccountId, to, threadId, prepared.sentinels);
const result = await sendImMessage({ client, to, content, msgType: 'post', replyToMessageId, replyInThread, replyFallbackOnWithdrawn: getReplyFallbackMode(cfg, accountId) });
// Skip sentinel recording when the reply was silently discarded (empty messageId).
if (result.messageId) {
recordSentinelsForChat(prepared.resolvedAccountId, to, threadId, prepared.sentinels);
}
return result;
}

Expand Down Expand Up @@ -372,7 +405,7 @@ export async function sendCardLark(params: SendCardLarkParams): Promise<FeishuSe
const content = JSON.stringify(card);

try {
return await sendImMessage({ client, to, content, msgType: 'interactive', replyToMessageId, replyInThread });
return await sendImMessage({ client, to, content, msgType: 'interactive', replyToMessageId, replyInThread, replyFallbackOnWithdrawn: getReplyFallbackMode(cfg, accountId) });
} catch (err) {
const detail = formatLarkError(err);
log.error(`sendCardLark failed: ${detail}`);
Expand Down
134 changes: 92 additions & 42 deletions src/messaging/outbound/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,22 @@ import { LarkClient } from '../../core/lark-client';
import { larkLogger } from '../../core/lark-logger';
import { threadScopedKey } from '../../channel/chat-queue';
import { normalizeFeishuTarget, normalizeMessageId, resolveReceiveIdType } from '../../core/targets';
import { runWithMessageUnavailableGuard } from '../../core/message-unavailable';
import { isMessageUnavailableError, runWithMessageUnavailableGuard } from '../../core/message-unavailable';
import { optimizeMarkdownStyle } from '../../card/markdown-style';
import { buildMentionedCardContent, buildMentionedMessage } from '../inbound/mention';
import { getSentinelStore } from '../inbound/sentinel-store';
import { type NormalizeContext, type SentinelEntry, normalizeOutboundMentions } from './normalize-mentions';

const sendLog = larkLogger('outbound/send');

type ReplyFallbackMode = 'top-level' | 'silent';

/** Read the replyFallbackOnWithdrawn setting from account-scoped channel config. */
export function getReplyFallbackMode(cfg: ClawdbotConfig, accountId?: string): ReplyFallbackMode {
const scopedCfg = createAccountScopedConfig(cfg, accountId);
return (scopedCfg.channels?.feishu as Record<string, unknown> | undefined)?.replyFallbackOnWithdrawn as ReplyFallbackMode ?? 'silent';
}

/**
* Runs the outbound text through mention normalization. Returns the
* input unchanged on any failure so a parser bug never blocks send.
Expand Down Expand Up @@ -220,29 +228,50 @@ export async function sendMessageFeishu(params: SendFeishuMessageParams): Promis

if (replyToMessageId) {
// Send as a threaded reply.
// 规范化 message_id,处理合成 ID(如 "om_xxx:auth-complete")
const normalizedId = normalizeMessageId(replyToMessageId);
const response = await runWithMessageUnavailableGuard({
messageId: normalizedId,
operation: 'im.message.reply(post)',
fn: () =>
client.im.message.reply({
path: {
message_id: normalizedId!,
},
data: {
content: contentPayload,
msg_type: 'post',
reply_in_thread: replyInThread,
},
}),
});
try {
const response = await runWithMessageUnavailableGuard({
messageId: normalizedId,
operation: 'im.message.reply(post)',
fn: () =>
client.im.message.reply({
path: {
message_id: normalizedId!,
},
data: {
content: contentPayload,
msg_type: 'post',
reply_in_thread: replyInThread,
},
}),
});

recordFeishuSendSentinels(normalizationAccountId, to, threadId, normalizationSentinels);
return {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
recordFeishuSendSentinels(normalizationAccountId, to, threadId, normalizationSentinels);
return {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
} catch (err) {
// When the reply target has been recalled (230011) or deleted (231003),
// behaviour depends on the replyFallbackOnWithdrawn config:
// 'silent' — silently discard the reply (default)
// 'top-level' — fall back to sending as a new message
// Other errors are propagated as-is.
if (isMessageUnavailableError(err)) {
const mode = getReplyFallbackMode(cfg, accountId);
if (mode === 'silent') {
sendLog.warn(
`reply target ${replyToMessageId} unavailable (${err.apiCode}), silently discarding (config: replyFallbackOnWithdrawn=silent)`,
);
return { messageId: '', chatId: '' };
}
sendLog.warn(
`reply target ${replyToMessageId} unavailable (${err.apiCode}), falling back to top-level send`,
);
} else {
throw err;
}
}
}

// Send as a new message.
Expand Down Expand Up @@ -290,28 +319,49 @@ export async function sendCardFeishu(params: SendFeishuCardParams): Promise<Feis
const contentPayload = JSON.stringify(card);

if (replyToMessageId) {
// 规范化 message_id,处理合成 ID(如 "om_xxx:auth-complete")
const normalizedId = normalizeMessageId(replyToMessageId);
const response = await runWithMessageUnavailableGuard({
messageId: normalizedId,
operation: 'im.message.reply(interactive)',
fn: () =>
client.im.message.reply({
path: {
message_id: normalizedId!,
},
data: {
content: contentPayload,
msg_type: 'interactive',
reply_in_thread: replyInThread,
},
}),
});
try {
const response = await runWithMessageUnavailableGuard({
messageId: normalizedId,
operation: 'im.message.reply(interactive)',
fn: () =>
client.im.message.reply({
path: {
message_id: normalizedId!,
},
data: {
content: contentPayload,
msg_type: 'interactive',
reply_in_thread: replyInThread,
},
}),
});

return {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
return {
messageId: response?.data?.message_id ?? '',
chatId: response?.data?.chat_id ?? '',
};
} catch (err) {
// When the reply target has been recalled (230011) or deleted (231003),
// behaviour depends on the replyFallbackOnWithdrawn config:
// 'silent' — silently discard the reply (default)
// 'top-level' — fall back to sending as a new card
// Other errors are propagated as-is.
if (isMessageUnavailableError(err)) {
const mode = getReplyFallbackMode(cfg, accountId);
if (mode === 'silent') {
sendLog.warn(
`reply target ${replyToMessageId} unavailable (${err.apiCode}), silently discarding (config: replyFallbackOnWithdrawn=silent)`,
);
return { messageId: '', chatId: '' };
}
sendLog.warn(
`reply target ${replyToMessageId} unavailable (${err.apiCode}), falling back to top-level card send`,
);
} else {
throw err;
}
}
}

const target = normalizeFeishuTarget(to);
Expand Down
Loading