@chatman-media/conversation-engine 1.2.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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -0
  3. package/dist/compact-conversation.d.ts +16 -0
  4. package/dist/compact-conversation.d.ts.map +1 -0
  5. package/dist/compact-conversation.test.d.ts +2 -0
  6. package/dist/compact-conversation.test.d.ts.map +1 -0
  7. package/dist/contact-resolver.d.ts +16 -0
  8. package/dist/contact-resolver.d.ts.map +1 -0
  9. package/dist/conversation-resolver.d.ts +17 -0
  10. package/dist/conversation-resolver.d.ts.map +1 -0
  11. package/dist/dal/channel-identities.d.ts +26 -0
  12. package/dist/dal/channel-identities.d.ts.map +1 -0
  13. package/dist/dal/contacts.d.ts +30 -0
  14. package/dist/dal/contacts.d.ts.map +1 -0
  15. package/dist/dal/conversations.d.ts +67 -0
  16. package/dist/dal/conversations.d.ts.map +1 -0
  17. package/dist/dal/experiments.d.ts +47 -0
  18. package/dist/dal/experiments.d.ts.map +1 -0
  19. package/dist/dal/index.d.ts +14 -0
  20. package/dist/dal/index.d.ts.map +1 -0
  21. package/dist/dal/kb-store.d.ts +58 -0
  22. package/dist/dal/kb-store.d.ts.map +1 -0
  23. package/dist/dal/kb-suggestions.d.ts +26 -0
  24. package/dist/dal/kb-suggestions.d.ts.map +1 -0
  25. package/dist/dal/leads.d.ts +38 -0
  26. package/dist/dal/leads.d.ts.map +1 -0
  27. package/dist/dal/messages.d.ts +48 -0
  28. package/dist/dal/messages.d.ts.map +1 -0
  29. package/dist/dal/notifications.d.ts +32 -0
  30. package/dist/dal/notifications.d.ts.map +1 -0
  31. package/dist/dal/outbound.d.ts +70 -0
  32. package/dist/dal/outbound.d.ts.map +1 -0
  33. package/dist/dal/skill-outcomes.d.ts +58 -0
  34. package/dist/dal/skill-outcomes.d.ts.map +1 -0
  35. package/dist/dal/styles.d.ts +44 -0
  36. package/dist/dal/styles.d.ts.map +1 -0
  37. package/dist/dal/types.d.ts +27 -0
  38. package/dist/dal/types.d.ts.map +1 -0
  39. package/dist/dispatch-reply.d.ts +49 -0
  40. package/dist/dispatch-reply.d.ts.map +1 -0
  41. package/dist/dispatch-reply.test.d.ts +2 -0
  42. package/dist/dispatch-reply.test.d.ts.map +1 -0
  43. package/dist/experiment-router.d.ts +15 -0
  44. package/dist/experiment-router.d.ts.map +1 -0
  45. package/dist/experiment-router.test.d.ts +2 -0
  46. package/dist/experiment-router.test.d.ts.map +1 -0
  47. package/dist/extract-fields.test.d.ts +2 -0
  48. package/dist/extract-fields.test.d.ts.map +1 -0
  49. package/dist/funnel-machine.d.ts +43 -0
  50. package/dist/funnel-machine.d.ts.map +1 -0
  51. package/dist/funnel-machine.test.d.ts +2 -0
  52. package/dist/funnel-machine.test.d.ts.map +1 -0
  53. package/dist/index.d.ts +21 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +2024 -0
  56. package/dist/lead-lifecycle.d.ts +46 -0
  57. package/dist/lead-lifecycle.d.ts.map +1 -0
  58. package/dist/lead-lifecycle.test.d.ts +2 -0
  59. package/dist/lead-lifecycle.test.d.ts.map +1 -0
  60. package/dist/memory-extractor.d.ts +62 -0
  61. package/dist/memory-extractor.d.ts.map +1 -0
  62. package/dist/memory-extractor.test.d.ts +2 -0
  63. package/dist/memory-extractor.test.d.ts.map +1 -0
  64. package/dist/notifications.d.ts +32 -0
  65. package/dist/notifications.d.ts.map +1 -0
  66. package/dist/notifications.test.d.ts +2 -0
  67. package/dist/notifications.test.d.ts.map +1 -0
  68. package/dist/operator-bot-handler.d.ts +13 -0
  69. package/dist/operator-bot-handler.d.ts.map +1 -0
  70. package/dist/operator-bot-handler.test.d.ts +2 -0
  71. package/dist/operator-bot-handler.test.d.ts.map +1 -0
  72. package/dist/outbound-dispatch.d.ts +17 -0
  73. package/dist/outbound-dispatch.d.ts.map +1 -0
  74. package/dist/process-inbound.d.ts +126 -0
  75. package/dist/process-inbound.d.ts.map +1 -0
  76. package/dist/process-inbound.test.d.ts +2 -0
  77. package/dist/process-inbound.test.d.ts.map +1 -0
  78. package/dist/reply-strategy/index.d.ts +3 -0
  79. package/dist/reply-strategy/index.d.ts.map +1 -0
  80. package/dist/reply-strategy/llm-reply.d.ts +69 -0
  81. package/dist/reply-strategy/llm-reply.d.ts.map +1 -0
  82. package/dist/reply-strategy/llm-reply.test.d.ts +2 -0
  83. package/dist/reply-strategy/llm-reply.test.d.ts.map +1 -0
  84. package/dist/reply-strategy/rag-reply.d.ts +175 -0
  85. package/dist/reply-strategy/rag-reply.d.ts.map +1 -0
  86. package/dist/rls-guard.d.ts +23 -0
  87. package/dist/rls-guard.d.ts.map +1 -0
  88. package/dist/rls-guard.integration.test.d.ts +2 -0
  89. package/dist/rls-guard.integration.test.d.ts.map +1 -0
  90. package/dist/secrets.d.ts +27 -0
  91. package/dist/secrets.d.ts.map +1 -0
  92. package/dist/secrets.test.d.ts +2 -0
  93. package/dist/secrets.test.d.ts.map +1 -0
  94. package/dist/stage-classifier.d.ts +48 -0
  95. package/dist/stage-classifier.d.ts.map +1 -0
  96. package/dist/testkit.d.ts +82 -0
  97. package/dist/testkit.d.ts.map +1 -0
  98. package/dist/transcriber.d.ts +15 -0
  99. package/dist/transcriber.d.ts.map +1 -0
  100. package/dist/types.d.ts +98 -0
  101. package/dist/types.d.ts.map +1 -0
  102. package/dist/with-tenant.d.ts +25 -0
  103. package/dist/with-tenant.d.ts.map +1 -0
  104. package/package.json +66 -0
