@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/admin-informer.d.ts +90 -0
- package/dist/admin-informer.d.ts.map +1 -0
- package/dist/admin-informer.integration.test.d.ts +2 -0
- package/dist/admin-informer.integration.test.d.ts.map +1 -0
- package/dist/admin-informer.test.d.ts +2 -0
- package/dist/admin-informer.test.d.ts.map +1 -0
- package/dist/dal/index.d.ts +1 -1
- package/dist/dal/index.d.ts.map +1 -1
- package/dist/dal/notifications.d.ts +39 -1
- package/dist/dal/notifications.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +728 -24
- package/dist/notifications.d.ts +14 -1
- package/dist/notifications.d.ts.map +1 -1
- package/dist/operator-bot-handler.d.ts +16 -0
- package/dist/operator-bot-handler.d.ts.map +1 -1
- package/dist/ops-alerts.d.ts +101 -0
- package/dist/ops-alerts.d.ts.map +1 -0
- package/dist/ops-alerts.integration.test.d.ts +2 -0
- package/dist/ops-alerts.integration.test.d.ts.map +1 -0
- package/package.json +9 -9
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,
|
|
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/
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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
|
|
1480
|
+
this.client = new TelegramClient4({ token: botToken });
|
|
994
1481
|
}
|
|
995
1482
|
}
|
|
996
1483
|
async handleUpdate(update) {
|
|
997
|
-
if (!this.client
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
};
|