blob: ade493b928e53f8f063090667f725a76c57ab765 [file]
import type { BotProvider } from './bot-chat-settings.js';
export type BotPlatform = BotProvider;
export interface BotAttachmentRef {
kind: 'image' | 'file' | 'voice';
url?: string;
fileId?: string;
mimeType?: string;
}
/**
* PR-BOT-NON-TEXT-MESSAGE-ACK-0 (external bot research): the kind of non-text
* payload Telegram delivered alongside (or instead of) text. Used so
* the handler can send a helpful "Maka 现在只读文字" ack instead of
* silently dropping a photo / voice / sticker message. NOT a request
* to ingest the binary — Maka does not yet have multi-modal input.
*
* `unknown` covers Telegram message subtypes we did not enumerate
* (location, contact, poll, video_note, ...). Those still need an ack
* because the user typed nothing the bot can act on.
*/
export type BotAttachmentKind =
| 'photo'
| 'voice'
| 'sticker'
| 'document'
| 'video'
| 'audio'
| 'animation'
| 'unknown';
export interface BotMessageEvent {
platform: BotPlatform;
userId: string;
userName: string;
chatId: string;
isGroup: boolean;
text: string;
sourceMessageId: string;
receivedAt: number;
attachments?: BotAttachmentRef[];
/**
* PR-BOT-NON-TEXT-MESSAGE-ACK-0: when the inbound message carried a
* non-text payload (photo / voice / etc.), this records the kind so
* the handler can decide whether to ack or drop. `undefined` means
* "text-only message" — the default and most common case.
*/
attachmentKind?: BotAttachmentKind;
}
/**
* PR-BOT-NON-TEXT-MESSAGE-ACK-0: fixed copy for the "we only handle
* text" ack. Kind-aware so a voice message and a sticker get slightly
* different copy without diluting the core message. Exported so the
* handler can use it AND a contract test can pin it.
*/
export function nonTextMessageAck(kind: BotAttachmentKind): string {
switch (kind) {
case 'photo':
return 'Maka 目前只能读文字。如果想问关于这张图的问题,请把内容直接写出来(caption 里也可以)。';
case 'voice':
case 'audio':
return 'Maka 目前不能识别语音消息。请把要问的内容用文字发过来。';
case 'sticker':
return 'Maka 目前不会处理贴纸。如果有问题,请直接用文字描述。';
case 'video':
case 'animation':
return 'Maka 目前不会处理视频。如果想讨论视频内容,请把要点用文字写一下。';
case 'document':
return 'Maka 目前不能直接读取附件文件。如果文件里有问题,请把内容粘到消息里。';
case 'unknown':
default:
return 'Maka 目前只能处理文字消息。请把要问的内容用文字发过来。';
}
}
export function botDisplayLabel(platform: BotPlatform): string {
switch (platform) {
case 'telegram':
return 'Telegram';
case 'feishu':
return '飞书';
case 'wecom':
return '企业微信';
case 'wechat':
return '微信';
case 'discord':
return 'Discord';
case 'dingtalk':
return '钉钉';
case 'qq':
return 'QQ';
case 'slack':
return 'Slack';
}
}
export function botConversationKey(message: Pick<BotMessageEvent, 'platform' | 'chatId'>): string {
return `${message.platform}:${message.chatId}`;
}
/**
* PR-BOT-INCOMING-IDEMPOTENCY-0 (external bot research): platform bridges
* can redeliver the same inbound message during reconnect / polling
* recovery. The runtime needs a stable event key before creating a
* Maka turn or sending a transient ack, otherwise a repeated platform
* update can produce duplicate agent replies.
*
* Scope deliberately stays at platform + chat + source message id. The
* id is only trusted as an idempotency key inside that chat; it is NOT
* a permission token and does not grant access to message history.
*/
export function botSourceEventKey(
message: Pick<BotMessageEvent, 'platform' | 'chatId' | 'sourceMessageId'>,
): string | undefined {
const sourceMessageId = message.sourceMessageId.trim();
if (!sourceMessageId) return undefined;
return `${message.platform}:${message.chatId}:${sourceMessageId}`;
}
/**
* PR-BOT-PLAINTEXT-RESET-COMMAND-0 (external bot research): DM-only plain-text
* "restart this conversation" affordance. Maka has no slash command
* runtime; users on mobile cannot easily type `/restart` either, so we
* coerce a handful of natural phrases into a reset action.
*
* Why DM-only: the bot conversation key is `${platform}:${chatId}`, NOT
* keyed by userId. In a group chat any member typing "restart" would
* wipe the conversation everyone else is in. Until a userId-scoped key
* lands, plain-text reset is silently ignored in groups so the cost of
* a misfire stays bounded to the sender's own DM.
*
* Match policy: NFC-normalize + lowercase + trim, then exact membership.
* No substring matching — the word "restart" inside a sentence is NOT
* a reset request; matching only the bare command avoids surprising
* users who intended to send a message ABOUT restart.
*/
export const BOT_PLAINTEXT_RESET_COMMANDS: ReadonlyArray<string> = Object.freeze([
'restart',
'reset',
'/restart',
'/reset',
'/new',
'/newchat',
'new chat',
'重启',
'重置',
'重新开始',
'新对话',
'新会话',
]);
export function isPlaintextResetCommand(
message: Pick<BotMessageEvent, 'text' | 'isGroup'>,
): boolean {
if (message.isGroup) return false;
const trimmed = message.text.normalize('NFC').trim().toLowerCase();
if (trimmed.length === 0) return false;
return BOT_PLAINTEXT_RESET_COMMANDS.includes(trimmed);
}
/**
* PR-BOT-PLAINTEXT-HELP-COMMAND-0 (external bot research): DM-only help
* affordance so a new user can discover what the bot supports
* without leaving Telegram. Same match policy as
* {@link isPlaintextResetCommand} — DM-only, NFC + lowercase + trim,
* exact membership, no substring match.
*
* The fixed reply text is deliberately short and product-scoped:
* how to chat, how to reset, and the threading behavior. No
* marketing copy or roadmap language.
*/
export const BOT_PLAINTEXT_HELP_COMMANDS: ReadonlyArray<string> = Object.freeze([
'help',
'/help',
'?',
'/?',
'帮助',
'/帮助',
]);
export function isPlaintextHelpCommand(
message: Pick<BotMessageEvent, 'text' | 'isGroup'>,
): boolean {
if (message.isGroup) return false;
const trimmed = message.text.normalize('NFC').trim().toLowerCase();
if (trimmed.length === 0) return false;
return BOT_PLAINTEXT_HELP_COMMANDS.includes(trimmed);
}
export function plaintextHelpReply(): string {
return [
'Maka 机器人帮助',
'',
'· 直接发文字消息就能和 Maka 对话;回复会挂在你的提问下面。',
'· 想清空当前对话开新会话,发:restart / reset / 重置 / 重启 / 新对话。',
'· 群里不响应 plaintext 重置指令(避免一个成员把整群对话清掉)。',
'· 长回复会自动拆成多条,第一条挂在你的提问下面。',
].join('\n');
}
export function formatBotMessageForSession(
message: Pick<BotMessageEvent, 'platform' | 'userName' | 'text'>,
): string {
return `[${botDisplayLabel(message.platform)}:${sanitizeBotUserName(message.userName)}] ${message.text.trim()}`;
}
function sanitizeBotUserName(value: string): string {
return (
value
.replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ')
.replace(/[\p{Cf}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim() || 'unknown'
);
}
/**
* PR-BOT-LASTERROR-FROM-SEND-0 (external bot research): translate the bridge's
* machine-readable `BotStatus.reason` into a short user-readable string
* suitable for persistence in `BotChannelSettings.lastError`. The Settings
* page reads `lastError` from persisted settings (not live status), so
* without this persistence step the user sees stale connection-test
* errors instead of the actual send-path failure that happened minutes
* ago.
*
* Returns `undefined` for non-error reasons (disabled/stopped/missing
* credentials — those have their own UI surface) and for unrecognized
* inputs whose pass-through risks leaking unredacted payloads.
*
* Length-capped at 200 chars defensively; a real Telegram error
* description is typically well under 80 chars.
*/
const BOT_REASON_HUMANIZE: Record<string, string | undefined> = {
'rate-limited': '发送被节流(429);上一条回复可能截断,可以请用户再发一次',
'polling-timeout': '事件轮询超时;可能是网络抖动或代理失效',
'send-failed': '上一次发送失败,详细原因 Telegram 没有返回',
'get-me-failed': '凭据探测失败;请检查 Bot Token',
// Non-error states surface elsewhere in the UI — return undefined so
// we do not overwrite a real lastError with a benign status change.
disabled: undefined,
stopped: undefined,
'no-token': undefined,
'missing-feishu-credentials': undefined,
'feishu-domain-required': undefined,
'feishu-events-not-connected': undefined,
'scaffold-only': undefined,
unimplemented: undefined,
};
/**
* PR-BOT-RUNTIME-REASON-HUMANIZE-0: Discord / DingTalk / QQ bridges
* emit parameterized reason strings like `gateway-closed-4004` and
* `connections-open-500`. Without these patterns the user sees the
* raw machine code in `lastError`; with them they get a translated
* description plus the diagnostic code preserved in parentheses.
*
* Each entry is a regex with one numeric capture group; the matched
* code is preserved verbatim so support diagnostics still survive.
*/
const BOT_REASON_HUMANIZE_PATTERNS: Array<{ pattern: RegExp; format: (code: string) => string }> = [
{ pattern: /^gateway-bot-(\d+)$/, format: (code) => `获取 Gateway 失败(HTTP ${code})` },
{ pattern: /^gateway-closed-(\d+)$/, format: (code) => `Gateway 连接关闭(${code});正在重连` },
{ pattern: /^connections-open-(\d+)$/, format: (code) => `Stream 订阅打开失败(HTTP ${code})` },
{ pattern: /^stream-closed-(\d+)$/, format: (code) => `Stream 连接关闭(${code});正在重连` },
{ pattern: /^send-failed-(\d+)$/, format: (code) => `发送失败(HTTP ${code})` },
{
pattern: /^getAppAccessToken-(\d+)$/,
format: (code) => `获取 access_token 失败(HTTP ${code})`,
},
];
export function humanizeBotStatusReason(reason: string | undefined): string | undefined {
if (typeof reason !== 'string' || reason.length === 0) return undefined;
if (reason in BOT_REASON_HUMANIZE) {
return BOT_REASON_HUMANIZE[reason];
}
for (const { pattern, format } of BOT_REASON_HUMANIZE_PATTERNS) {
const match = pattern.exec(reason);
if (match) return format(match[1]);
}
// Pass-through for platform-supplied descriptions ("Bad Request:
// chat not found", etc.). Trim + length-cap to keep `lastError`
// bounded.
const trimmed = reason.trim();
if (trimmed.length === 0) return undefined;
return trimmed.length > 200 ? trimmed.slice(0, 200) : trimmed;
}