package/dist/index.js ADDED
@@ -0,0 +1,2024 @@
1
+ // src/contact-resolver.ts
2
+ async function resolveContact(opts) {
3
+ const existingIdentity = await opts.identities.find(opts.channelDbId, opts.inbound.externalUserId);
4
+ if (existingIdentity) {
5
+ const contact2 = await opts.contacts.byId(existingIdentity.contactId);
6
+ if (!contact2) {
7
+ throw new Error(`resolveContact: identity ${existingIdentity.id} points to missing contact ${existingIdentity.contactId}`);
8
+ }
9
+ return contact2;
10
+ }
11
+ const contact = await opts.contacts.create({
12
+ ...opts.inbound.externalUsername ? { displayName: opts.inbound.externalUsername } : {}
13
+ });
14
+ await opts.identities.create({
15
+ contactId: contact.id,
16
+ channelId: opts.channelDbId,
17
+ externalUserId: opts.inbound.externalUserId
18
+ });
19
+ return contact;
20
+ }
21
+ // src/conversation-resolver.ts
22
+ function channelKindToSource(kind) {
23
+ switch (kind) {
24
+ case "telegram_bot":
25
+ return "bot";
26
+ case "telegram_userbot":
27
+ return "userbot";
28
+ default:
29
+ return "bot";
30
+ }
31
+ }
32
+ async function resolveConversation(opts) {
33
+ const source = channelKindToSource(opts.channelKind);
34
+ const existing = await opts.conversations.findByContactAndSource(opts.contactId, source);
35
+ if (existing) {
36
+ return { conversation: existing, created: false };
37
+ }
38
+ const created = await opts.conversations.create({
39
+ contactId: opts.contactId,
40
+ source,
41
+ mode: "ai",
42
+ nowEpoch: opts.nowEpoch
43
+ });
44
+ return { conversation: created, created: true };
45
+ }
46
+ // src/dal/channel-identities.ts
47
+ import { channelIdentities } from "@chatman-media/storage";
48
+ import { and, eq } from "drizzle-orm";
49
+
50
+ class ChannelIdentitiesRepo {
51
+ ctx;
52
+ constructor(ctx) {
53
+ this.ctx = ctx;
54
+ }
55
+ async find(channelId, externalUserId) {
56
+ const [row] = await this.ctx.db.select().from(channelIdentities).where(and(eq(channelIdentities.channelId, channelId), eq(channelIdentities.externalUserId, externalUserId)));
57
+ return row ?? null;
58
+ }
59
+ async create(opts) {
60
+ const [row] = await this.ctx.db.insert(channelIdentities).values({
61
+ contactId: opts.contactId,
62
+ channelId: opts.channelId,
63
+ externalUserId: opts.externalUserId
64
+ }).returning();
65
+ if (!row)
66
+ throw new Error("channel_identities.create: insert returned no row");
67
+ return row;
68
+ }
69
+ }
70
+ // src/dal/kb-suggestions.ts
71
+ import { kbSuggestions } from "@chatman-media/storage";
72
+
73
+ class KbSuggestionsRepo {
74
+ ctx;
75
+ constructor(ctx) {
76
+ this.ctx = ctx;
77
+ }
78
+ async log(opts) {
79
+ await this.ctx.db.insert(kbSuggestions).values({
80
+ tenantId: this.ctx.tenantId,
81
+ questionText: opts.questionText,
82
+ status: "pending",
83
+ ...opts.sourceConversationId !== undefined ? { sourceConversationId: opts.sourceConversationId } : {},
84
+ ...opts.sourceMessageId !== undefined ? { sourceMessageId: opts.sourceMessageId } : {},
85
+ createdAt: opts.nowEpoch,
86
+ updatedAt: opts.nowEpoch
87
+ });
88
+ }
89
+ }
90
+ // src/dal/contacts.ts
91
+ import { contacts as contactsTable } from "@chatman-media/storage";
92
+ import { and as and2, eq as eq2 } from "drizzle-orm";
93
+
94
+ class ContactsRepo {
95
+ ctx;
96
+ constructor(ctx) {
97
+ this.ctx = ctx;
98
+ }
99
+ async byId(id) {
100
+ const [row] = await this.ctx.db.select().from(contactsTable).where(and2(eq2(contactsTable.id, id), eq2(contactsTable.tenantId, this.ctx.tenantId)));
101
+ return row ?? null;
102
+ }
103
+ async create(opts) {
104
+ const [row] = await this.ctx.db.insert(contactsTable).values({
105
+ tenantId: this.ctx.tenantId,
106
+ ...opts.displayName !== undefined ? { displayName: opts.displayName } : {},
107
+ ...opts.attributesJson !== undefined ? { attributesJson: opts.attributesJson } : {}
108
+ }).returning();
109
+ if (!row)
110
+ throw new Error("contacts.create: insert returned no row");
111
+ return row;
112
+ }
113
+ async mergeAttributes(contactId, partial, nowEpoch) {
114
+ if (Object.keys(partial).length === 0) {
115
+ return this.byId(contactId);
116
+ }
117
+ const existing = await this.byId(contactId);
118
+ if (!existing)
119
+ return null;
120
+ const merged = existing.attributesJson ? { ...JSON.parse(existing.attributesJson) } : {};
121
+ for (const [k, v] of Object.entries(partial)) {
122
+ merged[k] = v;
123
+ }
124
+ const [row] = await this.ctx.db.update(contactsTable).set({ attributesJson: JSON.stringify(merged), updatedAt: nowEpoch }).where(and2(eq2(contactsTable.id, contactId), eq2(contactsTable.tenantId, this.ctx.tenantId))).returning();
125
+ return row ?? null;
126
+ }
127
+ }
128
+ // src/dal/conversations.ts
129
+ import { conversations as conversationsTable } from "@chatman-media/storage";
130
+ import { and as and3, eq as eq3, sql } from "drizzle-orm";
131
+
132
+ class ConversationsRepo {
133
+ ctx;
134
+ constructor(ctx) {
135
+ this.ctx = ctx;
136
+ }
137
+ async findByContactAndSource(contactId, source) {
138
+ const [row] = await this.ctx.db.select().from(conversationsTable).where(and3(eq3(conversationsTable.tenantId, this.ctx.tenantId), eq3(conversationsTable.userId, contactId), eq3(conversationsTable.source, source)));
139
+ return row ?? null;
140
+ }
141
+ async create(opts) {
142
+ const [row] = await this.ctx.db.insert(conversationsTable).values({
143
+ tenantId: this.ctx.tenantId,
144
+ userId: opts.contactId,
145
+ source: opts.source,
146
+ ...opts.mode ? { mode: opts.mode } : {},
147
+ ...opts.styleId !== undefined ? { styleId: opts.styleId } : {},
148
+ ...opts.experimentId !== undefined ? { experimentId: opts.experimentId } : {},
149
+ lastMessageAt: opts.nowEpoch
150
+ }).returning();
151
+ if (!row)
152
+ throw new Error("conversations.create: insert returned no row");
153
+ return row;
154
+ }
155
+ async recent(limit) {
156
+ const rows = await this.ctx.db.select().from(conversationsTable).where(eq3(conversationsTable.tenantId, this.ctx.tenantId)).orderBy(sql`last_message_at DESC NULLS LAST`).limit(limit);
157
+ return rows;
158
+ }
159
+ async touchLastMessageAt(conversationId, nowEpoch) {
160
+ await this.ctx.db.update(conversationsTable).set({ lastMessageAt: nowEpoch }).where(and3(eq3(conversationsTable.id, conversationId), eq3(conversationsTable.tenantId, this.ctx.tenantId)));
161
+ }
162
+ async updateInboxMetadata(conversationId, opts) {
163
+ const updates = {};
164
+ if (opts.lastMessageText !== undefined)
165
+ updates.lastMessageText = opts.lastMessageText;
166
+ if (opts.lastMessageAt !== undefined)
167
+ updates.lastMessageAt = opts.lastMessageAt;
168
+ if (opts.status !== undefined)
169
+ updates.status = opts.status;
170
+ await this.ctx.db.update(conversationsTable).set({
171
+ ...updates,
172
+ ...opts.incrementUnread ? { unreadCount: sql`${conversationsTable.unreadCount} + 1` } : {}
173
+ }).where(and3(eq3(conversationsTable.id, conversationId), eq3(conversationsTable.tenantId, this.ctx.tenantId)));
174
+ }
175
+ async markAsRead(conversationId) {
176
+ await this.ctx.db.update(conversationsTable).set({ unreadCount: 0 }).where(and3(eq3(conversationsTable.id, conversationId), eq3(conversationsTable.tenantId, this.ctx.tenantId)));
177
+ }
178
+ async setAssignee(conversationId, adminId) {
179
+ await this.ctx.db.update(conversationsTable).set({ assignedAdminId: adminId }).where(and3(eq3(conversationsTable.id, conversationId), eq3(conversationsTable.tenantId, this.ctx.tenantId)));
180
+ }
181
+ async setSummaryJson(conversationId, summaryJson) {
182
+ await this.ctx.db.update(conversationsTable).set({ summaryJson }).where(and3(eq3(conversationsTable.id, conversationId), eq3(conversationsTable.tenantId, this.ctx.tenantId)));
183
+ }
184
+ async findById(conversationId) {
185
+ const [row] = await this.ctx.db.select().from(conversationsTable).where(and3(eq3(conversationsTable.id, conversationId), eq3(conversationsTable.tenantId, this.ctx.tenantId)));
186
+ return row ?? null;
187
+ }
188
+ }
189
+ // src/dal/kb-store.ts
190
+ import {
191
+ reciprocalRankFusion,
192
+ sanitizeFtsQuery
193
+ } from "@chatman-media/kb";
194
+ import { sql as sql2 } from "drizzle-orm";
195
+
196
+ class DrizzleKbStore {
197
+ ctx;
198
+ constructor(ctx) {
199
+ this.ctx = ctx;
200
+ }
201
+ vec(embedding) {
202
+ return `[${embedding.join(",")}]`;
203
+ }
204
+ async search(embedding, k, topic) {
205
+ const e = this.vec(embedding);
206
+ const t = this.ctx.tenantId;
207
+ if (topic == null) {
208
+ const rows = await this.ctx.db.execute(sql2`
209
+ SELECT c.id AS chunk_id,
210
+ (c.embedding <=> ${e}::vector) AS distance,
211
+ c.text, c.document_id, d.source, d.title
212
+ FROM kb_chunks c
213
+ JOIN kb_documents d ON d.id = c.document_id
214
+ WHERE c.embedding IS NOT NULL
215
+ AND c.tenant_id = ${t}
216
+ AND d.tenant_id = ${t}
217
+ ORDER BY c.embedding <=> ${e}::vector ASC
218
+ LIMIT ${k}
219
+ `);
220
+ return rows;
221
+ }
222
+ const overFetched = await this.ctx.db.execute(sql2`
223
+ SELECT c.id AS chunk_id,
224
+ (c.embedding <=> ${e}::vector) AS distance,
225
+ c.text, c.document_id, d.source, d.title, d.topic
226
+ FROM kb_chunks c
227
+ JOIN kb_documents d ON d.id = c.document_id
228
+ WHERE c.embedding IS NOT NULL
229
+ AND c.tenant_id = ${t}
230
+ AND d.tenant_id = ${t}
231
+ ORDER BY c.embedding <=> ${e}::vector ASC
232
+ LIMIT ${k * 3}
233
+ `);
234
+ return overFetched.filter((h) => h.topic === topic || h.topic === null).slice(0, k).map(({ topic: _t, ...rest }) => rest);
235
+ }
236
+ async searchBm25(query, k, topic) {
237
+ const ftsQuery = sanitizeFtsQuery(query);
238
+ if (!ftsQuery)
239
+ return [];
240
+ const t = this.ctx.tenantId;
241
+ try {
242
+ if (topic == null) {
243
+ const rows2 = await this.ctx.db.execute(sql2`
244
+ SELECT c.id AS chunk_id,
245
+ -ts_rank(c.fts, to_tsquery('russian', ${ftsQuery})) AS distance,
246
+ c.text, c.document_id, d.source, d.title
247
+ FROM kb_chunks c
248
+ JOIN kb_documents d ON d.id = c.document_id
249
+ WHERE c.fts @@ to_tsquery('russian', ${ftsQuery})
250
+ AND c.tenant_id = ${t}
251
+ AND d.tenant_id = ${t}
252
+ ORDER BY ts_rank(c.fts, to_tsquery('russian', ${ftsQuery})) DESC
253
+ LIMIT ${k}
254
+ `);
255
+ return rows2;
256
+ }
257
+ const rows = await this.ctx.db.execute(sql2`
258
+ SELECT c.id AS chunk_id,
259
+ -ts_rank(c.fts, to_tsquery('russian', ${ftsQuery})) AS distance,
260
+ c.text, c.document_id, d.source, d.title
261
+ FROM kb_chunks c
262
+ JOIN kb_documents d ON d.id = c.document_id
263
+ WHERE c.fts @@ to_tsquery('russian', ${ftsQuery})
264
+ AND c.tenant_id = ${t}
265
+ AND d.tenant_id = ${t}
266
+ AND (d.topic = ${topic} OR d.topic IS NULL)
267
+ ORDER BY ts_rank(c.fts, to_tsquery('russian', ${ftsQuery})) DESC
268
+ LIMIT ${k}
269
+ `);
270
+ return rows;
271
+ } catch (err) {
272
+ console.warn(`[kb] BM25 query failed for "${query}":`, err.message);
273
+ return [];
274
+ }
275
+ }
276
+ async textSearch(query, k = 5, topic) {
277
+ return this.searchBm25(query, k, topic);
278
+ }
279
+ async hybridSearch(input) {
280
+ const k = input.k ?? 5;
281
+ const cands = k * 2;
282
+ const topic = input.topic ?? null;
283
+ const vectorHits = await this.search(input.embedding, cands, topic);
284
+ const bm25Hits = await this.searchBm25(input.query, cands, topic);
285
+ if (bm25Hits.length === 0)
286
+ return vectorHits.slice(0, k);
287
+ if (vectorHits.length === 0)
288
+ return bm25Hits.slice(0, k);
289
+ return reciprocalRankFusion(vectorHits, bm25Hits, k, 60);
290
+ }
291
+ async prioritySearch(input) {
292
+ const k = input.k ?? 5;
293
+ const booksHits = await this.search(input.embedding, k, "books");
294
+ if (booksHits.length > 0)
295
+ return booksHits;
296
+ if (input.vectorOnly)
297
+ return this.search(input.embedding, k);
298
+ return this.hybridSearch({ embedding: input.embedding, query: input.query, k });
299
+ }
300
+ async getDocumentBySource(source) {
301
+ const t = this.ctx.tenantId;
302
+ const rows = await this.ctx.db.execute(sql2`
303
+ SELECT id, content_hash
304
+ FROM kb_documents
305
+ WHERE source = ${source} AND tenant_id = ${t}
306
+ LIMIT 1
307
+ `);
308
+ return rows[0] ?? null;
309
+ }
310
+ async countChunksForDocument(documentId) {
311
+ const t = this.ctx.tenantId;
312
+ const rows = await this.ctx.db.execute(sql2`
313
+ SELECT COUNT(*)::INTEGER AS n
314
+ FROM kb_chunks
315
+ WHERE document_id = ${documentId} AND tenant_id = ${t}
316
+ `);
317
+ return rows[0]?.n ?? 0;
318
+ }
319
+ async deleteDocument(id) {
320
+ const t = this.ctx.tenantId;
321
+ await this.ctx.db.execute(sql2`
322
+ DELETE FROM kb_chunks
323
+ WHERE document_id = ${id} AND tenant_id = ${t}
324
+ `);
325
+ const result = await this.ctx.db.execute(sql2`
326
+ DELETE FROM kb_documents
327
+ WHERE id = ${id} AND tenant_id = ${t}
328
+ `);
329
+ const check = await this.ctx.db.execute(sql2`
330
+ SELECT 1 AS exists FROM kb_documents WHERE id = ${id} AND tenant_id = ${t}
331
+ `);
332
+ return check.length === 0;
333
+ }
334
+ async upsertDocument(input) {
335
+ const t = this.ctx.tenantId;
336
+ const topic = input.topic ?? null;
337
+ const rows = await this.ctx.db.execute(sql2`
338
+ INSERT INTO kb_documents (tenant_id, source, title, content_hash, topic)
339
+ VALUES (${t}, ${input.source}, ${input.title}, ${input.contentHash}, ${topic})
340
+ ON CONFLICT (source, content_hash) DO UPDATE SET source = EXCLUDED.source
341
+ RETURNING id
342
+ `);
343
+ if (!rows[0])
344
+ throw new Error("kb_documents upsert returned no row");
345
+ return rows[0];
346
+ }
347
+ async insertChunkWithEmbedding(input) {
348
+ const t = this.ctx.tenantId;
349
+ const e = this.vec(input.embedding);
350
+ await this.ctx.db.execute(sql2`
351
+ INSERT INTO kb_chunks (tenant_id, document_id, chunk_index, text, token_count, embedding)
352
+ VALUES (${t}, ${input.documentId}, ${input.chunkIndex}, ${input.text}, ${input.tokenCount}, ${e}::vector)
353
+ `);
354
+ }
355
+ }
356
+ // src/dal/experiments.ts
357
+ import { experiments as experimentsTable } from "@chatman-media/storage";
358
+ import { and as and4, desc, eq as eq4 } from "drizzle-orm";
359
+
360
+ class ExperimentsRepo {
361
+ ctx;
362
+ constructor(ctx) {
363
+ this.ctx = ctx;
364
+ }
365
+ async byId(id) {
366
+ const [row] = await this.ctx.db.select().from(experimentsTable).where(and4(eq4(experimentsTable.id, id), eq4(experimentsTable.tenantId, this.ctx.tenantId)));
367
+ return row ?? null;
368
+ }
369
+ async findRunningBySlug(slug) {
370
+ const [row] = await this.ctx.db.select().from(experimentsTable).where(and4(eq4(experimentsTable.tenantId, this.ctx.tenantId), eq4(experimentsTable.slug, slug), eq4(experimentsTable.status, "running")));
371
+ return row ?? null;
372
+ }
373
+ async listAll() {
374
+ const rows = await this.ctx.db.select().from(experimentsTable).where(eq4(experimentsTable.tenantId, this.ctx.tenantId)).orderBy(desc(experimentsTable.createdAt));
375
+ return rows;
376
+ }
377
+ async create(data) {
378
+ const nowEpoch = Math.floor(Date.now() / 1000);
379
+ const [row] = await this.ctx.db.insert(experimentsTable).values({
380
+ tenantId: this.ctx.tenantId,
381
+ slug: data.slug,
382
+ status: "draft",
383
+ allocationJson: data.allocationJson,
384
+ successMetric: data.successMetric,
385
+ createdAt: nowEpoch
386
+ }).returning();
387
+ return row;
388
+ }
389
+ async update(id, data) {
390
+ const [row] = await this.ctx.db.update(experimentsTable).set(data).where(and4(eq4(experimentsTable.id, id), eq4(experimentsTable.tenantId, this.ctx.tenantId))).returning();
391
+ return row ?? null;
392
+ }
393
+ async setStatus(id, status) {
394
+ const nowEpoch = Math.floor(Date.now() / 1000);
395
+ const extra = {};
396
+ if (status === "running")
397
+ extra.startedAt = nowEpoch;
398
+ if (status === "done")
399
+ extra.endedAt = nowEpoch;
400
+ const [row] = await this.ctx.db.update(experimentsTable).set({ status, ...extra }).where(and4(eq4(experimentsTable.id, id), eq4(experimentsTable.tenantId, this.ctx.tenantId))).returning();
401
+ return row ?? null;
402
+ }
403
+ }
404
+ function parseAllocation(allocationJson) {
405
+ let raw;
406
+ try {
407
+ raw = JSON.parse(allocationJson);
408
+ } catch {
409
+ throw new Error(`experiments.allocation_json invalid: not JSON`);
410
+ }
411
+ if (!Array.isArray(raw)) {
412
+ throw new Error("experiments.allocation_json must be array");
413
+ }
414
+ const out = [];
415
+ for (const item of raw) {
416
+ if (typeof item !== "object" || item === null)
417
+ continue;
418
+ const obj = item;
419
+ const slug = obj.style_slug ?? obj.styleSlug;
420
+ if (typeof slug !== "string")
421
+ continue;
422
+ const weight = typeof obj.weight === "number" && obj.weight > 0 ? obj.weight : 1;
423
+ out.push({ styleSlug: slug, weight });
424
+ }
425
+ if (out.length === 0) {
426
+ throw new Error("experiments.allocation_json contains no valid entries");
427
+ }
428
+ return out;
429
+ }
430
+ // src/dal/leads.ts
431
+ import { leads as leadsTable } from "@chatman-media/storage";
432
+ import { and as and5, eq as eq5 } from "drizzle-orm";
433
+
434
+ class LeadsRepo {
435
+ ctx;
436
+ constructor(ctx) {
437
+ this.ctx = ctx;
438
+ }
439
+ async byId(id) {
440
+ const [row] = await this.ctx.db.select().from(leadsTable).where(and5(eq5(leadsTable.id, id), eq5(leadsTable.tenantId, this.ctx.tenantId)));
441
+ return row ?? null;
442
+ }
443
+ async findByContactId(contactId) {
444
+ const [row] = await this.ctx.db.select().from(leadsTable).where(and5(eq5(leadsTable.tenantId, this.ctx.tenantId), eq5(leadsTable.userId, contactId)));
445
+ return row ?? null;
446
+ }
447
+ async create(opts) {
448
+ const [row] = await this.ctx.db.insert(leadsTable).values({
449
+ tenantId: this.ctx.tenantId,
450
+ userId: opts.contactId,
451
+ state: opts.state,
452
+ createdAt: opts.nowEpoch,
453
+ updatedAt: opts.nowEpoch
454
+ }).returning();
455
+ if (!row)
456
+ throw new Error("leads.create: insert returned no row");
457
+ return row;
458
+ }
459
+ async updateState(leadId, newState, nowEpoch) {
460
+ await this.ctx.db.update(leadsTable).set({ state: newState, updatedAt: nowEpoch }).where(and5(eq5(leadsTable.id, leadId), eq5(leadsTable.tenantId, this.ctx.tenantId)));
461
+ }
462
+ }
463
+ // src/dal/messages.ts
464
+ import { messages as messagesTable } from "@chatman-media/storage";
465
+ import { and as and6, desc as desc2, eq as eq6, isNull, sql as sql3 } from "drizzle-orm";
466
+
467
+ class MessagesRepo {
468
+ ctx;
469
+ constructor(ctx) {
470
+ this.ctx = ctx;
471
+ }
472
+ async insert(opts) {
473
+ const externalId = opts.externalMessageId !== undefined ? Number(opts.externalMessageId) : null;
474
+ const [row] = await this.ctx.db.insert(messagesTable).values({
475
+ tenantId: this.ctx.tenantId,
476
+ conversationId: opts.conversationId,
477
+ role: opts.role,
478
+ text: opts.text,
479
+ ...externalId !== null && !Number.isNaN(externalId) ? { tgMessageId: externalId } : {},
480
+ ...opts.metaJson !== undefined ? { metaJson: opts.metaJson } : {},
481
+ ...opts.stage !== undefined ? { stage: opts.stage } : {},
482
+ createdAt: opts.nowEpoch
483
+ }).returning();
484
+ if (!row)
485
+ throw new Error("messages.insert: insert returned no row");
486
+ return row;
487
+ }
488
+ async findUserByExternalId(conversationId, externalMessageId) {
489
+ const num = Number(externalMessageId);
490
+ if (Number.isNaN(num))
491
+ return null;
492
+ const [row] = await this.ctx.db.select().from(messagesTable).where(and6(eq6(messagesTable.tenantId, this.ctx.tenantId), eq6(messagesTable.conversationId, conversationId), eq6(messagesTable.tgMessageId, num), sql3`role = 'user'`));
493
+ return row ?? null;
494
+ }
495
+ async recent(conversationId, limit) {
496
+ const rows = await this.ctx.db.select().from(messagesTable).where(and6(eq6(messagesTable.tenantId, this.ctx.tenantId), eq6(messagesTable.conversationId, conversationId), isNull(messagesTable.deletedAt), sql3`role <> 'system'`)).orderBy(desc2(messagesTable.createdAt)).limit(limit);
497
+ return rows.reverse();
498
+ }
499
+ async countByConversation(conversationId) {
500
+ const [row] = await this.ctx.db.select({ count: sql3`COUNT(*)::int` }).from(messagesTable).where(and6(eq6(messagesTable.tenantId, this.ctx.tenantId), eq6(messagesTable.conversationId, conversationId), isNull(messagesTable.deletedAt), sql3`role <> 'system'`));
501
+ return row?.count ?? 0;
502
+ }
503
+ }
504
+ // src/dal/outbound.ts
505
+ import { channels, outboundQueue } from "@chatman-media/storage";
506
+ import { and as and7, eq as eq7, sql as sql4 } from "drizzle-orm";
507
+
508
+ class OutboundQueueRepo {
509
+ ctx;
510
+ constructor(ctx) {
511
+ this.ctx = ctx;
512
+ }
513
+ async enqueue(opts) {
514
+ const payload = JSON.stringify(opts.envelope);
515
+ const key = opts.envelope.idempotencyKey ?? null;
516
+ if (key !== null) {
517
+ const [existing] = await this.ctx.db.select().from(outboundQueue).where(and7(eq7(outboundQueue.tenantId, this.ctx.tenantId), eq7(outboundQueue.idempotencyKey, key)));
518
+ if (existing)
519
+ return existing;
520
+ }
521
+ const [row] = await this.ctx.db.insert(outboundQueue).values({
522
+ tenantId: this.ctx.tenantId,
523
+ channelId: opts.channelId,
524
+ ...opts.conversationId !== undefined && opts.conversationId !== null ? { conversationId: opts.conversationId } : {},
525
+ payloadJson: payload,
526
+ ...key ? { idempotencyKey: key } : {},
527
+ scheduledAt: opts.scheduledAt ?? opts.nowEpoch,
528
+ createdAt: opts.nowEpoch
529
+ }).returning();
530
+ if (!row)
531
+ throw new Error("outbound_queue.enqueue: insert returned no row");
532
+ return row;
533
+ }
534
+ async claimPending(opts) {
535
+ const kindFilter = opts.kinds && opts.kinds.length > 0 ? sql4`AND ${outboundQueue.channelId} IN (
536
+ SELECT id FROM ${channels} WHERE ${channels.kind} IN (${sql4.join(opts.kinds.map((k) => sql4`${k}`), sql4`, `)})
537
+ )` : sql4``;
538
+ const raw = await this.ctx.db.execute(sql4`
539
+ UPDATE ${outboundQueue}
540
+ SET status = 'processing'
541
+ WHERE id IN (
542
+ SELECT id FROM ${outboundQueue}
543
+ WHERE tenant_id = ${this.ctx.tenantId}
544
+ AND status = 'pending'
545
+ AND scheduled_at <= ${opts.nowEpoch}
546
+ ${kindFilter}
547
+ ORDER BY scheduled_at ASC
548
+ LIMIT ${opts.limit}
549
+ FOR UPDATE SKIP LOCKED
550
+ )
551
+ RETURNING *
552
+ `);
553
+ return raw.map((r) => ({
554
+ id: r.id,
555
+ tenantId: r.tenant_id,
556
+ channelId: r.channel_id,
557
+ conversationId: r.conversation_id ?? null,
558
+ payloadJson: r.payload_json,
559
+ idempotencyKey: r.idempotency_key ?? null,
560
+ scheduledAt: r.scheduled_at,
561
+ status: r.status,
562
+ attempt: r.attempt,
563
+ lastError: r.last_error ?? null,
564
+ externalMessageId: r.external_message_id ?? null,
565
+ sentAt: r.sent_at ?? null,
566
+ createdAt: r.created_at
567
+ }));
568
+ }
569
+ async releaseStuckProcessing(opts) {
570
+ const cutoff = opts.nowEpoch - opts.stuckSec;
571
+ const rows = await this.ctx.db.execute(sql4`
572
+ UPDATE ${outboundQueue}
573
+ SET status = 'pending'
574
+ WHERE tenant_id = ${this.ctx.tenantId}
575
+ AND status = 'processing'
576
+ AND scheduled_at < ${cutoff}
577
+ RETURNING id
578
+ `);
579
+ return Array.isArray(rows) ? rows.length : 0;
580
+ }
581
+ async markSent(id, externalMessageId, nowEpoch) {
582
+ await this.ctx.db.update(outboundQueue).set({
583
+ status: "sent",
584
+ externalMessageId,
585
+ sentAt: nowEpoch,
586
+ attempt: sql4`${outboundQueue.attempt} + 1`
587
+ }).where(and7(eq7(outboundQueue.id, id), eq7(outboundQueue.tenantId, this.ctx.tenantId)));
588
+ }
589
+ async markFailed(id, error) {
590
+ await this.ctx.db.update(outboundQueue).set({
591
+ status: "failed",
592
+ lastError: error,
593
+ attempt: sql4`${outboundQueue.attempt} + 1`
594
+ }).where(and7(eq7(outboundQueue.id, id), eq7(outboundQueue.tenantId, this.ctx.tenantId)));
595
+ }
596
+ }
597
+ // src/dal/skill-outcomes.ts
598
+ import { skillOutcomes as skillOutcomesTable } from "@chatman-media/storage";
599
+ import { and as and8, eq as eq8, sql as sql5 } from "drizzle-orm";
600
+
601
+ class SkillOutcomesRepo {
602
+ ctx;
603
+ constructor(ctx) {
604
+ this.ctx = ctx;
605
+ }
606
+ async record(opts) {
607
+ const result = await this.ctx.db.execute(sql5`
608
+ INSERT INTO skill_outcomes
609
+ (tenant_id, lead_id, conversation_id, message_id, style_slug,
610
+ skill_slug, outcome, source, created_at)
611
+ VALUES
612
+ (${this.ctx.tenantId}, ${opts.leadId},
613
+ ${opts.conversationId ?? null}, ${opts.messageId ?? null},
614
+ ${opts.styleSlug ?? null}, ${opts.skillSlug}, ${opts.outcome},
615
+ ${opts.source}, ${opts.nowEpoch})
616
+ ON CONFLICT (lead_id, skill_slug, source) DO NOTHING
617
+ RETURNING id
618
+ `);
619
+ return result.length > 0;
620
+ }
621
+ async byLeadId(leadId) {
622
+ const rows = await this.ctx.db.select().from(skillOutcomesTable).where(and8(eq8(skillOutcomesTable.tenantId, this.ctx.tenantId), eq8(skillOutcomesTable.leadId, leadId)));
623
+ return rows;
624
+ }
625
+ async aggregates() {
626
+ const rows = await this.ctx.db.execute(sql5`
627
+ SELECT
628
+ skill_slug,
629
+ SUM(CASE WHEN outcome='won' THEN 1 ELSE 0 END)::INTEGER AS wins,
630
+ SUM(CASE WHEN outcome='lost' THEN 1 ELSE 0 END)::INTEGER AS losses,
631
+ SUM(CASE WHEN outcome='draw' THEN 1 ELSE 0 END)::INTEGER AS draws,
632
+ COUNT(*)::INTEGER AS total
633
+ FROM skill_outcomes
634
+ WHERE tenant_id = ${this.ctx.tenantId}
635
+ GROUP BY skill_slug
636
+ ORDER BY total DESC
637
+ `);
638
+ return rows.map((r) => ({
639
+ skillSlug: r.skill_slug,
640
+ wins: r.wins,
641
+ losses: r.losses,
642
+ draws: r.draws,
643
+ total: r.total
644
+ }));
645
+ }
646
+ }
647
+ // src/dal/styles.ts
648
+ import { styles as stylesTable } from "@chatman-media/storage";
649
+ import { and as and9, eq as eq9, sql as sql6 } from "drizzle-orm";
650
+
651
+ class StylesRepo {
652
+ ctx;
653
+ constructor(ctx) {
654
+ this.ctx = ctx;
655
+ }
656
+ async byId(id) {
657
+ const [row] = await this.ctx.db.select().from(stylesTable).where(and9(eq9(stylesTable.id, id), eq9(stylesTable.tenantId, this.ctx.tenantId)));
658
+ return row ?? null;
659
+ }
660
+ async findActiveBySlug(slug) {
661
+ const [row] = await this.ctx.db.select().from(stylesTable).where(and9(eq9(stylesTable.tenantId, this.ctx.tenantId), eq9(stylesTable.slug, slug), eq9(stylesTable.isActive, true), sql6`deleted_at IS NULL`));
662
+ return row ?? null;
663
+ }
664
+ async listActive() {
665
+ const rows = await this.ctx.db.select().from(stylesTable).where(and9(eq9(stylesTable.tenantId, this.ctx.tenantId), eq9(stylesTable.isActive, true), sql6`deleted_at IS NULL`));
666
+ return rows;
667
+ }
668
+ async listAll() {
669
+ const rows = await this.ctx.db.select().from(stylesTable).where(and9(eq9(stylesTable.tenantId, this.ctx.tenantId), sql6`deleted_at IS NULL`));
670
+ return rows;
671
+ }
672
+ async create(data) {
673
+ const nowEpoch = Math.floor(Date.now() / 1000);
674
+ const [row] = await this.ctx.db.insert(stylesTable).values({
675
+ tenantId: this.ctx.tenantId,
676
+ slug: data.slug,
677
+ displayName: data.displayName,
678
+ configJson: data.configJson,
679
+ isActive: data.isActive,
680
+ version: 1,
681
+ createdAt: nowEpoch
682
+ }).returning();
683
+ return row;
684
+ }
685
+ async update(id, data) {
686
+ const [row] = await this.ctx.db.update(stylesTable).set(data).where(and9(eq9(stylesTable.id, id), eq9(stylesTable.tenantId, this.ctx.tenantId), sql6`deleted_at IS NULL`)).returning();
687
+ return row ?? null;
688
+ }
689
+ async softDelete(id) {
690
+ const nowEpoch = Math.floor(Date.now() / 1000);
691
+ const rows = await this.ctx.db.update(stylesTable).set({ deletedAt: nowEpoch, isActive: false }).where(and9(eq9(stylesTable.id, id), eq9(stylesTable.tenantId, this.ctx.tenantId), sql6`deleted_at IS NULL`)).returning({ id: stylesTable.id });
692
+ return rows.length > 0;
693
+ }
694
+ }
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";
698
+
699
+ class NotificationsRepo {
700
+ db;
701
+ constructor(db) {
702
+ this.db = db;
703
+ }
704
+ async findRulesByEvent(tenantId, eventType) {
705
+ return this.db.select().from(notificationRules).where(and10(eq10(notificationRules.tenantId, tenantId), eq10(notificationRules.eventType, eventType), eq10(notificationRules.isActive, true)));
706
+ }
707
+ async findOperatorSettings(adminId) {
708
+ const rows = await this.db.select().from(operatorSettings).where(eq10(operatorSettings.adminId, adminId)).limit(1);
709
+ return rows[0];
710
+ }
711
+ async findByLinkToken(token) {
712
+ const now = Math.floor(Date.now() / 1000);
713
+ const rows = await this.db.select().from(operatorSettings).where(and10(eq10(operatorSettings.linkToken, token), sql7`${operatorSettings.linkTokenExpiresAt} > ${now}`)).limit(1);
714
+ return rows[0];
715
+ }
716
+ async generateLinkToken(adminId, tenantId) {
717
+ const token = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
718
+ const expiresAt = Math.floor(Date.now() / 1000) + 3600;
719
+ await this.db.insert(operatorSettings).values({
720
+ adminId,
721
+ tenantId,
722
+ linkToken: token,
723
+ linkTokenExpiresAt: expiresAt
724
+ }).onConflictDoUpdate({
725
+ target: [operatorSettings.adminId],
726
+ set: {
727
+ linkToken: token,
728
+ linkTokenExpiresAt: expiresAt,
729
+ updatedAt: Math.floor(Date.now() / 1000)
730
+ }
731
+ });
732
+ return token;
733
+ }
734
+ async linkChat(adminId, telegramChatId) {
735
+ await this.db.update(operatorSettings).set({
736
+ telegramChatId,
737
+ linkToken: null,
738
+ linkTokenExpiresAt: null,
739
+ updatedAt: Math.floor(Date.now() / 1000)
740
+ }).where(eq10(operatorSettings.adminId, adminId));
741
+ }
742
+ async findOperatorSettingsByTenant(tenantId) {
743
+ return this.db.select().from(operatorSettings).where(eq10(operatorSettings.tenantId, tenantId));
744
+ }
745
+ async partialUpdateSettings(adminId, tenantId, fields) {
746
+ if (Object.keys(fields).length === 0)
747
+ return;
748
+ await this.db.insert(operatorSettings).values({ adminId, tenantId, ...fields }).onConflictDoUpdate({
749
+ target: [operatorSettings.adminId],
750
+ set: { ...fields, updatedAt: Math.floor(Date.now() / 1000) }
751
+ });
752
+ }
753
+ async upsertOperatorSettings(settings) {
754
+ await this.db.insert(operatorSettings).values(settings).onConflictDoUpdate({
755
+ target: [operatorSettings.adminId],
756
+ set: {
757
+ telegramChatId: settings.telegramChatId,
758
+ notifyOnAssignedOnly: settings.notifyOnAssignedOnly,
759
+ updatedAt: Math.floor(Date.now() / 1000)
760
+ }
761
+ });
762
+ }
763
+ async createRule(rule) {
764
+ const [inserted] = await this.db.insert(notificationRules).values(rule).returning();
765
+ if (!inserted)
766
+ throw new Error("notification rule insert returned no row");
767
+ return inserted;
768
+ }
769
+ async listRules(tenantId) {
770
+ return this.db.select().from(notificationRules).where(eq10(notificationRules.tenantId, tenantId));
771
+ }
772
+ async deleteRule(tenantId, id) {
773
+ await this.db.delete(notificationRules).where(and10(eq10(notificationRules.tenantId, tenantId), eq10(notificationRules.id, id)));
774
+ }
775
+ async findTemplate(tenantId, slug) {
776
+ const rows = await this.db.select().from(notificationTemplates).where(and10(eq10(notificationTemplates.tenantId, tenantId), eq10(notificationTemplates.slug, slug))).limit(1);
777
+ return rows[0];
778
+ }
779
+ async upsertTemplate(tpl) {
780
+ await this.db.insert(notificationTemplates).values(tpl).onConflictDoUpdate({
781
+ target: [notificationTemplates.tenantId, notificationTemplates.slug],
782
+ set: {
783
+ body: tpl.body,
784
+ updatedAt: Math.floor(Date.now() / 1000)
785
+ }
786
+ });
787
+ }
788
+ async listTemplates(tenantId) {
789
+ return this.db.select().from(notificationTemplates).where(eq10(notificationTemplates.tenantId, tenantId));
790
+ }
791
+ async deleteTemplate(tenantId, slug) {
792
+ await this.db.delete(notificationTemplates).where(and10(eq10(notificationTemplates.tenantId, tenantId), eq10(notificationTemplates.slug, slug)));
793
+ }
794
+ async generateGroupLinkToken(tenantId, adminId, eventType) {
795
+ const token = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
796
+ const expiresAt = Math.floor(Date.now() / 1000) + 3600;
797
+ await this.db.insert(notificationGroupTokens).values({
798
+ token,
799
+ tenantId,
800
+ adminId,
801
+ eventType,
802
+ expiresAt,
803
+ createdAt: Math.floor(Date.now() / 1000)
804
+ });
805
+ return token;
806
+ }
807
+ async findGroupLinkToken(token) {
808
+ const now = Math.floor(Date.now() / 1000);
809
+ const rows = await this.db.select().from(notificationGroupTokens).where(and10(eq10(notificationGroupTokens.token, token), sql7`${notificationGroupTokens.expiresAt} > ${now}`)).limit(1);
810
+ return rows[0];
811
+ }
812
+ async deleteGroupLinkToken(token) {
813
+ await this.db.delete(notificationGroupTokens).where(eq10(notificationGroupTokens.token, token));
814
+ }
815
+ }
816
+ // src/notifications.ts
817
+ import { TelegramClient } from "@chatman-media/channel-telegram";
818
+
819
+ class NotificationService {
820
+ repo;
821
+ botToken;
822
+ appUrl;
823
+ client = null;
824
+ constructor(repo, botToken, appUrl) {
825
+ this.repo = repo;
826
+ this.botToken = botToken;
827
+ this.appUrl = appUrl;
828
+ if (botToken) {
829
+ this.client = new TelegramClient({ token: botToken });
830
+ }
831
+ }
832
+ async notify(event) {
833
+ if (!this.client)
834
+ return;
835
+ const [rules, operatorSettingsList] = await Promise.all([
836
+ this.repo.findRulesByEvent(event.tenantId, event.eventType),
837
+ this.repo.findOperatorSettingsByTenant(event.tenantId)
838
+ ]);
839
+ const template = await this.repo.findTemplate(event.tenantId, event.eventType);
840
+ const text = template ? this.renderTemplate(template.body, event) : this.formatMessage(event);
841
+ const buttons = this.formatButtons(event);
842
+ const matchedRules = rules.filter((rule) => this.matchesCondition(rule, event));
843
+ for (const rule of matchedRules) {
844
+ try {
845
+ await this.sendMessage(rule.targetId, text, buttons);
846
+ } catch (err) {
847
+ console.error(`[NotificationService] rule ${rule.id} send failed:`, err);
848
+ }
849
+ }
850
+ for (const settings of operatorSettingsList) {
851
+ if (!settings.telegramChatId)
852
+ continue;
853
+ if (settings.notifyOnAssignedOnly && event.assignedAdminId !== undefined && event.assignedAdminId !== settings.adminId) {
854
+ continue;
855
+ }
856
+ try {
857
+ await this.sendMessage(settings.telegramChatId, text, buttons);
858
+ } catch (err) {
859
+ console.error(`[NotificationService] personal send to admin ${settings.adminId} failed:`, err);
860
+ }
861
+ }
862
+ }
863
+ async sendTestMessage(chatId) {
864
+ if (!this.client)
865
+ return { ok: false, error: "Бот не настроен (нет токена)" };
866
+ try {
867
+ await this.client.sendMessage({
868
+ chatId,
869
+ text: `\uD83E\uDDEA <b>Тестовое уведомление</b>
870
+
871
+ Правило активно — сообщения доходят корректно.`,
872
+ parseMode: "HTML"
873
+ });
874
+ return { ok: true };
875
+ } catch (err) {
876
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
877
+ }
878
+ }
879
+ async sendMessage(chatId, text, buttons) {
880
+ await this.client.sendMessage({
881
+ chatId,
882
+ text,
883
+ parseMode: "HTML",
884
+ replyMarkup: buttons ? { inline_keyboard: [buttons] } : undefined
885
+ });
886
+ }
887
+ matchesCondition(rule, event) {
888
+ if (!rule.conditionJson || rule.conditionJson === "{}")
889
+ return true;
890
+ try {
891
+ const condition = JSON.parse(rule.conditionJson);
892
+ for (const [key, value] of Object.entries(condition)) {
893
+ if (event.data[key] !== value)
894
+ return false;
895
+ }
896
+ return true;
897
+ } catch {
898
+ return true;
899
+ }
900
+ }
901
+ renderTemplate(body, event) {
902
+ const vars = {
903
+ ...event.data,
904
+ leadId: event.leadId,
905
+ conversationId: event.conversationId,
906
+ tenantId: event.tenantId
907
+ };
908
+ let result = body;
909
+ for (const [key, value] of Object.entries(vars)) {
910
+ result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, "g"), String(value ?? ""));
911
+ }
912
+ return result.replace(/\{\{.*?\}\}/g, "");
913
+ }
914
+ formatMessage(event) {
915
+ const emoji = this.getEventEmoji(event.eventType);
916
+ let msg = `${emoji} <b>${this.getEventTitle(event.eventType)}</b>
917
+
918
+ `;
919
+ if (event.data.displayName) {
920
+ msg += `\uD83D\uDC64 <b>Клиент:</b> ${event.data.displayName}
921
+ `;
922
+ }
923
+ for (const [key, value] of Object.entries(event.data)) {
924
+ if (["displayName", "toStage", "fromStage"].includes(key))
925
+ continue;
926
+ msg += `\uD83D\uDD39 <b>${this.formatKey(key)}:</b> ${value}
927
+ `;
928
+ }
929
+ if (event.data.fromStage && event.data.toStage) {
930
+ msg += `
931
+ \uD83D\uDD04 <b>Стадия:</b> ${event.data.fromStage} ➡️ ${event.data.toStage}
932
+ `;
933
+ } else if (event.data.toStage) {
934
+ msg += `
935
+ \uD83D\uDCCD <b>Стадия:</b> ${event.data.toStage}
936
+ `;
937
+ }
938
+ return msg;
939
+ }
940
+ formatButtons(event) {
941
+ if (event.leadId) {
942
+ return [{ text: "\uD83D\uDC41 Посмотреть", url: `${this.appUrl}/leads/${event.leadId}` }];
943
+ }
944
+ if (event.conversationId) {
945
+ return [{ text: "\uD83D\uDC41 Чат", url: `${this.appUrl}/conversations/${event.conversationId}` }];
946
+ }
947
+ return null;
948
+ }
949
+ getEventEmoji(type) {
950
+ const map = {
951
+ lead_intake_complete: "\uD83C\uDD95",
952
+ stage_changed: "\uD83D\uDD04",
953
+ human_takeover: "\uD83C\uDD98",
954
+ document_uploaded: "\uD83D\uDCF8",
955
+ high_value_deal: "\uD83D\uDC8E",
956
+ lead_stale: "⏰"
957
+ };
958
+ return map[type] ?? "\uD83D\uDD14";
959
+ }
960
+ getEventTitle(type) {
961
+ const map = {
962
+ lead_intake_complete: "Новый лид",
963
+ stage_changed: "Смена стадии",
964
+ human_takeover: "Нужна помощь оператора",
965
+ document_uploaded: "Загружен документ",
966
+ high_value_deal: "Крупная сделка",
967
+ lead_stale: "Лид завис"
968
+ };
969
+ return map[type] ?? "Уведомление";
970
+ }
971
+ formatKey(key) {
972
+ const map = {
973
+ amount: "Сумма",
974
+ asset: "Актив",
975
+ network: "Сеть",
976
+ phone: "Телефон",
977
+ email: "Email"
978
+ };
979
+ return map[key] ?? key;
980
+ }
981
+ }
982
+ // src/operator-bot-handler.ts
983
+ import { TelegramClient as TelegramClient2 } from "@chatman-media/channel-telegram";
984
+
985
+ class OperatorBotHandler {
986
+ repo;
987
+ botToken;
988
+ client = null;
989
+ constructor(repo, botToken) {
990
+ this.repo = repo;
991
+ this.botToken = botToken;
992
+ if (botToken) {
993
+ this.client = new TelegramClient2({ token: botToken });
994
+ }
995
+ }
996
+ async handleUpdate(update) {
997
+ if (!this.client || !update.message)
998
+ return;
999
+ const { message } = update;
1000
+ const text = message.text || "";
1001
+ const chatId = String(message.chat.id);
1002
+ if (text.startsWith("/start ")) {
1003
+ const token = text.split(" ")[1];
1004
+ if (token) {
1005
+ await this.handleLinkToken(token, chatId);
1006
+ return;
1007
+ }
1008
+ }
1009
+ if (text.startsWith("/setup ")) {
1010
+ const token = text.split(" ")[1];
1011
+ if (token) {
1012
+ await this.handleGroupLinkToken(token, message.chat.id, message.chat.title || "группа");
1013
+ return;
1014
+ }
1015
+ }
1016
+ if (text === "/setup" || text.startsWith("/setup@")) {
1017
+ await this.handleSetupGroup(message.chat.id, message.chat.title || "эту группу");
1018
+ return;
1019
+ }
1020
+ if (text === "/start") {
1021
+ await this.client.sendMessage({
1022
+ chatId,
1023
+ text: `\uD83D\uDC4B Привет! Я бот-уведомитель для Lead Engine.
1024
+
1025
+ Чтобы привязать свой аккаунт, перейдите в Админку -> Настройки уведомлений и нажмите 'Подключить Telegram'.`
1026
+ });
1027
+ }
1028
+ }
1029
+ async handleLinkToken(token, chatId) {
1030
+ if (!this.client)
1031
+ return;
1032
+ const settings = await this.repo.findByLinkToken(token);
1033
+ if (!settings) {
1034
+ await this.client.sendMessage({
1035
+ chatId,
1036
+ text: "❌ Ссылка недействительна или истекла. Пожалуйста, сгенерируйте новую в Админке."
1037
+ });
1038
+ return;
1039
+ }
1040
+ await this.repo.linkChat(settings.adminId, chatId);
1041
+ await this.client.sendMessage({
1042
+ chatId,
1043
+ text: "✅ Аккаунт успешно привязан! Теперь вы будете получать важные уведомления здесь."
1044
+ });
1045
+ }
1046
+ async handleGroupLinkToken(token, chatId, title) {
1047
+ if (!this.client)
1048
+ return;
1049
+ const isGroup = chatId < 0;
1050
+ if (!isGroup) {
1051
+ await this.client.sendMessage({
1052
+ chatId: String(chatId),
1053
+ text: "❌ /setup с токеном работает только в группах. Добавьте бота в группу и отправьте там эту команду."
1054
+ });
1055
+ return;
1056
+ }
1057
+ const groupToken = await this.repo.findGroupLinkToken(token);
1058
+ if (!groupToken) {
1059
+ await this.client.sendMessage({
1060
+ chatId: String(chatId),
1061
+ text: "❌ Токен недействителен или истёк. Сгенерируйте новый в Админке → Уведомления."
1062
+ });
1063
+ return;
1064
+ }
1065
+ await this.repo.createRule({
1066
+ tenantId: groupToken.tenantId,
1067
+ eventType: groupToken.eventType,
1068
+ conditionJson: "{}",
1069
+ channelType: "telegram_group",
1070
+ targetId: String(chatId),
1071
+ priority: "normal",
1072
+ isActive: true
1073
+ });
1074
+ await this.repo.deleteGroupLinkToken(token);
1075
+ await this.client.sendMessage({
1076
+ chatId: String(chatId),
1077
+ text: `✅ Группа <b>${title}</b> подключена к Lead Engine!
1078
+
1079
+ Отсюда вы будете получать уведомления о событиях: <b>${groupToken.eventType}</b>.`,
1080
+ parseMode: "HTML"
1081
+ });
1082
+ }
1083
+ async handleSetupGroup(chatId, title) {
1084
+ if (!this.client)
1085
+ return;
1086
+ const isGroup = chatId < 0;
1087
+ if (!isGroup) {
1088
+ await this.client.sendMessage({
1089
+ chatId: String(chatId),
1090
+ text: "команда /setup работает только в группах. Добавьте меня в группу и напишите там /setup."
1091
+ });
1092
+ return;
1093
+ }
1094
+ await this.client.sendMessage({
1095
+ chatId: String(chatId),
1096
+ text: `\uD83C\uDFD7 <b>Настройка группы "${title}"</b>
1097
+
1098
+ ID этой группы: <code>${chatId}</code>
1099
+
1100
+ Скопируйте этот ID и вставьте его в настройки уведомлений в Админке, чтобы бот мог присылать сюда алерты.`,
1101
+ parseMode: "HTML"
1102
+ });
1103
+ }
1104
+ }
1105
+ // src/funnel-machine.ts
1106
+ class FunnelTransitionError extends Error {
1107
+ fromState;
1108
+ toState;
1109
+ templateSlug;
1110
+ constructor(fromState, toState, templateSlug, reason) {
1111
+ super(`funnel transition rejected: ${fromState} → ${toState} ` + `(template=${templateSlug}, ${reason})`);
1112
+ this.fromState = fromState;
1113
+ this.toState = toState;
1114
+ this.templateSlug = templateSlug;
1115
+ this.name = "FunnelTransitionError";
1116
+ }
1117
+ }
1118
+ function findStage(template, slug) {
1119
+ return template.funnelStages.find((s) => s.slug === slug);
1120
+ }
1121
+ function getInitialStage(template) {
1122
+ const first = template.funnelStages[0];
1123
+ if (!first) {
1124
+ throw new Error(`funnel-machine: template "${template.slug}" has no funnelStages`);
1125
+ }
1126
+ return first;
1127
+ }
1128
+ function validateTransition(template, fromState, toState) {
1129
+ if (fromState === toState)
1130
+ return;
1131
+ const from = findStage(template, fromState);
1132
+ if (!from) {
1133
+ throw new FunnelTransitionError(fromState, toState, template.slug, `from-state not in template`);
1134
+ }
1135
+ const to = findStage(template, toState);
1136
+ if (!to) {
1137
+ throw new FunnelTransitionError(fromState, toState, template.slug, `to-state not in template`);
1138
+ }
1139
+ if (from.kind === "terminal") {
1140
+ throw new FunnelTransitionError(fromState, toState, template.slug, `terminal stage cannot transition`);
1141
+ }
1142
+ if (!from.next || !from.next.includes(toState)) {
1143
+ throw new FunnelTransitionError(fromState, toState, template.slug, `not in from.next [${(from.next ?? []).join(", ")}]`);
1144
+ }
1145
+ }
1146
+ function allowedTransitions(template, fromState) {
1147
+ const from = findStage(template, fromState);
1148
+ if (!from || from.kind === "terminal")
1149
+ return [];
1150
+ return [...from.next ?? []];
1151
+ }
1152
+ function isTerminal(template, state) {
1153
+ return findStage(template, state)?.kind === "terminal";
1154
+ }
1155
+ // src/lead-lifecycle.ts
1156
+ async function ensureLead(opts) {
1157
+ const existing = await opts.leads.findByContactId(opts.contactId);
1158
+ if (existing)
1159
+ return { lead: existing, created: false };
1160
+ const created = await opts.leads.create({
1161
+ contactId: opts.contactId,
1162
+ state: getInitialStage(opts.template).slug,
1163
+ nowEpoch: opts.nowEpoch
1164
+ });
1165
+ return { lead: created, created: true };
1166
+ }
1167
+ async function transitionLeadState(opts) {
1168
+ validateTransition(opts.template, opts.lead.state, opts.toState);
1169
+ if (opts.lead.state === opts.toState) {
1170
+ return opts.lead;
1171
+ }
1172
+ const fromState = opts.lead.state;
1173
+ await opts.leads.updateState(opts.lead.id, opts.toState, opts.nowEpoch);
1174
+ const updated = { ...opts.lead, state: opts.toState, updatedAt: opts.nowEpoch };
1175
+ if (opts.notifications) {
1176
+ await opts.notifications.notify({
1177
+ tenantId: opts.lead.tenantId,
1178
+ eventType: "stage_changed",
1179
+ leadId: opts.lead.id,
1180
+ contactId: opts.lead.userId,
1181
+ assignedAdminId: opts.lead.assignedAdminId ?? undefined,
1182
+ data: {
1183
+ fromStage: fromState,
1184
+ toStage: opts.toState
1185
+ }
1186
+ });
1187
+ }
1188
+ const hook = opts.template.hooks?.onLeadStageChange;
1189
+ if (hook) {
1190
+ const ctx = {
1191
+ tenantId: opts.lead.tenantId,
1192
+ contactId: opts.lead.userId,
1193
+ leadId: opts.lead.id,
1194
+ template: opts.template,
1195
+ ...opts.hookContext
1196
+ };
1197
+ await hook(ctx, fromState, opts.toState);
1198
+ }
1199
+ return updated;
1200
+ }
1201
+ // src/reply-strategy/rag-reply.ts
1202
+ import {
1203
+ answerWithRag,
1204
+ DEFAULT_PERSONA,
1205
+ generateSoftFallback,
1206
+ NO_CONTEXT_MARKER,
1207
+ StyleSchema
1208
+ } from "@chatman-media/kb";
1209
+
1210
+ // src/compact-conversation.ts
1211
+ async function compactConversation(messages, chat) {
1212
+ if (messages.length === 0)
1213
+ return null;
1214
+ const transcript = messages.map((m) => {
1215
+ const role = m.role === "user" ? "Кандидат" : "Бот";
1216
+ return `${role}: ${String(m.content ?? "").trim()}`;
1217
+ }).join(`
1218
+ `);
1219
+ const systemPrompt = [
1220
+ "Ты — суммаризатор диалогов продаж.",
1221
+ "Тебе дан фрагмент переписки между ботом и кандидатом.",
1222
+ "Напиши КРАТКОЕ резюме на русском языке — 3-7 пунктов:",
1223
+ " • Что узнал кандидат (вопросы, интересы).",
1224
+ " • Что уже выяснено (бюджет, сроки, параметры).",
1225
+ " • На каком этапе находится диалог.",
1226
+ " • Любые особые договорённости или отказы.",
1227
+ "",
1228
+ "Формат — маркированный список. Без вводных фраз. Только факты."
1229
+ ].join(`
1230
+ `);
1231
+ const raw = await chat.complete([
1232
+ { role: "system", content: systemPrompt },
1233
+ { role: "user", content: `Диалог:
1234
+ ${transcript}` }
1235
+ ], { temperature: 0, numPredict: 400 });
1236
+ return raw.trim() || null;
1237
+ }
1238
+
1239
+ // src/reply-strategy/rag-reply.ts
1240
+ function messagesToChatHistory(history) {
1241
+ const out = [];
1242
+ for (const m of history) {
1243
+ if (m.role === "user")
1244
+ out.push({ role: "user", content: m.text });
1245
+ else if (m.role === "assistant" || m.role === "human")
1246
+ out.push({ role: "assistant", content: m.text });
1247
+ }
1248
+ return out;
1249
+ }
1250
+ function parseStyleConfig(configJson) {
1251
+ try {
1252
+ const raw = JSON.parse(configJson);
1253
+ return StyleSchema.parse(raw);
1254
+ } catch {
1255
+ return null;
1256
+ }
1257
+ }
1258
+
1259
+ class RagReplyStrategy {
1260
+ opts;
1261
+ messagesRepoFor;
1262
+ constructor(opts, messagesRepoFor) {
1263
+ this.opts = opts;
1264
+ this.messagesRepoFor = messagesRepoFor;
1265
+ }
1266
+ async generate(input) {
1267
+ if (input.userMessageText.length === 0)
1268
+ return null;
1269
+ const tenantId = input.tenant.tenantId;
1270
+ if (this.opts.resolveIsSupport) {
1271
+ const isSupport = await this.opts.resolveIsSupport({ tenantId, contactId: input.contactId });
1272
+ if (isSupport)
1273
+ return null;
1274
+ }
1275
+ const messagesRepo = this.messagesRepoFor(tenantId);
1276
+ const chat = this.opts.resolveChat(tenantId);
1277
+ const compactThreshold = this.opts.compactAfterMessages ?? 20;
1278
+ let conversationSummary;
1279
+ const [allRecent, totalCount] = await Promise.all([
1280
+ messagesRepo.recent(input.conversationId, (this.opts.historyLimit ?? 12) + 1),
1281
+ compactThreshold > 0 ? messagesRepo.countByConversation(input.conversationId) : Promise.resolve(0)
1282
+ ]);
1283
+ if (compactThreshold > 0 && totalCount >= compactThreshold) {
1284
+ const convsRepo = this.opts.resolveConversations?.(tenantId);
1285
+ const convo = convsRepo ? await convsRepo.findById(input.conversationId) : null;
1286
+ if (convo?.summaryJson) {
1287
+ try {
1288
+ conversationSummary = JSON.parse(convo.summaryJson);
1289
+ } catch {
1290
+ conversationSummary = convo.summaryJson;
1291
+ }
1292
+ }
1293
+ const shouldRecompact = !conversationSummary || totalCount % compactThreshold === 0;
1294
+ if (shouldRecompact) {
1295
+ const chatHistory = messagesToChatHistory(allRecent);
1296
+ const freshSummary = await compactConversation(chatHistory, chat).catch((err) => {
1297
+ console.warn("[rag-reply] compaction failed:", err);
1298
+ return null;
1299
+ });
1300
+ if (freshSummary) {
1301
+ conversationSummary = freshSummary;
1302
+ convsRepo?.setSummaryJson(input.conversationId, JSON.stringify(freshSummary)).catch((err) => console.warn("[rag-reply] failed to save summary:", err));
1303
+ }
1304
+ }
1305
+ }
1306
+ const historyWithoutCurrent = allRecent.filter((m) => m.text !== input.userMessageText);
1307
+ const history = messagesToChatHistory(historyWithoutCurrent);
1308
+ const embedder = this.opts.resolveEmbed(tenantId);
1309
+ const kb = this.opts.resolveKb(tenantId);
1310
+ const style = this.opts.resolveStyle ? await this.opts.resolveStyle({
1311
+ tenantId,
1312
+ conversationId: input.conversationId,
1313
+ contactId: input.contactId
1314
+ }) : null;
1315
+ const [skills, directorHooks, tools, reranker] = await Promise.all([
1316
+ this.opts.resolveSkills ? this.opts.resolveSkills({ tenantId }) : Promise.resolve([]),
1317
+ this.opts.resolveDirectorHooks ? this.opts.resolveDirectorHooks({ tenantId }) : Promise.resolve([]),
1318
+ this.opts.resolveTools ? this.opts.resolveTools({ tenantId, conversationId: input.conversationId }) : Promise.resolve([]),
1319
+ this.opts.resolveReranker ? this.opts.resolveReranker({ tenantId }) : Promise.resolve(null)
1320
+ ]);
1321
+ const result = await answerWithRag({
1322
+ question: input.userMessageText,
1323
+ kb,
1324
+ embedder,
1325
+ chat,
1326
+ history,
1327
+ topK: this.opts.topK ?? 5,
1328
+ hybridSearch: this.opts.hybridSearch ?? true,
1329
+ rewriteQueryBeforeRetrieval: this.opts.rewriteQueryBeforeRetrieval ?? true,
1330
+ reflect: this.opts.reflect ?? false,
1331
+ numPredict: this.opts.maxOutputTokens ?? 600,
1332
+ ...style ? { style } : {},
1333
+ ...skills.length > 0 ? { skills } : {},
1334
+ ...directorHooks.length > 0 ? { directorHooks } : {},
1335
+ ...tools.length > 0 ? { tools } : {},
1336
+ ...reranker ? { reranker } : {},
1337
+ ...conversationSummary ? { conversationSummary } : {}
1338
+ });
1339
+ if (result.text === NO_CONTEXT_MARKER || !result.text || result.text.trim().length === 0) {
1340
+ if (this.opts.resolveSuggestions) {
1341
+ const suggestionsRepo = this.opts.resolveSuggestions(tenantId);
1342
+ const nowEpoch = Math.floor(Date.now() / 1000);
1343
+ suggestionsRepo.log({
1344
+ questionText: input.userMessageText,
1345
+ sourceConversationId: input.conversationId,
1346
+ nowEpoch
1347
+ }).catch((err) => {
1348
+ console.warn("[rag-reply] failed to log kb_suggestion:", err);
1349
+ });
1350
+ }
1351
+ if (!this.opts.softFallback)
1352
+ return null;
1353
+ const persona = style ? {
1354
+ name: style.persona.name,
1355
+ role: style.persona.role,
1356
+ ...style.persona.company?.trim() ? { company: style.persona.company.trim() } : {}
1357
+ } : DEFAULT_PERSONA;
1358
+ const fallbackText = await generateSoftFallback({
1359
+ question: input.userMessageText,
1360
+ chat,
1361
+ persona,
1362
+ history
1363
+ });
1364
+ if (!fallbackText || fallbackText.trim().length === 0)
1365
+ return null;
1366
+ return [
1367
+ {
1368
+ channelId: String(input.channel.channelId),
1369
+ externalUserId: input.inbound.externalUserId,
1370
+ parts: [{ kind: "text", text: fallbackText }]
1371
+ }
1372
+ ];
1373
+ }
1374
+ return [
1375
+ {
1376
+ channelId: String(input.channel.channelId),
1377
+ externalUserId: input.inbound.externalUserId,
1378
+ parts: [{ kind: "text", text: result.text }]
1379
+ }
1380
+ ];
1381
+ }
1382
+ }
1383
+
1384
+ // src/experiment-router.ts
1385
+ async function loadExperimentVariants(experiment, stylesRepo) {
1386
+ const entries = parseAllocation(experiment.allocationJson);
1387
+ const variants = [];
1388
+ for (const entry of entries) {
1389
+ const row = await stylesRepo.findActiveBySlug(entry.styleSlug);
1390
+ if (!row) {
1391
+ console.warn(`[experiment-router] experiment=${experiment.slug}: style=${entry.styleSlug} not found, skipping`);
1392
+ continue;
1393
+ }
1394
+ const style = parseStyleConfig(row.configJson);
1395
+ if (!style) {
1396
+ console.warn(`[experiment-router] experiment=${experiment.slug}: style=${entry.styleSlug} invalid config_json, skipping`);
1397
+ continue;
1398
+ }
1399
+ variants.push({ style, weight: entry.weight });
1400
+ }
1401
+ if (variants.length === 0)
1402
+ return null;
1403
+ return variants;
1404
+ }
1405
+ // src/memory-extractor.ts
1406
+ import { extractUserFacts } from "@chatman-media/kb";
1407
+
1408
+ class LlmMemoryExtractor {
1409
+ opts;
1410
+ messagesRepoFor;
1411
+ constructor(opts, messagesRepoFor) {
1412
+ this.opts = opts;
1413
+ this.messagesRepoFor = messagesRepoFor;
1414
+ }
1415
+ async extract(input) {
1416
+ const messagesRepo = this.messagesRepoFor(input.tenantId);
1417
+ const history = await messagesRepo.recent(input.conversationId, this.opts.historyLimit ?? 10);
1418
+ if (history.length === 0)
1419
+ return {};
1420
+ const llmMessages = mapToRagMessages(history);
1421
+ if (llmMessages.length === 0)
1422
+ return {};
1423
+ const chat = this.opts.resolveChat(input.tenantId);
1424
+ return extractUserFacts({
1425
+ messages: llmMessages,
1426
+ chat,
1427
+ existingFacts: input.existingFacts
1428
+ });
1429
+ }
1430
+ }
1431
+ function mapToRagMessages(history) {
1432
+ const out = [];
1433
+ for (const m of history) {
1434
+ if (m.role === "user")
1435
+ out.push({ role: "user", content: m.text });
1436
+ else if (m.role === "assistant" || m.role === "human")
1437
+ out.push({ role: "assistant", content: m.text });
1438
+ }
1439
+ return out;
1440
+ }
1441
+ async function runMemoryExtraction(opts) {
1442
+ const contact = await opts.contacts.byId(opts.contactId);
1443
+ if (!contact)
1444
+ return {};
1445
+ const existing = {};
1446
+ if (contact.attributesJson) {
1447
+ const parsed = JSON.parse(contact.attributesJson);
1448
+ for (const [k, v] of Object.entries(parsed)) {
1449
+ if (typeof v === "string")
1450
+ existing[k] = v;
1451
+ }
1452
+ }
1453
+ const newFacts = await opts.extractor.extract({
1454
+ tenantId: opts.tenantId,
1455
+ conversationId: opts.conversationId,
1456
+ contactId: opts.contactId,
1457
+ existingFacts: existing
1458
+ });
1459
+ if (Object.keys(newFacts).length > 0) {
1460
+ await opts.contacts.mergeAttributes(opts.contactId, newFacts, opts.nowEpoch);
1461
+ }
1462
+ return newFacts;
1463
+ }
1464
+ // src/stage-classifier.ts
1465
+ import { conversations as conversationsTable2 } from "@chatman-media/storage";
1466
+ import { and as and11, eq as eq11 } from "drizzle-orm";
1467
+ async function applyClassifiedStage(opts) {
1468
+ if (!opts.newStage)
1469
+ 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)));
1471
+ if (!row || row.stage === opts.newStage)
1472
+ return false;
1473
+ await opts.db.update(conversationsTable2).set({ currentStage: opts.newStage }).where(and11(eq11(conversationsTable2.id, opts.conversationId), eq11(conversationsTable2.tenantId, opts.tenantId)));
1474
+ return true;
1475
+ }
1476
+ // src/secrets.ts
1477
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
1478
+ import { tenantSecrets } from "@chatman-media/storage";
1479
+ import { and as and12, eq as eq12 } from "drizzle-orm";
1480
+ var ALGORITHM = "aes-256-gcm";
1481
+ var IV_LEN = 12;
1482
+ var AUTH_TAG_LEN = 16;
1483
+
1484
+ class SecretCryptoError extends Error {
1485
+ constructor(reason) {
1486
+ super(`SecretCrypto: ${reason}`);
1487
+ this.name = "SecretCryptoError";
1488
+ }
1489
+ }
1490
+ function masterKeyBytes(masterKeyHex) {
1491
+ if (!/^[0-9a-fA-F]+$/.test(masterKeyHex)) {
1492
+ throw new SecretCryptoError("master key must be hex");
1493
+ }
1494
+ const buf = Buffer.from(masterKeyHex, "hex");
1495
+ if (buf.length !== 32) {
1496
+ throw new SecretCryptoError(`master key must be 32 bytes (64 hex chars), got ${buf.length}`);
1497
+ }
1498
+ return buf;
1499
+ }
1500
+ function encryptSecret(masterKeyHex, plaintext) {
1501
+ const key = masterKeyBytes(masterKeyHex);
1502
+ const iv = randomBytes(IV_LEN);
1503
+ const cipher = createCipheriv(ALGORITHM, key, iv);
1504
+ const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
1505
+ const authTag = cipher.getAuthTag();
1506
+ if (authTag.length !== AUTH_TAG_LEN) {
1507
+ throw new SecretCryptoError(`unexpected auth tag length ${authTag.length}`);
1508
+ }
1509
+ return [
1510
+ iv.toString("base64"),
1511
+ authTag.toString("base64"),
1512
+ encrypted.toString("base64")
1513
+ ].join(":");
1514
+ }
1515
+ function decryptSecret(masterKeyHex, ciphertext) {
1516
+ const key = masterKeyBytes(masterKeyHex);
1517
+ const parts = ciphertext.split(":");
1518
+ if (parts.length !== 3) {
1519
+ throw new SecretCryptoError("ciphertext format must be iv:tag:payload");
1520
+ }
1521
+ const [ivB64, tagB64, payloadB64] = parts;
1522
+ const iv = Buffer.from(ivB64, "base64");
1523
+ const authTag = Buffer.from(tagB64, "base64");
1524
+ const payload = Buffer.from(payloadB64, "base64");
1525
+ if (iv.length !== IV_LEN)
1526
+ throw new SecretCryptoError("bad iv length");
1527
+ if (authTag.length !== AUTH_TAG_LEN)
1528
+ throw new SecretCryptoError("bad auth tag length");
1529
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
1530
+ decipher.setAuthTag(authTag);
1531
+ try {
1532
+ const decrypted = Buffer.concat([decipher.update(payload), decipher.final()]);
1533
+ return decrypted.toString("utf8");
1534
+ } catch {
1535
+ throw new SecretCryptoError("auth tag mismatch or corrupted ciphertext");
1536
+ }
1537
+ }
1538
+ 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)));
1540
+ if (!row)
1541
+ return null;
1542
+ return decryptSecret(opts.masterKeyHex, row.encryptedValue);
1543
+ }
1544
+ async function setEncryptedSecret(opts) {
1545
+ const enc = encryptSecret(opts.masterKeyHex, opts.value);
1546
+ await opts.db.insert(tenantSecrets).values({
1547
+ tenantId: opts.tenantId,
1548
+ key: opts.key,
1549
+ encryptedValue: enc,
1550
+ createdAt: opts.nowEpoch,
1551
+ updatedAt: opts.nowEpoch
1552
+ }).onConflictDoUpdate({
1553
+ target: [tenantSecrets.tenantId, tenantSecrets.key],
1554
+ set: { encryptedValue: enc, updatedAt: opts.nowEpoch }
1555
+ });
1556
+ }
1557
+ // src/outbound-dispatch.ts
1558
+ async function dispatchOutbound(opts) {
1559
+ return opts.outbound.enqueue({
1560
+ channelId: opts.channelDbId,
1561
+ conversationId: opts.conversationId,
1562
+ envelope: opts.envelope,
1563
+ nowEpoch: opts.nowEpoch
1564
+ });
1565
+ }
1566
+
1567
+ // src/types.ts
1568
+ var systemClock = {
1569
+ nowEpoch: () => Math.floor(Date.now() / 1000)
1570
+ };
1571
+
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
+ // src/dispatch-reply.ts
1582
+ async function generateReplyAndEnqueue(deps) {
1583
+ const clock = deps.clock ?? systemClock;
1584
+ const { result, replyStrategy, inbound, tenant, channel, channelDbId } = deps;
1585
+ if (result.mediaOnly) {
1586
+ return { outboundEnqueued: 0 };
1587
+ }
1588
+ const text = result.userMessageText ?? "";
1589
+ if (text.length === 0)
1590
+ return { outboundEnqueued: 0 };
1591
+ const envelopes = await replyStrategy.generate({
1592
+ tenant,
1593
+ channel,
1594
+ conversationId: result.conversationId,
1595
+ contactId: result.contactId,
1596
+ inbound,
1597
+ userMessageText: text
1598
+ });
1599
+ if (!envelopes || envelopes.length === 0) {
1600
+ return { outboundEnqueued: 0 };
1601
+ }
1602
+ const now = clock.nowEpoch();
1603
+ let count = 0;
1604
+ await withTenant(deps.db, tenant.tenantId, async (tx) => {
1605
+ const outbound = new OutboundQueueRepo({ db: tx, tenantId: tenant.tenantId });
1606
+ for (const env of envelopes) {
1607
+ const queued = await dispatchOutbound({
1608
+ channelDbId,
1609
+ conversationId: result.conversationId,
1610
+ envelope: env,
1611
+ outbound,
1612
+ nowEpoch: now
1613
+ });
1614
+ count += 1;
1615
+ deps.sink?.emit?.({
1616
+ type: "outbound-enqueued",
1617
+ tenantId: tenant.tenantId,
1618
+ conversationId: result.conversationId,
1619
+ queueItemId: queued.id,
1620
+ envelope: env
1621
+ });
1622
+ }
1623
+ });
1624
+ return { outboundEnqueued: count };
1625
+ }
1626
+ // src/process-inbound.ts
1627
+ function inboundText(inbound) {
1628
+ const textParts = [];
1629
+ let hasMedia = false;
1630
+ for (const part of inbound.parts) {
1631
+ if (part.kind === "text")
1632
+ textParts.push(part.text);
1633
+ else if ("caption" in part && part.caption)
1634
+ textParts.push(part.caption);
1635
+ if (part.kind !== "text" && part.kind !== "callback_query")
1636
+ hasMedia = true;
1637
+ }
1638
+ const text = textParts.join(`
1639
+ `).trim();
1640
+ return { text, mediaOnly: hasMedia && text.length === 0 };
1641
+ }
1642
+ async function processInbound(inbound, deps) {
1643
+ const clock = deps.clock ?? systemClock;
1644
+ const now = clock.nowEpoch();
1645
+ deps.sink?.log?.("info", "inbound received", {
1646
+ tenantId: deps.tenant.tenantId,
1647
+ channelId: deps.channel.channelId,
1648
+ externalUserId: inbound.externalUserId,
1649
+ parts: inbound.parts.length
1650
+ });
1651
+ const contact = await resolveContact({
1652
+ inbound,
1653
+ channelDbId: deps.channelDbId,
1654
+ contacts: deps.contacts,
1655
+ identities: deps.identities
1656
+ });
1657
+ const { conversation, created: conversationCreated } = await resolveConversation({
1658
+ contactId: contact.id,
1659
+ channelKind: deps.channel.kind,
1660
+ conversations: deps.conversations,
1661
+ nowEpoch: now
1662
+ });
1663
+ deps.sink?.emit?.({
1664
+ type: "inbound-received",
1665
+ tenantId: deps.tenant.tenantId,
1666
+ conversationId: conversation.id,
1667
+ inbound
1668
+ });
1669
+ if (deps.transcriber && deps.downloadVoice) {
1670
+ for (const part of inbound.parts) {
1671
+ if (part.kind === "voice") {
1672
+ try {
1673
+ const res = await deps.downloadVoice(part.mediaRef, inbound.externalUserId);
1674
+ if (res.ok) {
1675
+ const buf = await res.arrayBuffer();
1676
+ const transcript = await deps.transcriber.transcribe(new Uint8Array(buf), "voice.ogg");
1677
+ if (transcript) {
1678
+ const idx = inbound.parts.indexOf(part);
1679
+ inbound.parts[idx] = {
1680
+ kind: "text",
1681
+ text: transcript
1682
+ };
1683
+ }
1684
+ }
1685
+ } catch (err) {
1686
+ deps.sink?.log?.("warn", "voice transcription failed", {
1687
+ tenantId: deps.tenant.tenantId,
1688
+ error: err.message
1689
+ });
1690
+ }
1691
+ }
1692
+ }
1693
+ }
1694
+ const { text, mediaOnly } = inboundText(inbound);
1695
+ const isCallback = inbound.parts.some((p) => p.kind === "callback_query");
1696
+ if (isCallback) {
1697
+ return {
1698
+ contactId: contact.id,
1699
+ conversationId: conversation.id,
1700
+ persisted: false,
1701
+ outboundEnqueued: 0
1702
+ };
1703
+ }
1704
+ const existingMsg = await deps.messages.findUserByExternalId(conversation.id, inbound.externalMessageId);
1705
+ let messageId;
1706
+ if (existingMsg) {
1707
+ deps.sink?.log?.("debug", "inbound dedup hit", {
1708
+ conversationId: conversation.id,
1709
+ externalMessageId: inbound.externalMessageId
1710
+ });
1711
+ messageId = existingMsg.id;
1712
+ } else {
1713
+ const inserted = await deps.messages.insert({
1714
+ conversationId: conversation.id,
1715
+ role: "user",
1716
+ text,
1717
+ externalMessageId: inbound.externalMessageId,
1718
+ ...inbound.parts.some((p) => p.kind !== "text") ? { metaJson: JSON.stringify({ parts: inbound.parts }) } : {},
1719
+ nowEpoch: now
1720
+ });
1721
+ messageId = inserted.id;
1722
+ deps.sink?.emit?.({
1723
+ type: "message-persisted",
1724
+ tenantId: deps.tenant.tenantId,
1725
+ conversationId: conversation.id,
1726
+ messageId: inserted.id,
1727
+ role: "user"
1728
+ });
1729
+ }
1730
+ if (!existingMsg) {
1731
+ await deps.conversations.updateInboxMetadata(conversation.id, {
1732
+ lastMessageAt: now,
1733
+ lastMessageText: text.slice(0, 200) || "(Медиа)",
1734
+ incrementUnread: true,
1735
+ ...conversation.status === "resolved" ? { status: "open" } : {}
1736
+ });
1737
+ }
1738
+ const hasMedia = inbound.parts.some((p) => p.kind !== "text" && p.kind !== "callback_query");
1739
+ const hasVideoNote = inbound.parts.some((p) => p.kind === "video_note");
1740
+ if (deps.notifications && !existingMsg) {
1741
+ if (conversation.mode === "human" || conversation.mode === "queued") {
1742
+ await deps.notifications.notify({
1743
+ tenantId: deps.tenant.tenantId,
1744
+ eventType: "human_takeover",
1745
+ conversationId: conversation.id,
1746
+ contactId: contact.id,
1747
+ data: {
1748
+ displayName: contact.displayName || "Без имени",
1749
+ text: text || "(Медиа)"
1750
+ }
1751
+ });
1752
+ } else if (hasVideoNote) {
1753
+ await deps.notifications.notify({
1754
+ tenantId: deps.tenant.tenantId,
1755
+ eventType: "verification_requested",
1756
+ conversationId: conversation.id,
1757
+ contactId: contact.id,
1758
+ data: {
1759
+ displayName: contact.displayName || "Без имени",
1760
+ text: text || "(Видео-кружок для верификации)"
1761
+ }
1762
+ });
1763
+ } else if (hasMedia) {
1764
+ await deps.notifications.notify({
1765
+ tenantId: deps.tenant.tenantId,
1766
+ eventType: "document_uploaded",
1767
+ conversationId: conversation.id,
1768
+ contactId: contact.id,
1769
+ data: {
1770
+ displayName: contact.displayName || "Без имени"
1771
+ }
1772
+ });
1773
+ }
1774
+ }
1775
+ if (deps.template?.hooks?.extractFields && !existingMsg && text.length > 0) {
1776
+ try {
1777
+ const extracted = await deps.template.hooks.extractFields({ tenantId: deps.tenant.tenantId, contactId: contact.id, conversationId: conversation.id }, text);
1778
+ if (extracted && Object.keys(extracted).length > 0) {
1779
+ await deps.contacts.mergeAttributes(contact.id, extracted, now);
1780
+ }
1781
+ } catch (err) {
1782
+ deps.sink?.log?.("warn", "extractFields hook failed", {
1783
+ tenantId: deps.tenant.tenantId,
1784
+ conversationId: conversation.id,
1785
+ error: err instanceof Error ? err.message : String(err)
1786
+ });
1787
+ }
1788
+ }
1789
+ if (deps.stageClassifier && deps.db && !existingMsg && text.length > 0) {
1790
+ try {
1791
+ const newStage = await deps.stageClassifier.classify({
1792
+ tenantId: deps.tenant.tenantId,
1793
+ userMessageText: text,
1794
+ previousStage: conversation.currentStage,
1795
+ isFirstUserMessage: conversationCreated
1796
+ });
1797
+ const changed = await applyClassifiedStage({
1798
+ db: deps.db,
1799
+ tenantId: deps.tenant.tenantId,
1800
+ conversationId: conversation.id,
1801
+ newStage
1802
+ });
1803
+ if (changed) {
1804
+ deps.sink?.log?.("debug", "conversation stage classified", {
1805
+ tenantId: deps.tenant.tenantId,
1806
+ conversationId: conversation.id,
1807
+ from: conversation.currentStage,
1808
+ to: newStage
1809
+ });
1810
+ }
1811
+ } catch (err) {
1812
+ deps.sink?.log?.("warn", "stage classifier failed", {
1813
+ tenantId: deps.tenant.tenantId,
1814
+ conversationId: conversation.id,
1815
+ error: err instanceof Error ? err.message : String(err)
1816
+ });
1817
+ }
1818
+ }
1819
+ if (deps.memoryExtractor && !existingMsg && text.length > 0) {
1820
+ try {
1821
+ const extracted = await runMemoryExtraction({
1822
+ extractor: deps.memoryExtractor,
1823
+ tenantId: deps.tenant.tenantId,
1824
+ conversationId: conversation.id,
1825
+ contactId: contact.id,
1826
+ contacts: deps.contacts,
1827
+ nowEpoch: now
1828
+ });
1829
+ if (Object.keys(extracted).length > 0) {
1830
+ deps.sink?.log?.("debug", "memory facts extracted", {
1831
+ tenantId: deps.tenant.tenantId,
1832
+ conversationId: conversation.id,
1833
+ keys: Object.keys(extracted)
1834
+ });
1835
+ }
1836
+ } catch (err) {
1837
+ deps.sink?.log?.("warn", "memory extractor failed", {
1838
+ tenantId: deps.tenant.tenantId,
1839
+ conversationId: conversation.id,
1840
+ error: err instanceof Error ? err.message : String(err)
1841
+ });
1842
+ }
1843
+ }
1844
+ if (deps.deferReply) {
1845
+ return {
1846
+ contactId: contact.id,
1847
+ conversationId: conversation.id,
1848
+ persisted: !existingMsg,
1849
+ outboundEnqueued: 0,
1850
+ userMessageText: text,
1851
+ mediaOnly,
1852
+ replyDeferred: true
1853
+ };
1854
+ }
1855
+ let outboundCount = 0;
1856
+ if (conversation.mode === "ai" && deps.reply && !mediaOnly) {
1857
+ const envelopes = await deps.reply.generate({
1858
+ tenant: deps.tenant,
1859
+ channel: deps.channel,
1860
+ conversationId: conversation.id,
1861
+ contactId: contact.id,
1862
+ inbound,
1863
+ userMessageText: text
1864
+ });
1865
+ if (envelopes && envelopes.length > 0) {
1866
+ const lastEnv = envelopes[envelopes.length - 1];
1867
+ if (lastEnv) {
1868
+ const aiText = lastEnv.parts.find((p) => p.kind === "text")?.text;
1869
+ if (aiText) {
1870
+ await deps.conversations.updateInboxMetadata(conversation.id, {
1871
+ lastMessageText: aiText.slice(0, 200)
1872
+ });
1873
+ }
1874
+ }
1875
+ for (const env of envelopes) {
1876
+ const queued = await dispatchOutbound({
1877
+ channelDbId: deps.channelDbId,
1878
+ conversationId: conversation.id,
1879
+ envelope: env,
1880
+ outbound: deps.outbound,
1881
+ nowEpoch: now
1882
+ });
1883
+ outboundCount += 1;
1884
+ deps.sink?.emit?.({
1885
+ type: "outbound-enqueued",
1886
+ tenantId: deps.tenant.tenantId,
1887
+ conversationId: conversation.id,
1888
+ queueItemId: queued.id,
1889
+ envelope: env
1890
+ });
1891
+ }
1892
+ }
1893
+ }
1894
+ return {
1895
+ contactId: contact.id,
1896
+ conversationId: conversation.id,
1897
+ persisted: !existingMsg,
1898
+ outboundEnqueued: outboundCount
1899
+ };
1900
+ }
1901
+ // src/reply-strategy/llm-reply.ts
1902
+ var BASE_SYSTEM_PROMPT = "Ты — операционный бот платформы lead-engine. Отвечай кратко, " + "уважительно, по делу. Никогда не выдумывай факты которых нет в " + "контексте — лучше скажи «уточню у партнёра» и поставь сообщение в очередь оператора.";
1903
+ function messagesToChatHistory2(history) {
1904
+ const out = [];
1905
+ for (const m of history) {
1906
+ if (m.role === "user")
1907
+ out.push({ role: "user", content: m.text });
1908
+ else if (m.role === "assistant" || m.role === "human")
1909
+ out.push({ role: "assistant", content: m.text });
1910
+ }
1911
+ return out;
1912
+ }
1913
+
1914
+ class LlmReplyStrategy {
1915
+ opts;
1916
+ messagesRepoFor;
1917
+ constructor(opts, messagesRepoFor) {
1918
+ this.opts = opts;
1919
+ this.messagesRepoFor = messagesRepoFor;
1920
+ }
1921
+ async generate(input) {
1922
+ if (input.userMessageText.length === 0)
1923
+ return null;
1924
+ if (this.opts.resolveIsSupport) {
1925
+ const isSupport = await this.opts.resolveIsSupport({
1926
+ tenantId: input.tenant.tenantId,
1927
+ contactId: input.contactId
1928
+ });
1929
+ if (isSupport)
1930
+ return null;
1931
+ }
1932
+ const messages = this.messagesRepoFor(input.tenant.tenantId);
1933
+ const history = await messages.recent(input.conversationId, this.opts.historyLimit ?? 20);
1934
+ const historyMessages = messagesToChatHistory2(history);
1935
+ const systemPrompt = [BASE_SYSTEM_PROMPT, this.opts.template.systemPromptFragment].filter(Boolean).join(`
1936
+
1937
+ `);
1938
+ const chat = this.opts.resolveChat(input.tenant.tenantId);
1939
+ const reply = await chat.complete([{ role: "system", content: systemPrompt }, ...historyMessages], {
1940
+ temperature: this.opts.temperature ?? 0.7,
1941
+ numPredict: this.opts.maxOutputTokens ?? 600
1942
+ });
1943
+ if (reply.trim().length === 0)
1944
+ return null;
1945
+ return [
1946
+ {
1947
+ channelId: String(input.channel.channelId),
1948
+ externalUserId: input.inbound.externalUserId,
1949
+ parts: [{ kind: "text", text: reply }]
1950
+ }
1951
+ ];
1952
+ }
1953
+ }
1954
+ // src/rls-guard.ts
1955
+ import { sql as sql10 } from "drizzle-orm";
1956
+ async function checkRlsEnforcement(db) {
1957
+ const rows = await db.execute(sql10`
1958
+ SELECT current_user::text AS role,
1959
+ rolsuper AS issuper,
1960
+ rolbypassrls AS hasbypassrls
1961
+ FROM pg_roles
1962
+ WHERE rolname = current_user
1963
+ `);
1964
+ const r = rows[0];
1965
+ if (!r) {
1966
+ return {
1967
+ role: "<unknown>",
1968
+ isSuperuser: false,
1969
+ hasBypassRls: false,
1970
+ isEnforced: false
1971
+ };
1972
+ }
1973
+ return {
1974
+ role: r.role,
1975
+ isSuperuser: Boolean(r.issuper),
1976
+ hasBypassRls: Boolean(r.hasbypassrls),
1977
+ isEnforced: !r.issuper && !r.hasbypassrls
1978
+ };
1979
+ }
1980
+ export {
1981
+ withTenant,
1982
+ validateTransition,
1983
+ transitionLeadState,
1984
+ systemClock,
1985
+ setEncryptedSecret,
1986
+ runMemoryExtraction,
1987
+ resolveConversation,
1988
+ resolveContact,
1989
+ processInbound,
1990
+ parseStyleConfig,
1991
+ parseAllocation,
1992
+ loadExperimentVariants,
1993
+ isTerminal,
1994
+ getInitialStage,
1995
+ getDecryptedSecret,
1996
+ generateReplyAndEnqueue,
1997
+ ensureLead,
1998
+ encryptSecret,
1999
+ dispatchOutbound,
2000
+ decryptSecret,
2001
+ compactConversation,
2002
+ checkRlsEnforcement,
2003
+ applyClassifiedStage,
2004
+ allowedTransitions,
2005
+ StylesRepo,
2006
+ SkillOutcomesRepo,
2007
+ SecretCryptoError,
2008
+ RagReplyStrategy,
2009
+ OutboundQueueRepo,
2010
+ OperatorBotHandler,
2011
+ NotificationsRepo,
2012
+ NotificationService,
2013
+ MessagesRepo,
2014
+ LlmReplyStrategy,
2015
+ LlmMemoryExtractor,
2016
+ LeadsRepo,
2017
+ KbSuggestionsRepo,
2018
+ FunnelTransitionError,
2019
+ ExperimentsRepo,
2020
+ DrizzleKbStore,
2021
+ ConversationsRepo,
2022
+ ContactsRepo,
2023
+ ChannelIdentitiesRepo
2024
+ };