Backoffice stage 2: chats (support + concierge, files)
- 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>
This commit is contained in:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4176
-144
No files matched your search
@@ -25,11 +25,12 @@ export class TelegramSender {
|
||||
private label: string,
|
||||
) {}
|
||||
|
||||
async send(msg: OutgoingMessage): Promise<void> {
|
||||
/** 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;
|
||||
return true;
|
||||
}
|
||||
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
@@ -48,10 +49,43 @@ export class TelegramSender {
|
||||
// (например, тестовые пользователи seed); повторять бессмысленно
|
||||
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
|
||||
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
|
||||
return;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user