@chatman-media/conversation-engine 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -693,8 +693,8 @@ class StylesRepo {
693
693
  }
694
694
  }
695
695
  // src/dal/notifications.ts
696
- import { eq as eq10, and as and10, sql as sql7 } from "drizzle-orm";
697
- import { notificationRules, operatorSettings, notificationTemplates, notificationGroupTokens } from "@chatman-media/storage";
696
+ import { and as and10, desc as desc3, eq as eq10, gte, inArray, isNull as isNull2, sql as sql7 } from "drizzle-orm";
697
+ import { admins, adminNotifications, notificationRules, operatorSettings, notificationTemplates, notificationGroupTokens } from "@chatman-media/storage";
698
698
 
699
699
  class NotificationsRepo {
700
700
  db;
@@ -812,6 +812,54 @@ class NotificationsRepo {
812
812
  async deleteGroupLinkToken(token) {
813
813
  await this.db.delete(notificationGroupTokens).where(eq10(notificationGroupTokens.token, token));
814
814
  }
815
+ async resolveOwnerSettings(tenantId) {
816
+ const [owner] = await this.db.select({ adminId: admins.id, email: admins.email }).from(admins).where(and10(eq10(admins.tenantId, tenantId), eq10(admins.role, "superadmin"))).orderBy(admins.id).limit(1);
817
+ if (!owner)
818
+ return null;
819
+ const settings = await this.findOperatorSettings(owner.adminId);
820
+ return { adminId: owner.adminId, email: owner.email, settings };
821
+ }
822
+ async findOperatorSettingsByChatId(chatId) {
823
+ const rows = await this.db.select().from(operatorSettings).where(eq10(operatorSettings.telegramChatId, chatId)).limit(1);
824
+ return rows[0];
825
+ }
826
+ async updateInformerPrefs(adminId, prefs, tenantId) {
827
+ if (Object.keys(prefs).length === 0)
828
+ return;
829
+ const now = Math.floor(Date.now() / 1000);
830
+ if (tenantId !== undefined) {
831
+ await this.db.insert(operatorSettings).values({ adminId, tenantId, ...prefs }).onConflictDoUpdate({
832
+ target: [operatorSettings.adminId],
833
+ set: { ...prefs, updatedAt: now }
834
+ });
835
+ return;
836
+ }
837
+ await this.db.update(operatorSettings).set({ ...prefs, updatedAt: now }).where(eq10(operatorSettings.adminId, adminId));
838
+ }
839
+ async insertAdminNotification(row) {
840
+ const [ins] = await this.db.insert(adminNotifications).values(row).returning({ id: adminNotifications.id });
841
+ if (!ins)
842
+ throw new Error("admin_notifications insert returned no row");
843
+ return ins.id;
844
+ }
845
+ async markNotificationDelivered(id, deliveredAt) {
846
+ await this.db.update(adminNotifications).set({ deliveredAt }).where(eq10(adminNotifications.id, id));
847
+ }
848
+ async findRecentByDedup(tenantId, dedupKey, sinceEpoch) {
849
+ const rows = await this.db.select().from(adminNotifications).where(and10(eq10(adminNotifications.tenantId, tenantId), eq10(adminNotifications.dedupKey, dedupKey), gte(adminNotifications.createdAt, sinceEpoch))).orderBy(desc3(adminNotifications.createdAt)).limit(1);
850
+ return rows[0];
851
+ }
852
+ async listRecentNotifications(tenantId, adminId, limit = 10) {
853
+ return this.db.select().from(adminNotifications).where(and10(eq10(adminNotifications.tenantId, tenantId), eq10(adminNotifications.adminId, adminId))).orderBy(desc3(adminNotifications.createdAt)).limit(limit);
854
+ }
855
+ async listPendingDigest(tenantId, adminId) {
856
+ return this.db.select().from(adminNotifications).where(and10(eq10(adminNotifications.tenantId, tenantId), eq10(adminNotifications.adminId, adminId), isNull2(adminNotifications.deliveredAt), isNull2(adminNotifications.digestBatchId))).orderBy(adminNotifications.createdAt);
857
+ }
858
+ async markDigested(ids, batchId) {
859
+ if (ids.length === 0)
860
+ return;
861
+ await this.db.update(adminNotifications).set({ digestBatchId: batchId }).where(inArray(adminNotifications.id, ids));
862
+ }
815
863
  }
816
864
  // src/notifications.ts
817
865
  import { TelegramClient } from "@chatman-media/channel-telegram";
@@ -820,16 +868,22 @@ class NotificationService {
820
868
  repo;
821
869
  botToken;
822
870
  appUrl;
871
+ informer;
823
872
  client = null;
824
- constructor(repo, botToken, appUrl) {
873
+ constructor(repo, botToken, appUrl, informer) {
825
874
  this.repo = repo;
826
875
  this.botToken = botToken;
827
876
  this.appUrl = appUrl;
877
+ this.informer = informer;
828
878
  if (botToken) {
829
879
  this.client = new TelegramClient({ token: botToken });
830
880
  }
831
881
  }
832
882
  async notify(event) {
883
+ const ownerAdminId = this.informer ? await this.informer.resolveOwnerAdminId(event.tenantId) : null;
884
+ if (this.informer) {
885
+ await this.informer.emitNotificationEvent(event);
886
+ }
833
887
  if (!this.client)
834
888
  return;
835
889
  const [rules, operatorSettingsList] = await Promise.all([
@@ -850,6 +904,8 @@ class NotificationService {
850
904
  for (const settings of operatorSettingsList) {
851
905
  if (!settings.telegramChatId)
852
906
  continue;
907
+ if (ownerAdminId !== null && settings.adminId === ownerAdminId)
908
+ continue;
853
909
  if (settings.notifyOnAssignedOnly && event.assignedAdminId !== undefined && event.assignedAdminId !== settings.adminId) {
854
910
  continue;
855
911
  }
@@ -979,8 +1035,439 @@ class NotificationService {
979
1035
  return map[key] ?? key;
980
1036
  }
981
1037
  }
982
- // src/operator-bot-handler.ts
1038
+ // src/ops-alerts.ts
983
1039
  import { TelegramClient as TelegramClient2 } from "@chatman-media/channel-telegram";
1040
+ import { admins as admins2, operatorSettings as operatorSettings2, tenants } from "@chatman-media/storage";
1041
+ import { and as and11, eq as eq11, isNotNull } from "drizzle-orm";
1042
+
1043
+ // src/with-tenant.ts
1044
+ import { sql as sql8 } from "drizzle-orm";
1045
+ async function withTenant(db, tenantId, fn) {
1046
+ return db.transaction(async (tx) => {
1047
+ await tx.execute(sql8.raw(`SET LOCAL app.tenant_id = ${tenantId}`));
1048
+ return fn(tx);
1049
+ });
1050
+ }
1051
+
1052
+ // src/ops-alerts.ts
1053
+ var SEVERITY_EMOJI = {
1054
+ critical: "\uD83D\uDD34",
1055
+ warning: "\uD83D\uDFE1",
1056
+ info: "ℹ️"
1057
+ };
1058
+ function escapeHtml(v) {
1059
+ return v.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1060
+ }
1061
+ async function resolveOwnerContacts(db, tenantId) {
1062
+ return withTenant(db, tenantId, async (tx) => {
1063
+ const [owner] = await tx.select({ email: admins2.email, slug: tenants.slug }).from(admins2).innerJoin(tenants, eq11(tenants.id, admins2.tenantId)).where(and11(eq11(admins2.tenantId, tenantId), eq11(admins2.role, "superadmin"))).limit(1);
1064
+ const chats = await tx.select({ chatId: operatorSettings2.telegramChatId }).from(operatorSettings2).where(and11(eq11(operatorSettings2.tenantId, tenantId), isNotNull(operatorSettings2.telegramChatId)));
1065
+ return {
1066
+ email: owner?.email ?? null,
1067
+ slug: owner?.slug ?? null,
1068
+ telegramChatIds: chats.map((c) => c.chatId).filter((x) => !!x)
1069
+ };
1070
+ });
1071
+ }
1072
+
1073
+ class OpsAlertRouter {
1074
+ deps;
1075
+ telegram;
1076
+ cooldownMs;
1077
+ lastAt = new Map;
1078
+ constructor(deps) {
1079
+ this.deps = deps;
1080
+ if (deps.telegram !== undefined) {
1081
+ this.telegram = deps.telegram;
1082
+ } else if (deps.botToken) {
1083
+ const client = new TelegramClient2({ token: deps.botToken });
1084
+ this.telegram = {
1085
+ send: (chatId, htmlText) => client.sendMessage({ chatId, text: htmlText, parseMode: "HTML" }).then(() => {
1086
+ return;
1087
+ })
1088
+ };
1089
+ } else {
1090
+ this.telegram = null;
1091
+ }
1092
+ this.cooldownMs = deps.cooldownMs ?? 30 * 60000;
1093
+ }
1094
+ suppressed(alert, nowMs) {
1095
+ const key = `${alert.tenantId}:${alert.dedupKey}`;
1096
+ const last = this.lastAt.get(key);
1097
+ if (last !== undefined && nowMs - last < this.cooldownMs)
1098
+ return true;
1099
+ this.lastAt.set(key, nowMs);
1100
+ return false;
1101
+ }
1102
+ async emit(alert) {
1103
+ if (this.deps.informer) {
1104
+ await this.deps.informer.emitOpsAlert(alert);
1105
+ return;
1106
+ }
1107
+ if (this.suppressed(alert, Date.now()))
1108
+ return;
1109
+ const owner = await resolveOwnerContacts(this.deps.db, alert.tenantId).catch((err) => {
1110
+ this.deps.log?.warn?.("[ops-alerts] owner resolve failed", { tenantId: alert.tenantId, err: String(err) });
1111
+ return null;
1112
+ });
1113
+ if (!owner)
1114
+ return;
1115
+ let telegramDelivered = false;
1116
+ if (this.telegram && owner.telegramChatIds.length > 0) {
1117
+ const text = `${SEVERITY_EMOJI[alert.severity]} <b>${escapeHtml(alert.title)}</b>
1118
+
1119
+ ` + `${escapeHtml(alert.detail)}`;
1120
+ for (const chatId of owner.telegramChatIds) {
1121
+ try {
1122
+ await this.telegram.send(chatId, text);
1123
+ telegramDelivered = true;
1124
+ } catch (err) {
1125
+ this.deps.log?.warn?.("[ops-alerts] telegram send failed", { chatId, err: String(err) });
1126
+ }
1127
+ }
1128
+ }
1129
+ const emailNeeded = alert.severity === "critical" || !telegramDelivered && alert.severity !== "info";
1130
+ let emailDelivered = false;
1131
+ if (emailNeeded && this.deps.email && owner.email) {
1132
+ try {
1133
+ await this.deps.email.send({
1134
+ to: owner.email,
1135
+ subject: `[${alert.severity}] ${alert.title} — обменник`,
1136
+ html: renderOpsEmailHtml(alert, this.deps.appUrl)
1137
+ });
1138
+ emailDelivered = true;
1139
+ } catch (err) {
1140
+ this.deps.log?.warn?.("[ops-alerts] email send failed", { to: owner.email, err: String(err) });
1141
+ }
1142
+ }
1143
+ this.deps.log?.info?.("[ops-alerts] routed", {
1144
+ tenantId: alert.tenantId,
1145
+ kind: alert.kind,
1146
+ severity: alert.severity,
1147
+ telegramDelivered,
1148
+ emailDelivered
1149
+ });
1150
+ }
1151
+ }
1152
+ function renderOpsEmailHtml(alert, appUrl) {
1153
+ const color = alert.severity === "critical" ? "#dc2626" : alert.severity === "warning" ? "#d97706" : "#2563eb";
1154
+ return `<!DOCTYPE html><html lang="ru"><body style="margin:0;background:#f4f4f5;font-family:-apple-system,Segoe UI,sans-serif">
1155
+ <div style="max-width:560px;margin:32px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)">
1156
+ <div style="background:${color};padding:20px 28px;color:#fff;font-size:18px;font-weight:700">${SEVERITY_EMOJI[alert.severity]} ${escapeHtml(alert.title)}</div>
1157
+ <div style="padding:24px 28px;color:#18181b;font-size:15px;line-height:1.6">
1158
+ <p style="margin:0 0 16px">${escapeHtml(alert.detail)}</p>
1159
+ <a href="${appUrl}" style="display:inline-block;padding:10px 20px;background:${color};color:#fff;border-radius:8px;font-weight:600;text-decoration:none">Открыть админку →</a>
1160
+ </div>
1161
+ <div style="padding:16px 28px;background:#fafafa;border-top:1px solid #e4e4e7;font-size:12px;color:#a1a1aa">
1162
+ lead-engine · операционный алерт обменника · severity: ${alert.severity}
1163
+ </div>
1164
+ </div></body></html>`;
1165
+ }
1166
+
1167
+ class ResendEmailSender {
1168
+ apiKey;
1169
+ from;
1170
+ constructor(apiKey, from) {
1171
+ this.apiKey = apiKey;
1172
+ this.from = from;
1173
+ }
1174
+ async send(opts) {
1175
+ if (!this.apiKey) {
1176
+ console.log(`[resend] dry-run (no key) → to=${opts.to} subject="${opts.subject}"`);
1177
+ return;
1178
+ }
1179
+ const res = await fetch("https://api.resend.com/emails", {
1180
+ method: "POST",
1181
+ headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
1182
+ body: JSON.stringify({ from: this.from, to: [opts.to], subject: opts.subject, html: opts.html })
1183
+ });
1184
+ if (!res.ok) {
1185
+ throw new Error(`Resend error ${res.status}: ${await res.text().catch(() => "")}`);
1186
+ }
1187
+ }
1188
+ }
1189
+ // src/admin-informer.ts
1190
+ import { TelegramClient as TelegramClient3 } from "@chatman-media/channel-telegram";
1191
+ var INFORMER_TOPICS = ["leads", "escalation", "orders", "system"];
1192
+ var SEV_RANK = { info: 1, important: 2, critical: 3 };
1193
+ var LEVEL_THRESHOLD = {
1194
+ silent: 4,
1195
+ critical: 3,
1196
+ important: 2,
1197
+ all: 1
1198
+ };
1199
+ function passesThreshold(severity, level) {
1200
+ return SEV_RANK[severity] >= LEVEL_THRESHOLD[level];
1201
+ }
1202
+ function isMuted(settings, nowEpoch) {
1203
+ const until = settings?.informerMutedUntil;
1204
+ return typeof until === "number" && until > nowEpoch;
1205
+ }
1206
+ function topicEnabled(settings, topic) {
1207
+ const raw = settings?.informerTopics;
1208
+ if (!raw)
1209
+ return true;
1210
+ try {
1211
+ const map = JSON.parse(raw);
1212
+ return map[topic] !== false;
1213
+ } catch {
1214
+ return true;
1215
+ }
1216
+ }
1217
+ var OPS_TOPIC = {
1218
+ rate_anomaly: "system",
1219
+ rate_feed_stale: "system",
1220
+ channel_down: "system",
1221
+ order_stuck: "orders",
1222
+ volume_spike: "orders"
1223
+ };
1224
+ function mapOpsSeverity(s) {
1225
+ return s === "warning" ? "important" : s;
1226
+ }
1227
+ function opsAlertToInformer(alert) {
1228
+ return {
1229
+ tenantId: alert.tenantId,
1230
+ topic: OPS_TOPIC[alert.kind] ?? "system",
1231
+ severity: mapOpsSeverity(alert.severity),
1232
+ kind: alert.kind,
1233
+ title: alert.title,
1234
+ detail: alert.detail,
1235
+ dedupKey: alert.dedupKey
1236
+ };
1237
+ }
1238
+ var NOTIF_MAP = {
1239
+ lead_intake_complete: { topic: "leads", severity: "info", title: "Новый лид" },
1240
+ stage_changed: { topic: "leads", severity: "info", title: "Смена стадии" },
1241
+ lead_stale: { topic: "leads", severity: "info", title: "Лид завис" },
1242
+ human_takeover: { topic: "escalation", severity: "important", title: "Нужна помощь оператора" },
1243
+ verification_requested: { topic: "escalation", severity: "important", title: "Видео-верификация" },
1244
+ document_uploaded: { topic: "escalation", severity: "info", title: "Загружен документ" },
1245
+ high_value_deal: { topic: "orders", severity: "important", title: "Крупная сделка" }
1246
+ };
1247
+ function notificationEventToInformer(event, appUrl) {
1248
+ const meta = NOTIF_MAP[event.eventType];
1249
+ if (!meta)
1250
+ return null;
1251
+ const parts = [];
1252
+ const who = event.data.displayName;
1253
+ if (typeof who === "string" && who)
1254
+ parts.push(`Клиент: ${who}`);
1255
+ if (event.data.fromStage && event.data.toStage) {
1256
+ parts.push(`${event.data.fromStage} → ${event.data.toStage}`);
1257
+ } else if (event.data.toStage) {
1258
+ parts.push(`Стадия: ${event.data.toStage}`);
1259
+ }
1260
+ if (event.data.amount !== undefined && event.data.amount !== null) {
1261
+ parts.push(`Сумма: ${event.data.amount}`);
1262
+ }
1263
+ const ref = event.leadId ?? event.conversationId ?? event.contactId ?? 0;
1264
+ const url = event.leadId && appUrl ? `${appUrl}/leads/${event.leadId}` : event.conversationId && appUrl ? `${appUrl}/conversations/${event.conversationId}` : undefined;
1265
+ return {
1266
+ tenantId: event.tenantId,
1267
+ topic: meta.topic,
1268
+ severity: meta.severity,
1269
+ kind: event.eventType,
1270
+ title: meta.title,
1271
+ detail: parts.join(" · "),
1272
+ dedupKey: `${event.eventType}:${ref}`,
1273
+ url
1274
+ };
1275
+ }
1276
+ var SEV_EMOJI = {
1277
+ critical: "\uD83D\uDD34",
1278
+ important: "\uD83D\uDFE1",
1279
+ info: "ℹ️"
1280
+ };
1281
+ var TOPIC_EMOJI = {
1282
+ leads: "\uD83C\uDD95",
1283
+ escalation: "\uD83C\uDD98",
1284
+ orders: "\uD83D\uDCB1",
1285
+ system: "\uD83D\uDEE0"
1286
+ };
1287
+ function escapeHtml2(v) {
1288
+ return v.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1289
+ }
1290
+
1291
+ class AdminInformer {
1292
+ deps;
1293
+ telegram;
1294
+ cooldownSec;
1295
+ repo;
1296
+ constructor(deps) {
1297
+ this.deps = deps;
1298
+ if (deps.telegram !== undefined) {
1299
+ this.telegram = deps.telegram;
1300
+ } else if (deps.botToken) {
1301
+ const client = new TelegramClient3({ token: deps.botToken });
1302
+ this.telegram = {
1303
+ send: (chatId, htmlText) => client.sendMessage({ chatId, text: htmlText, parseMode: "HTML", disableWebPagePreview: true }).then(() => {
1304
+ return;
1305
+ })
1306
+ };
1307
+ } else {
1308
+ this.telegram = null;
1309
+ }
1310
+ this.cooldownSec = deps.cooldownSec ?? 30 * 60;
1311
+ this.repo = new NotificationsRepo(deps.db);
1312
+ }
1313
+ async resolveOwnerAdminId(tenantId) {
1314
+ return withTenant(this.deps.db, tenantId, async (tx) => {
1315
+ const repo = new NotificationsRepo(tx);
1316
+ const owner = await repo.resolveOwnerSettings(tenantId);
1317
+ return owner?.adminId ?? null;
1318
+ }).catch(() => null);
1319
+ }
1320
+ async emitOpsAlert(alert) {
1321
+ await this.emit(opsAlertToInformer(alert));
1322
+ }
1323
+ async emitNotificationEvent(event) {
1324
+ const ev = notificationEventToInformer(event, this.deps.appUrl);
1325
+ if (ev)
1326
+ await this.emit(ev);
1327
+ }
1328
+ async emit(event) {
1329
+ const now = Math.floor(Date.now() / 1000);
1330
+ const plan = await withTenant(this.deps.db, event.tenantId, async (tx) => {
1331
+ const repo = new NotificationsRepo(tx);
1332
+ const owner = await repo.resolveOwnerSettings(event.tenantId);
1333
+ if (!owner)
1334
+ return null;
1335
+ if (!topicEnabled(owner.settings, event.topic))
1336
+ return null;
1337
+ const chatId = owner.settings?.telegramChatId ?? null;
1338
+ if (!chatId && event.severity !== "critical")
1339
+ return null;
1340
+ const recent = await repo.findRecentByDedup(event.tenantId, event.dedupKey, now - this.cooldownSec);
1341
+ if (recent)
1342
+ return null;
1343
+ const level = owner.settings?.informerLevel ?? "important";
1344
+ const realtime = !isMuted(owner.settings, now) && passesThreshold(event.severity, level);
1345
+ const rowId = await repo.insertAdminNotification({
1346
+ tenantId: event.tenantId,
1347
+ adminId: owner.adminId,
1348
+ topic: event.topic,
1349
+ severity: event.severity,
1350
+ kind: event.kind,
1351
+ title: event.title,
1352
+ body: event.detail,
1353
+ dedupKey: event.dedupKey,
1354
+ targetChatId: chatId
1355
+ });
1356
+ return { rowId, chatId, email: owner.email, realtime };
1357
+ }).catch((err) => {
1358
+ this.deps.log?.warn?.("[informer] resolve/insert failed", {
1359
+ tenantId: event.tenantId,
1360
+ err: String(err)
1361
+ });
1362
+ return null;
1363
+ });
1364
+ if (!plan)
1365
+ return;
1366
+ let delivered = false;
1367
+ if (plan.realtime && this.telegram && plan.chatId) {
1368
+ try {
1369
+ await this.telegram.send(plan.chatId, this.renderHtml(event));
1370
+ delivered = true;
1371
+ } catch (err) {
1372
+ this.deps.log?.warn?.("[informer] telegram send failed", {
1373
+ chatId: plan.chatId,
1374
+ err: String(err)
1375
+ });
1376
+ }
1377
+ }
1378
+ if (event.severity === "critical" && this.deps.email && plan.email) {
1379
+ try {
1380
+ await this.deps.email.send({
1381
+ to: plan.email,
1382
+ subject: `[critical] ${event.title}`,
1383
+ html: this.renderEmailHtml(event)
1384
+ });
1385
+ delivered = true;
1386
+ } catch (err) {
1387
+ this.deps.log?.warn?.("[informer] email send failed", { to: plan.email, err: String(err) });
1388
+ }
1389
+ }
1390
+ if (delivered) {
1391
+ await this.repo.markNotificationDelivered(plan.rowId, now).catch((err) => this.deps.log?.warn?.("[informer] mark delivered failed", {
1392
+ rowId: plan.rowId,
1393
+ err: String(err)
1394
+ }));
1395
+ }
1396
+ this.deps.log?.info?.("[informer] routed", {
1397
+ tenantId: event.tenantId,
1398
+ kind: event.kind,
1399
+ topic: event.topic,
1400
+ severity: event.severity,
1401
+ realtime: plan.realtime,
1402
+ delivered
1403
+ });
1404
+ }
1405
+ renderHtml(event) {
1406
+ let msg = `${SEV_EMOJI[event.severity]} ${TOPIC_EMOJI[event.topic]} <b>${escapeHtml2(event.title)}</b>`;
1407
+ if (event.detail)
1408
+ msg += `
1409
+
1410
+ ${escapeHtml2(event.detail)}`;
1411
+ if (event.url)
1412
+ msg += `
1413
+
1414
+ <a href="${escapeHtml2(event.url)}">Открыть →</a>`;
1415
+ return msg;
1416
+ }
1417
+ renderEmailHtml(event) {
1418
+ const color = "#dc2626";
1419
+ return `<!DOCTYPE html><html lang="ru"><body style="margin:0;background:#f4f4f5;font-family:-apple-system,Segoe UI,sans-serif">
1420
+ <div style="max-width:560px;margin:32px auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08)">
1421
+ <div style="background:${color};padding:20px 28px;color:#fff;font-size:18px;font-weight:700">${SEV_EMOJI[event.severity]} ${escapeHtml2(event.title)}</div>
1422
+ <div style="padding:24px 28px;color:#18181b;font-size:15px;line-height:1.6">
1423
+ <p style="margin:0 0 16px">${escapeHtml2(event.detail)}</p>
1424
+ <a href="${escapeHtml2(event.url ?? this.deps.appUrl)}" style="display:inline-block;padding:10px 20px;background:${color};color:#fff;border-radius:8px;font-weight:600;text-decoration:none">Открыть админку →</a>
1425
+ </div>
1426
+ <div style="padding:16px 28px;background:#fafafa;border-top:1px solid #e4e4e7;font-size:12px;color:#a1a1aa">
1427
+ lead-engine · информер владельца · ${event.topic} / ${event.severity}
1428
+ </div>
1429
+ </div></body></html>`;
1430
+ }
1431
+ }
1432
+ // src/operator-bot-handler.ts
1433
+ import {
1434
+ TelegramClient as TelegramClient4
1435
+ } from "@chatman-media/channel-telegram";
1436
+ var LEVELS = ["silent", "critical", "important", "all"];
1437
+ var LEVEL_LABEL = {
1438
+ silent: "\uD83D\uDD15 Тихо",
1439
+ critical: "\uD83D\uDD34 Только критичное",
1440
+ important: "\uD83D\uDFE1 Важное",
1441
+ all: "\uD83D\uDCE2 Всё подряд"
1442
+ };
1443
+ var TOPICS = ["leads", "escalation", "orders", "system"];
1444
+ var TOPIC_LABEL = {
1445
+ leads: "\uD83C\uDD95 Лиды",
1446
+ escalation: "\uD83C\uDD98 Эскалации",
1447
+ orders: "\uD83D\uDCB1 Заявки",
1448
+ system: "\uD83D\uDEE0 Система"
1449
+ };
1450
+ var DIGESTS = ["off", "daily", "shift"];
1451
+ var DIGEST_LABEL = {
1452
+ off: "Выкл",
1453
+ daily: "Раз в день",
1454
+ shift: "2×/день"
1455
+ };
1456
+ var SEV_EMOJI2 = { critical: "\uD83D\uDD34", important: "\uD83D\uDFE1", info: "ℹ️" };
1457
+ function escapeHtml3(v) {
1458
+ return v.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1459
+ }
1460
+ function parseMuteSeconds(arg) {
1461
+ const a = arg.trim().toLowerCase();
1462
+ if (!a || a === "off" || a === "0")
1463
+ return 0;
1464
+ const m = /^(\d+)\s*(m|h|d)$/.exec(a);
1465
+ if (!m)
1466
+ return null;
1467
+ const n = Number.parseInt(m[1], 10);
1468
+ const unit = m[2];
1469
+ return unit === "m" ? n * 60 : unit === "h" ? n * 3600 : n * 86400;
1470
+ }
984
1471
 
985
1472
  class OperatorBotHandler {
986
1473
  repo;
@@ -990,11 +1477,17 @@ class OperatorBotHandler {
990
1477
  this.repo = repo;
991
1478
  this.botToken = botToken;
992
1479
  if (botToken) {
993
- this.client = new TelegramClient2({ token: botToken });
1480
+ this.client = new TelegramClient4({ token: botToken });
994
1481
  }
995
1482
  }
996
1483
  async handleUpdate(update) {
997
- if (!this.client || !update.message)
1484
+ if (!this.client)
1485
+ return;
1486
+ if (update.callback_query) {
1487
+ await this.handleCallback(update.callback_query);
1488
+ return;
1489
+ }
1490
+ if (!update.message)
998
1491
  return;
999
1492
  const { message } = update;
1000
1493
  const text = message.text || "";
@@ -1017,14 +1510,223 @@ class OperatorBotHandler {
1017
1510
  await this.handleSetupGroup(message.chat.id, message.chat.title || "эту группу");
1018
1511
  return;
1019
1512
  }
1513
+ if (text === "/status")
1514
+ return this.cmdStatus(chatId);
1515
+ if (text === "/level")
1516
+ return this.cmdLevel(chatId);
1517
+ if (text === "/topics")
1518
+ return this.cmdTopics(chatId);
1519
+ if (text === "/digest")
1520
+ return this.cmdDigest(chatId);
1521
+ if (text === "/mute" || text.startsWith("/mute "))
1522
+ return this.cmdMute(chatId, text);
1523
+ if (text === "/last" || text.startsWith("/last "))
1524
+ return this.cmdLast(chatId, text);
1020
1525
  if (text === "/start") {
1021
1526
  await this.client.sendMessage({
1022
1527
  chatId,
1023
- text: `\uD83D\uDC4B Привет! Я бот-уведомитель для Lead Engine.
1528
+ parseMode: "HTML",
1529
+ text: `\uD83D\uDC4B Привет! Я бот-информер Lead Engine.
1530
+
1531
+ ` + `Чтобы привязать аккаунт — Админка → Уведомления → «Подключить Telegram».
1532
+
1533
+ ` + `Когда привязан, настраивай прямо здесь:
1534
+ ` + `• /status — текущие настройки
1535
+ ` + `• /level — насколько громко информировать
1536
+ ` + `• /topics — какие темы слать
1537
+ ` + `• /digest — сводка (выкл/раз в день/2×)
1538
+ ` + `• /mute 2h — заглушить на время
1539
+ ` + "• /last — последние события"
1540
+ });
1541
+ }
1542
+ }
1543
+ async cmdStatus(chatId) {
1544
+ const s = await this.repo.findOperatorSettingsByChatId(chatId);
1545
+ if (!s)
1546
+ return this.replyNotLinked(chatId);
1547
+ const now = Math.floor(Date.now() / 1000);
1548
+ const muted = s.informerMutedUntil && s.informerMutedUntil > now ? `вкл (ещё ${Math.ceil((s.informerMutedUntil - now) / 60)} мин)` : "выкл";
1549
+ const map = this.topicMap(s.informerTopics);
1550
+ const topicsLine = TOPICS.map((t) => `${map[t] ? "✅" : "⬜"} ${TOPIC_LABEL[t]}`).join(`
1551
+ `);
1552
+ await this.client?.sendMessage({
1553
+ chatId,
1554
+ parseMode: "HTML",
1555
+ text: `⚙️ <b>Информер</b>
1556
+
1557
+ ` + `Уровень: <b>${LEVEL_LABEL[s.informerLevel] ?? s.informerLevel}</b>
1558
+ ` + `Дайджест: <b>${DIGEST_LABEL[s.informerDigest] ?? s.informerDigest}</b> ` + `(${s.informerDigestHour}:00 ${s.informerTz})
1559
+ ` + `Мут: <b>${muted}</b>
1560
+
1561
+ ` + `Темы:
1562
+ ${topicsLine}
1563
+
1564
+ ` + "Изменить: /level · /topics · /digest · /mute"
1565
+ });
1566
+ }
1567
+ async cmdLevel(chatId) {
1568
+ const s = await this.repo.findOperatorSettingsByChatId(chatId);
1569
+ if (!s)
1570
+ return this.replyNotLinked(chatId);
1571
+ await this.client?.sendMessage({
1572
+ chatId,
1573
+ text: "Насколько громко информировать?",
1574
+ replyMarkup: this.levelKeyboard(s.informerLevel)
1575
+ });
1576
+ }
1577
+ async cmdTopics(chatId) {
1578
+ const s = await this.repo.findOperatorSettingsByChatId(chatId);
1579
+ if (!s)
1580
+ return this.replyNotLinked(chatId);
1581
+ await this.client?.sendMessage({
1582
+ chatId,
1583
+ text: "Темы (нажми, чтобы вкл/выкл):",
1584
+ replyMarkup: this.topicsKeyboard(this.topicMap(s.informerTopics))
1585
+ });
1586
+ }
1587
+ async cmdDigest(chatId) {
1588
+ const s = await this.repo.findOperatorSettingsByChatId(chatId);
1589
+ if (!s)
1590
+ return this.replyNotLinked(chatId);
1591
+ await this.client?.sendMessage({
1592
+ chatId,
1593
+ text: "Как часто слать сводку?",
1594
+ replyMarkup: this.digestKeyboard(s.informerDigest)
1595
+ });
1596
+ }
1597
+ async cmdMute(chatId, text) {
1598
+ const arg = text.split(/\s+/)[1] ?? "";
1599
+ const sec = parseMuteSeconds(arg);
1600
+ if (sec === null) {
1601
+ await this.client?.sendMessage({
1602
+ chatId,
1603
+ text: "Формат: /mute 30m · /mute 2h · /mute 1d · /mute off"
1604
+ });
1605
+ return;
1606
+ }
1607
+ const s = await this.repo.findOperatorSettingsByChatId(chatId);
1608
+ if (!s)
1609
+ return this.replyNotLinked(chatId);
1610
+ const until = sec === 0 ? null : Math.floor(Date.now() / 1000) + sec;
1611
+ await this.repo.updateInformerPrefs(s.adminId, { informerMutedUntil: until });
1612
+ await this.client?.sendMessage({
1613
+ chatId,
1614
+ text: until ? `\uD83D\uDD07 Заглушено на ${Math.round(sec / 60)} мин. Реалтайм вернётся, события всё равно попадут в дайджест.` : "\uD83D\uDD14 Мут снят."
1615
+ });
1616
+ }
1617
+ async cmdLast(chatId, text) {
1618
+ const s = await this.repo.findOperatorSettingsByChatId(chatId);
1619
+ if (!s)
1620
+ return this.replyNotLinked(chatId);
1621
+ const arg = Number.parseInt(text.split(/\s+/)[1] ?? "", 10);
1622
+ const limit = Number.isFinite(arg) ? Math.min(Math.max(arg, 1), 20) : 10;
1623
+ const rows = await this.repo.listRecentNotifications(s.tenantId, s.adminId, limit);
1624
+ if (rows.length === 0) {
1625
+ await this.client?.sendMessage({ chatId, text: "Пока пусто — событий ещё не было." });
1626
+ return;
1627
+ }
1628
+ let msg = `\uD83D\uDDD2 <b>Последние ${rows.length}:</b>`;
1629
+ for (const r of rows) {
1630
+ const when = new Date(r.createdAt * 1000).toISOString().slice(5, 16).replace("T", " ");
1631
+ msg += `
1024
1632
 
1025
- Чтобы привязать свой аккаунт, перейдите в Админку -> Настройки уведомлений и нажмите 'Подключить Telegram'.`
1633
+ ${SEV_EMOJI2[r.severity] ?? ""} <b>${escapeHtml3(r.title)}</b> · ${when}`;
1634
+ if (r.body)
1635
+ msg += `
1636
+ ${escapeHtml3(r.body)}`;
1637
+ }
1638
+ await this.client?.sendMessage({ chatId, parseMode: "HTML", text: msg });
1639
+ }
1640
+ async handleCallback(cq) {
1641
+ if (!this.client)
1642
+ return;
1643
+ const chatId = String(cq.message?.chat.id ?? cq.from.id);
1644
+ const s = await this.repo.findOperatorSettingsByChatId(chatId);
1645
+ if (!s) {
1646
+ await this.client.answerCallbackQuery({ callbackQueryId: cq.id, text: "Аккаунт не привязан" });
1647
+ return;
1648
+ }
1649
+ const [kind, val] = (cq.data ?? "").split(":");
1650
+ if (kind === "lvl" && LEVELS.includes(val ?? "")) {
1651
+ await this.repo.updateInformerPrefs(s.adminId, { informerLevel: val });
1652
+ await this.editKeyboard(cq, "Насколько громко информировать?", this.levelKeyboard(val));
1653
+ await this.client.answerCallbackQuery({ callbackQueryId: cq.id, text: LEVEL_LABEL[val] });
1654
+ return;
1655
+ }
1656
+ if (kind === "dig" && DIGESTS.includes(val ?? "")) {
1657
+ await this.repo.updateInformerPrefs(s.adminId, { informerDigest: val });
1658
+ await this.editKeyboard(cq, "Как часто слать сводку?", this.digestKeyboard(val));
1659
+ await this.client.answerCallbackQuery({ callbackQueryId: cq.id, text: DIGEST_LABEL[val] });
1660
+ return;
1661
+ }
1662
+ if (kind === "tpc" && TOPICS.includes(val ?? "")) {
1663
+ const map = this.topicMap(s.informerTopics);
1664
+ map[val] = !map[val];
1665
+ await this.repo.updateInformerPrefs(s.adminId, { informerTopics: JSON.stringify(map) });
1666
+ await this.editKeyboard(cq, "Темы (нажми, чтобы вкл/выкл):", this.topicsKeyboard(map));
1667
+ await this.client.answerCallbackQuery({
1668
+ callbackQueryId: cq.id,
1669
+ text: `${TOPIC_LABEL[val]}: ${map[val] ? "вкл" : "выкл"}`
1026
1670
  });
1671
+ return;
1027
1672
  }
1673
+ await this.client.answerCallbackQuery({ callbackQueryId: cq.id });
1674
+ }
1675
+ async editKeyboard(cq, text, markup) {
1676
+ if (!this.client || !cq.message)
1677
+ return;
1678
+ await this.client.editMessageText({
1679
+ chatId: cq.message.chat.id,
1680
+ messageId: cq.message.message_id,
1681
+ text,
1682
+ replyMarkup: markup
1683
+ }).catch(() => {});
1684
+ }
1685
+ levelKeyboard(current) {
1686
+ return {
1687
+ inline_keyboard: LEVELS.map((l) => [
1688
+ { text: `${l === current ? "• " : ""}${LEVEL_LABEL[l]}`, callback_data: `lvl:${l}` }
1689
+ ])
1690
+ };
1691
+ }
1692
+ digestKeyboard(current) {
1693
+ return {
1694
+ inline_keyboard: DIGESTS.map((d) => [
1695
+ { text: `${d === current ? "• " : ""}${DIGEST_LABEL[d]}`, callback_data: `dig:${d}` }
1696
+ ])
1697
+ };
1698
+ }
1699
+ topicsKeyboard(map) {
1700
+ return {
1701
+ inline_keyboard: TOPICS.map((t) => [
1702
+ { text: `${map[t] ? "✅" : "⬜"} ${TOPIC_LABEL[t]}`, callback_data: `tpc:${t}` }
1703
+ ])
1704
+ };
1705
+ }
1706
+ topicMap(raw) {
1707
+ const base = {
1708
+ leads: true,
1709
+ escalation: true,
1710
+ orders: true,
1711
+ system: true
1712
+ };
1713
+ if (!raw)
1714
+ return base;
1715
+ try {
1716
+ const m = JSON.parse(raw);
1717
+ for (const t of TOPICS)
1718
+ if (m[t] === false)
1719
+ base[t] = false;
1720
+ return base;
1721
+ } catch {
1722
+ return base;
1723
+ }
1724
+ }
1725
+ async replyNotLinked(chatId) {
1726
+ await this.client?.sendMessage({
1727
+ chatId,
1728
+ text: "Сначала привяжите аккаунт: Админка → Уведомления → «Подключить Telegram»."
1729
+ });
1028
1730
  }
1029
1731
  async handleLinkToken(token, chatId) {
1030
1732
  if (!this.client)
@@ -1040,7 +1742,7 @@ class OperatorBotHandler {
1040
1742
  await this.repo.linkChat(settings.adminId, chatId);
1041
1743
  await this.client.sendMessage({
1042
1744
  chatId,
1043
- text: "✅ Аккаунт успешно привязан! Теперь вы будете получать важные уведомления здесь."
1745
+ text: "✅ Аккаунт привязан! Здесь будут важные уведомления. Настрой громкость: /level · /topics · /digest"
1044
1746
  });
1045
1747
  }
1046
1748
  async handleGroupLinkToken(token, chatId, title) {
@@ -1463,20 +2165,20 @@ async function runMemoryExtraction(opts) {
1463
2165
  }
1464
2166
  // src/stage-classifier.ts
1465
2167
  import { conversations as conversationsTable2 } from "@chatman-media/storage";
1466
- import { and as and11, eq as eq11 } from "drizzle-orm";
2168
+ import { and as and12, eq as eq12 } from "drizzle-orm";
1467
2169
  async function applyClassifiedStage(opts) {
1468
2170
  if (!opts.newStage)
1469
2171
  return false;
1470
- const [row] = await opts.db.select({ stage: conversationsTable2.currentStage }).from(conversationsTable2).where(and11(eq11(conversationsTable2.id, opts.conversationId), eq11(conversationsTable2.tenantId, opts.tenantId)));
2172
+ const [row] = await opts.db.select({ stage: conversationsTable2.currentStage }).from(conversationsTable2).where(and12(eq12(conversationsTable2.id, opts.conversationId), eq12(conversationsTable2.tenantId, opts.tenantId)));
1471
2173
  if (!row || row.stage === opts.newStage)
1472
2174
  return false;
1473
- await opts.db.update(conversationsTable2).set({ currentStage: opts.newStage }).where(and11(eq11(conversationsTable2.id, opts.conversationId), eq11(conversationsTable2.tenantId, opts.tenantId)));
2175
+ await opts.db.update(conversationsTable2).set({ currentStage: opts.newStage }).where(and12(eq12(conversationsTable2.id, opts.conversationId), eq12(conversationsTable2.tenantId, opts.tenantId)));
1474
2176
  return true;
1475
2177
  }
1476
2178
  // src/secrets.ts
1477
2179
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
1478
2180
  import { tenantSecrets } from "@chatman-media/storage";
1479
- import { and as and12, eq as eq12 } from "drizzle-orm";
2181
+ import { and as and13, eq as eq13 } from "drizzle-orm";
1480
2182
  var ALGORITHM = "aes-256-gcm";
1481
2183
  var IV_LEN = 12;
1482
2184
  var AUTH_TAG_LEN = 16;
@@ -1536,7 +2238,7 @@ function decryptSecret(masterKeyHex, ciphertext) {
1536
2238
  }
1537
2239
  }
1538
2240
  async function getDecryptedSecret(opts) {
1539
- const [row] = await opts.db.select().from(tenantSecrets).where(and12(eq12(tenantSecrets.tenantId, opts.tenantId), eq12(tenantSecrets.key, opts.key)));
2241
+ const [row] = await opts.db.select().from(tenantSecrets).where(and13(eq13(tenantSecrets.tenantId, opts.tenantId), eq13(tenantSecrets.key, opts.key)));
1540
2242
  if (!row)
1541
2243
  return null;
1542
2244
  return decryptSecret(opts.masterKeyHex, row.encryptedValue);
@@ -1569,15 +2271,6 @@ var systemClock = {
1569
2271
  nowEpoch: () => Math.floor(Date.now() / 1000)
1570
2272
  };
1571
2273
 
1572
- // src/with-tenant.ts
1573
- import { sql as sql9 } from "drizzle-orm";
1574
- async function withTenant(db, tenantId, fn) {
1575
- return db.transaction(async (tx) => {
1576
- await tx.execute(sql9.raw(`SET LOCAL app.tenant_id = ${tenantId}`));
1577
- return fn(tx);
1578
- });
1579
- }
1580
-
1581
2274
  // src/dispatch-reply.ts
1582
2275
  async function generateReplyAndEnqueue(deps) {
1583
2276
  const clock = deps.clock ?? systemClock;
@@ -1981,16 +2674,23 @@ export {
1981
2674
  withTenant,
1982
2675
  validateTransition,
1983
2676
  transitionLeadState,
2677
+ topicEnabled,
1984
2678
  systemClock,
1985
2679
  setEncryptedSecret,
1986
2680
  runMemoryExtraction,
2681
+ resolveOwnerContacts,
1987
2682
  resolveConversation,
1988
2683
  resolveContact,
2684
+ renderOpsEmailHtml,
1989
2685
  processInbound,
2686
+ passesThreshold,
1990
2687
  parseStyleConfig,
1991
2688
  parseAllocation,
2689
+ opsAlertToInformer,
2690
+ notificationEventToInformer,
1992
2691
  loadExperimentVariants,
1993
2692
  isTerminal,
2693
+ isMuted,
1994
2694
  getInitialStage,
1995
2695
  getDecryptedSecret,
1996
2696
  generateReplyAndEnqueue,
@@ -2005,8 +2705,10 @@ export {
2005
2705
  StylesRepo,
2006
2706
  SkillOutcomesRepo,
2007
2707
  SecretCryptoError,
2708
+ ResendEmailSender,
2008
2709
  RagReplyStrategy,
2009
2710
  OutboundQueueRepo,
2711
+ OpsAlertRouter,
2010
2712
  OperatorBotHandler,
2011
2713
  NotificationsRepo,
2012
2714
  NotificationService,
@@ -2015,10 +2717,12 @@ export {
2015
2717
  LlmMemoryExtractor,
2016
2718
  LeadsRepo,
2017
2719
  KbSuggestionsRepo,
2720
+ INFORMER_TOPICS,
2018
2721
  FunnelTransitionError,
2019
2722
  ExperimentsRepo,
2020
2723
  DrizzleKbStore,
2021
2724
  ConversationsRepo,
2022
2725
  ContactsRepo,
2023
- ChannelIdentitiesRepo
2726
+ ChannelIdentitiesRepo,
2727
+ AdminInformer
2024
2728
  };