@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.
- package/LICENSE +21 -0
- package/README.md +22 -0
- package/dist/compact-conversation.d.ts +16 -0
- package/dist/compact-conversation.d.ts.map +1 -0
- package/dist/compact-conversation.test.d.ts +2 -0
- package/dist/compact-conversation.test.d.ts.map +1 -0
- package/dist/contact-resolver.d.ts +16 -0
- package/dist/contact-resolver.d.ts.map +1 -0
- package/dist/conversation-resolver.d.ts +17 -0
- package/dist/conversation-resolver.d.ts.map +1 -0
- package/dist/dal/channel-identities.d.ts +26 -0
- package/dist/dal/channel-identities.d.ts.map +1 -0
- package/dist/dal/contacts.d.ts +30 -0
- package/dist/dal/contacts.d.ts.map +1 -0
- package/dist/dal/conversations.d.ts +67 -0
- package/dist/dal/conversations.d.ts.map +1 -0
- package/dist/dal/experiments.d.ts +47 -0
- package/dist/dal/experiments.d.ts.map +1 -0
- package/dist/dal/index.d.ts +14 -0
- package/dist/dal/index.d.ts.map +1 -0
- package/dist/dal/kb-store.d.ts +58 -0
- package/dist/dal/kb-store.d.ts.map +1 -0
- package/dist/dal/kb-suggestions.d.ts +26 -0
- package/dist/dal/kb-suggestions.d.ts.map +1 -0
- package/dist/dal/leads.d.ts +38 -0
- package/dist/dal/leads.d.ts.map +1 -0
- package/dist/dal/messages.d.ts +48 -0
- package/dist/dal/messages.d.ts.map +1 -0
- package/dist/dal/notifications.d.ts +32 -0
- package/dist/dal/notifications.d.ts.map +1 -0
- package/dist/dal/outbound.d.ts +70 -0
- package/dist/dal/outbound.d.ts.map +1 -0
- package/dist/dal/skill-outcomes.d.ts +58 -0
- package/dist/dal/skill-outcomes.d.ts.map +1 -0
- package/dist/dal/styles.d.ts +44 -0
- package/dist/dal/styles.d.ts.map +1 -0
- package/dist/dal/types.d.ts +27 -0
- package/dist/dal/types.d.ts.map +1 -0
- package/dist/dispatch-reply.d.ts +49 -0
- package/dist/dispatch-reply.d.ts.map +1 -0
- package/dist/dispatch-reply.test.d.ts +2 -0
- package/dist/dispatch-reply.test.d.ts.map +1 -0
- package/dist/experiment-router.d.ts +15 -0
- package/dist/experiment-router.d.ts.map +1 -0
- package/dist/experiment-router.test.d.ts +2 -0
- package/dist/experiment-router.test.d.ts.map +1 -0
- package/dist/extract-fields.test.d.ts +2 -0
- package/dist/extract-fields.test.d.ts.map +1 -0
- package/dist/funnel-machine.d.ts +43 -0
- package/dist/funnel-machine.d.ts.map +1 -0
- package/dist/funnel-machine.test.d.ts +2 -0
- package/dist/funnel-machine.test.d.ts.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2024 -0
- package/dist/lead-lifecycle.d.ts +46 -0
- package/dist/lead-lifecycle.d.ts.map +1 -0
- package/dist/lead-lifecycle.test.d.ts +2 -0
- package/dist/lead-lifecycle.test.d.ts.map +1 -0
- package/dist/memory-extractor.d.ts +62 -0
- package/dist/memory-extractor.d.ts.map +1 -0
- package/dist/memory-extractor.test.d.ts +2 -0
- package/dist/memory-extractor.test.d.ts.map +1 -0
- package/dist/notifications.d.ts +32 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.test.d.ts +2 -0
- package/dist/notifications.test.d.ts.map +1 -0
- package/dist/operator-bot-handler.d.ts +13 -0
- package/dist/operator-bot-handler.d.ts.map +1 -0
- package/dist/operator-bot-handler.test.d.ts +2 -0
- package/dist/operator-bot-handler.test.d.ts.map +1 -0
- package/dist/outbound-dispatch.d.ts +17 -0
- package/dist/outbound-dispatch.d.ts.map +1 -0
- package/dist/process-inbound.d.ts +126 -0
- package/dist/process-inbound.d.ts.map +1 -0
- package/dist/process-inbound.test.d.ts +2 -0
- package/dist/process-inbound.test.d.ts.map +1 -0
- package/dist/reply-strategy/index.d.ts +3 -0
- package/dist/reply-strategy/index.d.ts.map +1 -0
- package/dist/reply-strategy/llm-reply.d.ts +69 -0
- package/dist/reply-strategy/llm-reply.d.ts.map +1 -0
- package/dist/reply-strategy/llm-reply.test.d.ts +2 -0
- package/dist/reply-strategy/llm-reply.test.d.ts.map +1 -0
- package/dist/reply-strategy/rag-reply.d.ts +175 -0
- package/dist/reply-strategy/rag-reply.d.ts.map +1 -0
- package/dist/rls-guard.d.ts +23 -0
- package/dist/rls-guard.d.ts.map +1 -0
- package/dist/rls-guard.integration.test.d.ts +2 -0
- package/dist/rls-guard.integration.test.d.ts.map +1 -0
- package/dist/secrets.d.ts +27 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.test.d.ts +2 -0
- package/dist/secrets.test.d.ts.map +1 -0
- package/dist/stage-classifier.d.ts +48 -0
- package/dist/stage-classifier.d.ts.map +1 -0
- package/dist/testkit.d.ts +82 -0
- package/dist/testkit.d.ts.map +1 -0
- package/dist/transcriber.d.ts +15 -0
- package/dist/transcriber.d.ts.map +1 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/with-tenant.d.ts +25 -0
- package/dist/with-tenant.d.ts.map +1 -0
- 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
|
+
};
|