- ChatThread per client/provider and channel; messages get files, internal notes, source (bot/mini app) and delivery errors; reply templates. Migration moves old concierge messages into threads. - Routing: subscribers write to the concierge, others to support; /support and /concierge in the client bot; staff can take, transfer, close and move threads. - Telegram: photos/documents from clients and providers, replies with the channel header, files sent from storage, notifications only to the assignee or the channel's staff, "Reply" into a specific thread. - New admin: /chats with list, conversation, notes, files, templates, client panel; "Message" button on the client card; dashboard counts threads awaiting reply. - Client mini app: in-app chat screen sharing the same conversation. - Old admin keeps working on the same data (notes hidden there). - Tests for routing, notes, assign/close/move, lists, recipients; separate test DB via TEST_DB_NAME; dev:chats-demo script. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
93 lines
4.5 KiB
TypeScript
93 lines
4.5 KiB
TypeScript
/**
|
|
* Отправка сообщений через Telegram Bot API без зависимостей (воркер).
|
|
* Без токена (локальная разработка) сообщение печатается в консоль.
|
|
*/
|
|
export type InlineButton = { text: string; callback_data: string } | { text: string; web_app: { url: string } } | { text: string; url: string };
|
|
|
|
export interface OutgoingMessage {
|
|
chatId: bigint | number | string;
|
|
text: string;
|
|
buttons?: InlineButton[][];
|
|
}
|
|
|
|
/** Telegram принимает web_app-кнопки только с https — в разработке такие кнопки опускаются */
|
|
export function webAppButton(text: string, url: string): InlineButton | null {
|
|
return url.startsWith('https://') ? { text, web_app: { url } } : null;
|
|
}
|
|
|
|
export function compactRows(rows: (InlineButton | null)[][]): InlineButton[][] {
|
|
return rows.map((r) => r.filter((b): b is InlineButton => !!b)).filter((r) => r.length > 0);
|
|
}
|
|
|
|
export class TelegramSender {
|
|
constructor(
|
|
private token: string,
|
|
private label: string,
|
|
) {}
|
|
|
|
/** false — получатель недоступен (заблокировал бота / бот ему не писал) */
|
|
async send(msg: OutgoingMessage): Promise<boolean> {
|
|
if (!this.token) {
|
|
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
|
|
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
|
|
return true;
|
|
}
|
|
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({
|
|
chat_id: String(msg.chatId),
|
|
text: msg.text,
|
|
parse_mode: 'HTML',
|
|
link_preview_options: { is_disabled: true },
|
|
reply_markup: msg.buttons?.length ? { inline_keyboard: msg.buttons } : undefined,
|
|
}),
|
|
});
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
// 403 — пользователь заблокировал бота, 400 chat not found — бот ему ещё не писал
|
|
// (например, тестовые пользователи seed); повторять бессмысленно
|
|
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
|
|
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
|
|
return false;
|
|
}
|
|
throw new Error(`Telegram ${res.status}: ${body}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Файл с подписью: картинки — как фото (видно сразу), остальное — документом. false — получатель недоступен */
|
|
async sendFile(msg: { chatId: bigint | number | string; caption: string; body: Buffer; fileName: string; mime: string }): Promise<boolean> {
|
|
const asPhoto = /^image\/(jpeg|png|webp)$/.test(msg.mime) && msg.body.length <= 10 * 1024 * 1024;
|
|
if (!this.token) {
|
|
console.log(`[${this.label} → ${msg.chatId}] 📎 ${msg.fileName} (${msg.body.length} B) ${msg.caption.replace(/<[^>]+>/g, '')}`);
|
|
return true;
|
|
}
|
|
const form = new FormData();
|
|
form.set('chat_id', String(msg.chatId));
|
|
form.set('caption', msg.caption.slice(0, 1024));
|
|
form.set('parse_mode', 'HTML');
|
|
form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(msg.body)], { type: msg.mime }), msg.fileName);
|
|
const res = await fetch(`https://api.telegram.org/bot${this.token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { method: 'POST', body: form });
|
|
if (!res.ok) {
|
|
const body = await res.text();
|
|
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) return false;
|
|
throw new Error(`Telegram ${res.status}: ${body}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/** Скачать файл, который прислали боту (до 20 МБ — ограничение Bot API) */
|
|
async download(fileId: string): Promise<Response | null> {
|
|
if (!this.token) return null;
|
|
const info = await fetch(`https://api.telegram.org/bot${this.token}/getFile?file_id=${encodeURIComponent(fileId)}`);
|
|
if (!info.ok) return null;
|
|
const json = (await info.json()) as { ok: boolean; result?: { file_path?: string } };
|
|
if (!json.result?.file_path) return null;
|
|
const file = await fetch(`https://api.telegram.org/file/bot${this.token}/${json.result.file_path}`);
|
|
return file.ok ? file : null;
|
|
}
|
|
}
|
|
|
|
export const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|