/**
* pi-feishu-notify — 通知内容构建(纯函数,可单测)
*
* 把任务完成通知构造成飞书 post 可渲染的 markdown:
* - 默认 markdown 格式(标题/加粗/引用/代码块)
* - 可回退到纯文本(messageFormat: 'text')
*/
import type { FeishuNotifyConfig } from './types.js';
import { resolveLocale, messages, type Locale } from './i18n.js';
/** 从 assistant 消息的 content 数组里提取纯文本(跳过 thinking / toolCall)。 */
export function extractAssistantText(content: unknown): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
const parts: string[] = [];
for (const part of content) {
if (part && typeof part === 'object' && (part as { type?: string }).type === 'text') {
const text = (part as { text?: unknown }).text;
if (typeof text === 'string') parts.push(text);
}
}
return stripThinkingMarkers(parts.join('\n').trim());
}
/**
* 防御性清理:某些 provider/配置下思考内容会以文本形式混进 text 增量
* (如 requiresThinkingAsText 把 thinking 转成 text 块,或 qwen 把思考包在
* `~~...~~` 里下发)。这里只清理几种明确的思考标记,避免误伤正常 markdown:
* - `...`:
* - 行首的 `~~...~~`(qwen 思考段,一般位于回答最前面)
* - ````...```` 代码块包裹的思考
*/
export function stripThinkingMarkers(text: string): string {
if (!text) return text;
let out = text;
// `...`(含多行)
out = out.replace(/<\s*thinking\s*>[\s\S]*?<\/\s*thinking\s*>/gi, '');
// qwen 行首 `~~...~~` 思考段(可能跨多行,直到不再以 ~~ 续行)
out = out.replace(/^~{2}[\s\S]*?~{2}\s*/m, '');
return out.trim();
}
export interface NotificationMeta {
project: string;
sid: string;
time: string;
}
/** 拼接任务完成通知(markdown 版)。 */
export function buildNotificationMarkdown(
meta: NotificationMeta,
summary?: string,
locale: Locale = 'en',
): string {
const m = messages(locale);
const lines = [
`## ✅ ${m.notification.title}`,
'',
`**${m.notification.project}**:${meta.project}`,
`**${m.notification.session}**:${meta.sid.slice(0, 8)}`,
`**${m.notification.time}**:${meta.time}`,
];
if (summary) {
lines.push('', '---', '', summary);
}
lines.push('', `> ${m.notification.replyHint}`);
return lines.join('\n');
}
/** 拼接任务完成通知(纯文本版,兼容旧行为)。 */
export function buildNotificationText(
meta: NotificationMeta,
summary?: string,
locale: Locale = 'en',
): string {
const m = messages(locale);
const lines = [
`✅ ${m.notification.title}`,
`${m.notification.project}: ${meta.project}`,
`${m.notification.session}: ${meta.sid.slice(0, 8)}`,
`${m.notification.time}: ${meta.time}`,
];
if (summary) lines.push('', summary);
lines.push('', `${m.notification.replyHint}`);
return lines.join('\n');
}
/**
* 按配置选格式构建通知内容。
* messageFormat 缺省视为 'markdown'(默认 markdown 美化);
* locale 缺省视为 auto(按 LANG 环境变量判断)。
*/
export function buildNotification(
cfg: FeishuNotifyConfig,
meta: NotificationMeta,
summary?: string,
): { format: 'markdown' | 'text'; content: string } {
const locale = resolveLocale(cfg.locale);
if (cfg.messageFormat === 'text') {
return { format: 'text', content: buildNotificationText(meta, summary, locale) };
}
return { format: 'markdown', content: buildNotificationMarkdown(meta, summary, locale) };
}
/**
* 从飞书回复消息里提取要回注的文本。
*
* SDK 已把 post 消息转成纯文本(convertPost),text 消息 content 是纯文本;
* 这里统一取 content.trim(),并对「JSON 外壳」做兜底(如 text 消息的
* {"text":"..."} 形式),保证 post/text 两类回复都能拿到干净文本。
*/
export function extractReplyText(content: string, rawContentType?: string): string {
const trimmed = content.trim();
// post 类型:SDK 已转纯文本,直接返回
if (rawContentType === 'post') return trimmed;
// text 类型:可能是 {"text":"..."} 外壳,也可能是纯文本
if (trimmed.startsWith('{')) {
try {
const parsed = JSON.parse(trimmed) as { text?: unknown };
if (typeof parsed.text === 'string') return parsed.text.trim();
} catch {
// fallthrough → 原样返回
}
}
return trimmed;
}