refactor chat delivery to graphql + hatchet services

This commit is contained in:
Ruslan Bakiev
2026-03-08 18:55:58 +07:00
parent fe4bd59248
commit 7d1bed0d67
61 changed files with 5007 additions and 5004 deletions

11
backend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
COPY prisma ./prisma
RUN npm ci
COPY . .
CMD ["npm", "run", "start"]

35
backend/README.md Normal file
View File

@@ -0,0 +1,35 @@
# backend
Core CRM/omni-домен с единственной Prisma-базой.
## Назначение
- принимает входящие telegram-события через GraphQL mutation `ingestTelegramInbound`;
- создает исходящую задачу через GraphQL mutation `requestTelegramOutbound``telegram_backend`, далее в Hatchet);
- принимает отчет о доставке через GraphQL mutation `reportTelegramOutbound`.
## API
- `GET /health`
- `POST /graphql`
## GraphQL auth
Если задан `BACKEND_GRAPHQL_SHARED_SECRET`, запросы на `/graphql` должны содержать заголовок:
- `x-graphql-secret: <BACKEND_GRAPHQL_SHARED_SECRET>`
## Переменные окружения
- `PORT` (default: `8090`)
- `MAX_BODY_SIZE_BYTES` (default: `2097152`)
- `BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `TELEGRAM_BACKEND_GRAPHQL_URL` (required для `requestTelegramOutbound`)
- `TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET` (optional)
- `DEFAULT_TEAM_ID` (optional fallback для inbound маршрутизации)
## Prisma policy
- Источник схемы: `Frontend/prisma/schema.prisma`.
- Локальная копия в `backend/prisma/schema.prisma` обновляется только через `scripts/prisma-sync.sh`.
- Миграции/`db push` выполняются только в `Frontend`.

1025
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
backend/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "crm-backend",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@types/node": "^22.13.9",
"prisma": "^6.16.1",
"tsx": "^4.20.5",
"typescript": "^5.9.2"
},
"dependencies": {
"@prisma/client": "^6.16.1",
"graphql": "^16.13.1"
}
}

View File

@@ -0,0 +1,483 @@
generator client {
provider = "prisma-client"
output = "../server/generated/prisma"
}
datasource db {
provider = "postgresql"
}
enum TeamRole {
OWNER
MEMBER
}
enum MessageDirection {
IN
OUT
}
enum MessageChannel {
TELEGRAM
WHATSAPP
INSTAGRAM
PHONE
EMAIL
INTERNAL
}
enum ContactMessageKind {
MESSAGE
CALL
}
enum ChatRole {
USER
ASSISTANT
SYSTEM
}
enum OmniMessageStatus {
PENDING
SENT
FAILED
DELIVERED
READ
}
enum FeedCardDecision {
PENDING
ACCEPTED
REJECTED
}
enum WorkspaceDocumentType {
Regulation
Playbook
Policy
Template
}
enum ClientTimelineContentType {
CALENDAR_EVENT
DOCUMENT
RECOMMENDATION
}
model Team {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members TeamMember[]
contacts Contact[]
calendarEvents CalendarEvent[]
deals Deal[]
aiConversations AiConversation[]
aiMessages AiMessage[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
telegramBusinessConnections TelegramBusinessConnection[]
feedCards FeedCard[]
contactPins ContactPin[]
documents WorkspaceDocument[]
clientTimelineEntries ClientTimelineEntry[]
contactInboxes ContactInbox[]
contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
}
model User {
id String @id @default(cuid())
phone String @unique
passwordHash String
email String? @unique
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships TeamMember[]
aiConversations AiConversation[] @relation("ConversationCreator")
aiMessages AiMessage[] @relation("ChatAuthor")
contactInboxPreferences ContactInboxPreference[]
contactThreadReads ContactThreadRead[]
}
model TeamMember {
id String @id @default(cuid())
teamId String
userId String
role TeamRole @default(MEMBER)
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([teamId, userId])
@@index([userId])
}
model Contact {
id String @id @default(cuid())
teamId String
name String
avatarUrl String?
email String?
phone String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
note ContactNote?
messages ContactMessage[]
events CalendarEvent[]
deals Deal[]
feedCards FeedCard[]
pins ContactPin[]
omniThreads OmniThread[]
omniMessages OmniMessage[]
omniIdentities OmniContactIdentity[]
contactInboxes ContactInbox[]
clientTimelineEntries ClientTimelineEntry[]
contactThreadReads ContactThreadRead[]
@@index([teamId, updatedAt])
}
model ContactThreadRead {
id String @id @default(cuid())
teamId String
userId String
contactId String
readAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([userId, contactId])
@@index([teamId, userId])
}
model ContactNote {
id String @id @default(cuid())
contactId String @unique
content String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
}
model ContactMessage {
id String @id @default(cuid())
contactId String
contactInboxId String?
kind ContactMessageKind @default(MESSAGE)
direction MessageDirection
channel MessageChannel
content String
audioUrl String?
durationSec Int?
waveformJson Json?
transcriptJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
contactInbox ContactInbox? @relation(fields: [contactInboxId], references: [id], onDelete: SetNull)
@@index([contactId, occurredAt])
@@index([contactInboxId, occurredAt])
}
model ContactInbox {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
sourceExternalId String
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
messages ContactMessage[]
preferences ContactInboxPreference[]
@@unique([teamId, channel, sourceExternalId])
@@index([contactId, updatedAt])
@@index([teamId, updatedAt])
}
model ContactInboxPreference {
id String @id @default(cuid())
teamId String
userId String
contactInboxId String
isHidden Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
contactInbox ContactInbox @relation(fields: [contactInboxId], references: [id], onDelete: Cascade)
@@unique([userId, contactInboxId])
@@index([teamId, userId, isHidden])
}
model OmniContactIdentity {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([teamId, channel, externalId])
@@index([contactId])
@@index([teamId, updatedAt])
}
model OmniThread {
id String @id @default(cuid())
teamId String
contactId String
channel MessageChannel
externalChatId String
businessConnectionId String?
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
messages OmniMessage[]
@@unique([teamId, channel, externalChatId, businessConnectionId])
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model OmniMessage {
id String @id @default(cuid())
teamId String
contactId String
threadId String
direction MessageDirection
channel MessageChannel
status OmniMessageStatus @default(PENDING)
text String
providerMessageId String?
providerUpdateId String?
rawJson Json?
occurredAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
thread OmniThread @relation(fields: [threadId], references: [id], onDelete: Cascade)
@@unique([threadId, providerMessageId])
@@index([teamId, occurredAt])
@@index([threadId, occurredAt])
}
model TelegramBusinessConnection {
id String @id @default(cuid())
teamId String
businessConnectionId String
isEnabled Boolean?
canReply Boolean?
rawJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, businessConnectionId])
@@index([teamId, updatedAt])
}
model CalendarEvent {
id String @id @default(cuid())
teamId String
contactId String?
title String
startsAt DateTime
endsAt DateTime?
note String?
isArchived Boolean @default(false)
archiveNote String?
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([startsAt])
@@index([contactId, startsAt])
@@index([teamId, startsAt])
}
model Deal {
id String @id @default(cuid())
teamId String
contactId String
title String
stage String
amount Int?
paidAmount Int?
nextStep String?
summary String?
currentStepId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
steps DealStep[]
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
@@index([currentStepId])
}
model DealStep {
id String @id @default(cuid())
dealId String
title String
description String?
status String @default("todo")
dueAt DateTime?
order Int @default(0)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deal Deal @relation(fields: [dealId], references: [id], onDelete: Cascade)
@@index([dealId, order])
@@index([status, dueAt])
}
model AiConversation {
id String @id @default(cuid())
teamId String
createdByUserId String
title String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
createdByUser User @relation("ConversationCreator", fields: [createdByUserId], references: [id], onDelete: Cascade)
messages AiMessage[]
@@index([teamId, updatedAt])
@@index([createdByUserId])
@@map("ChatConversation")
}
model AiMessage {
id String @id @default(cuid())
teamId String
conversationId String
authorUserId String?
role ChatRole
text String
planJson Json?
createdAt DateTime @default(now())
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
authorUser User? @relation("ChatAuthor", fields: [authorUserId], references: [id], onDelete: SetNull)
@@index([createdAt])
@@index([teamId, createdAt])
@@index([conversationId, createdAt])
@@map("ChatMessage")
}
model FeedCard {
id String @id @default(cuid())
teamId String
contactId String?
happenedAt DateTime
text String
proposalJson Json
decision FeedCardDecision @default(PENDING)
decisionNote String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull)
@@index([teamId, happenedAt])
@@index([contactId, happenedAt])
}
model ContactPin {
id String @id @default(cuid())
teamId String
contactId String
text String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
@@index([contactId, updatedAt])
}
model WorkspaceDocument {
id String @id @default(cuid())
teamId String
title String
type WorkspaceDocumentType
owner String
scope String
summary String
body String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@index([teamId, updatedAt])
}
model ClientTimelineEntry {
id String @id @default(cuid())
teamId String
contactId String
contentType ClientTimelineContentType
contentId String
datetime DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
contact Contact @relation(fields: [contactId], references: [id], onDelete: Cascade)
@@unique([teamId, contentType, contentId])
@@index([teamId, contactId, datetime])
@@index([contactId, datetime])
}

38
backend/src/index.ts Normal file
View File

@@ -0,0 +1,38 @@
import { startServer } from "./server";
import { prisma } from "./utils/prisma";
const server = startServer();
async function shutdown(signal: string) {
console.log(`[backend] shutting down by ${signal}`);
try {
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
} catch {
// ignore shutdown errors
}
try {
await prisma.$disconnect();
} catch {
// ignore shutdown errors
}
process.exit(0);
}
process.on("SIGINT", () => {
void shutdown("SIGINT");
});
process.on("SIGTERM", () => {
void shutdown("SIGTERM");
});

232
backend/src/server.ts Normal file
View File

@@ -0,0 +1,232 @@
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { buildSchema, graphql } from "graphql";
import {
ingestTelegramInbound,
reportTelegramOutbound,
requestTelegramOutbound,
type TelegramInboundEnvelope,
type TelegramOutboundReport,
type TelegramOutboundRequest,
} from "./service";
const PORT = Number(process.env.PORT || 8090);
const MAX_BODY_SIZE_BYTES = Number(process.env.MAX_BODY_SIZE_BYTES || 2 * 1024 * 1024);
const GRAPHQL_SHARED_SECRET = String(process.env.BACKEND_GRAPHQL_SHARED_SECRET || "").trim();
const schema = buildSchema(`
type Query {
health: Health!
}
type Health {
ok: Boolean!
service: String!
now: String!
}
type MutationResult {
ok: Boolean!
message: String!
runId: String
omniMessageId: String
}
input TelegramInboundInput {
version: Int!
idempotencyKey: String!
provider: String!
channel: String!
direction: String!
providerEventId: String!
providerMessageId: String
eventType: String!
occurredAt: String!
receivedAt: String!
payloadRawJson: String!
payloadNormalizedJson: String!
}
input TelegramOutboundReportInput {
omniMessageId: String!
status: String!
providerMessageId: String
error: String
responseJson: String
}
input TelegramOutboundTaskInput {
omniMessageId: String!
chatId: String!
text: String!
businessConnectionId: String
}
type Mutation {
ingestTelegramInbound(input: TelegramInboundInput!): MutationResult!
reportTelegramOutbound(input: TelegramOutboundReportInput!): MutationResult!
requestTelegramOutbound(input: TelegramOutboundTaskInput!): MutationResult!
}
`);
function writeJson(res: ServerResponse, statusCode: number, body: unknown) {
res.statusCode = statusCode;
res.setHeader("content-type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function isGraphqlAuthorized(req: IncomingMessage) {
if (!GRAPHQL_SHARED_SECRET) return true;
const incoming = String(req.headers["x-graphql-secret"] || "").trim();
return incoming !== "" && incoming === GRAPHQL_SHARED_SECRET;
}
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buf.length;
if (total > MAX_BODY_SIZE_BYTES) {
throw new Error(`payload_too_large:${MAX_BODY_SIZE_BYTES}`);
}
chunks.push(buf);
}
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw) return {};
return JSON.parse(raw);
}
function parseJsonField<T>(raw: string, fieldName: string): T {
try {
return JSON.parse(raw) as T;
} catch {
throw new Error(`${fieldName} must be valid JSON string`);
}
}
const root = {
health: () => ({
ok: true,
service: "backend",
now: new Date().toISOString(),
}),
ingestTelegramInbound: async ({ input }: { input: any }) => {
const envelope: TelegramInboundEnvelope = {
version: Number(input.version ?? 1),
idempotencyKey: String(input.idempotencyKey ?? ""),
provider: String(input.provider ?? ""),
channel: String(input.channel ?? ""),
direction: String(input.direction ?? "IN") === "OUT" ? "OUT" : "IN",
providerEventId: String(input.providerEventId ?? ""),
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
eventType: String(input.eventType ?? ""),
occurredAt: String(input.occurredAt ?? new Date().toISOString()),
receivedAt: String(input.receivedAt ?? new Date().toISOString()),
payloadRaw: parseJsonField(input.payloadRawJson, "payloadRawJson"),
payloadNormalized: parseJsonField(input.payloadNormalizedJson, "payloadNormalizedJson"),
};
const result = await ingestTelegramInbound(envelope);
return {
ok: result.ok,
message: result.message,
omniMessageId: (result as any).omniMessageId ?? null,
runId: null,
};
},
reportTelegramOutbound: async ({ input }: { input: any }) => {
const payload: TelegramOutboundReport = {
omniMessageId: String(input.omniMessageId ?? ""),
status: String(input.status ?? "FAILED"),
providerMessageId: input.providerMessageId != null ? String(input.providerMessageId) : null,
error: input.error != null ? String(input.error) : null,
responseJson: input.responseJson != null ? String(input.responseJson) : null,
};
const result = await reportTelegramOutbound(payload);
return {
ok: result.ok,
message: result.message,
runId: null,
omniMessageId: null,
};
},
requestTelegramOutbound: async ({ input }: { input: any }) => {
const payload: TelegramOutboundRequest = {
omniMessageId: String(input.omniMessageId ?? ""),
chatId: String(input.chatId ?? ""),
text: String(input.text ?? ""),
businessConnectionId: input.businessConnectionId != null ? String(input.businessConnectionId) : null,
};
const result = await requestTelegramOutbound(payload);
return {
ok: result.ok,
message: result.message,
runId: result.runId ?? null,
omniMessageId: null,
};
},
};
export function startServer() {
const server = createServer(async (req, res) => {
if (!req.url || !req.method) {
writeJson(res, 404, { ok: false, error: "not_found" });
return;
}
if (req.url === "/health" && req.method === "GET") {
writeJson(res, 200, {
ok: true,
service: "backend",
now: new Date().toISOString(),
});
return;
}
if (req.url === "/graphql" && req.method === "POST") {
if (!isGraphqlAuthorized(req)) {
writeJson(res, 401, { errors: [{ message: "unauthorized" }] });
return;
}
try {
const body = (await readJsonBody(req)) as {
query?: string;
variables?: Record<string, unknown>;
operationName?: string;
};
const result = await graphql({
schema,
source: String(body.query || ""),
rootValue: root,
variableValues: body.variables || {},
operationName: body.operationName,
});
writeJson(res, 200, result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const statusCode = message.startsWith("payload_too_large:") ? 413 : 400;
writeJson(res, statusCode, { errors: [{ message }] });
}
return;
}
writeJson(res, 404, { ok: false, error: "not_found" });
});
server.listen(PORT, "0.0.0.0", () => {
console.log(`[backend] listening on :${PORT}`);
});
return server;
}

512
backend/src/service.ts Normal file
View File

@@ -0,0 +1,512 @@
type MessageDirection = "IN" | "OUT";
type OmniMessageStatus = "PENDING" | "SENT" | "FAILED" | "DELIVERED" | "READ";
import { prisma } from "./utils/prisma";
export type TelegramInboundEnvelope = {
version: number;
idempotencyKey: string;
provider: string;
channel: string;
direction: "IN" | "OUT";
providerEventId: string;
providerMessageId: string | null;
eventType: string;
occurredAt: string;
receivedAt: string;
payloadRaw: unknown;
payloadNormalized: {
threadExternalId: string | null;
contactExternalId: string | null;
text: string | null;
businessConnectionId: string | null;
[key: string]: unknown;
};
};
export type TelegramOutboundReport = {
omniMessageId: string;
status: string;
providerMessageId?: string | null;
error?: string | null;
responseJson?: string | null;
};
export type TelegramOutboundRequest = {
omniMessageId: string;
chatId: string;
text: string;
businessConnectionId?: string | null;
};
function asString(value: unknown) {
if (typeof value !== "string") return null;
const v = value.trim();
return v || null;
}
function parseDate(value: string) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return new Date();
return d;
}
function normalizeDirection(value: string): MessageDirection {
return value === "OUT" ? "OUT" : "IN";
}
async function resolveTeamId(envelope: TelegramInboundEnvelope) {
const n = envelope.payloadNormalized;
const bcId = asString(n.businessConnectionId);
if (bcId) {
const linked = await prisma.telegramBusinessConnection.findFirst({
where: { businessConnectionId: bcId },
orderBy: { updatedAt: "desc" },
select: { teamId: true },
});
if (linked?.teamId) return linked.teamId;
}
const externalContactId = asString(n.contactExternalId) ?? asString(n.threadExternalId);
if (externalContactId) {
const linked = await prisma.telegramBusinessConnection.findFirst({
where: { businessConnectionId: `link:${externalContactId}` },
orderBy: { updatedAt: "desc" },
select: { teamId: true },
});
if (linked?.teamId) return linked.teamId;
}
const fallback = asString(process.env.DEFAULT_TEAM_ID);
if (fallback) return fallback;
const firstTeam = await prisma.team.findFirst({
orderBy: { createdAt: "asc" },
select: { id: true },
});
return firstTeam?.id ?? null;
}
async function resolveContact(input: {
teamId: string;
externalContactId: string;
displayName: string;
avatarUrl: string | null;
}) {
const existing = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (existing?.contactId) {
return existing.contactId;
}
const contact = await prisma.contact.create({
data: {
teamId: input.teamId,
name: input.displayName,
avatarUrl: input.avatarUrl,
},
select: { id: true },
});
try {
await prisma.omniContactIdentity.create({
data: {
teamId: input.teamId,
contactId: contact.id,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
});
return contact.id;
} catch {
const concurrent = await prisma.omniContactIdentity.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalId: input.externalContactId,
},
select: { contactId: true },
});
if (concurrent?.contactId) {
await prisma.contact.delete({ where: { id: contact.id } }).catch(() => undefined);
return concurrent.contactId;
}
throw new Error("failed to create telegram contact identity");
}
}
async function upsertThread(input: {
teamId: string;
contactId: string;
externalChatId: string;
businessConnectionId: string | null;
title: string | null;
}) {
const existing = await prisma.omniThread.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
},
select: { id: true },
});
if (existing) {
await prisma.omniThread.update({
where: { id: existing.id },
data: {
contactId: input.contactId,
...(input.title ? { title: input.title } : {}),
},
select: { id: true },
});
return existing.id;
}
try {
const created = await prisma.omniThread.create({
data: {
teamId: input.teamId,
contactId: input.contactId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
title: input.title,
},
select: { id: true },
});
return created.id;
} catch {
const concurrent = await prisma.omniThread.findFirst({
where: {
teamId: input.teamId,
channel: "TELEGRAM",
externalChatId: input.externalChatId,
businessConnectionId: input.businessConnectionId,
},
select: { id: true },
});
if (concurrent?.id) return concurrent.id;
throw new Error("failed to upsert telegram thread");
}
}
async function upsertContactInbox(input: {
teamId: string;
contactId: string;
sourceExternalId: string;
title: string | null;
}) {
const inbox = await prisma.contactInbox.upsert({
where: {
teamId_channel_sourceExternalId: {
teamId: input.teamId,
channel: "TELEGRAM",
sourceExternalId: input.sourceExternalId,
},
},
create: {
teamId: input.teamId,
contactId: input.contactId,
channel: "TELEGRAM",
sourceExternalId: input.sourceExternalId,
title: input.title,
},
update: {
contactId: input.contactId,
...(input.title ? { title: input.title } : {}),
},
select: { id: true },
});
return inbox.id;
}
async function markRead(teamId: string, externalChatId: string) {
const thread = await prisma.omniThread.findFirst({
where: {
teamId,
channel: "TELEGRAM",
externalChatId,
},
select: { contactId: true },
});
if (!thread) return;
const members = await prisma.teamMember.findMany({
where: { teamId },
select: { userId: true },
});
const readAt = new Date();
await Promise.all(
members.map((member: { userId: string }) =>
prisma.contactThreadRead.upsert({
where: {
userId_contactId: {
userId: member.userId,
contactId: thread.contactId,
},
},
create: {
teamId,
userId: member.userId,
contactId: thread.contactId,
readAt,
},
update: { readAt },
}),
),
);
}
export async function ingestTelegramInbound(envelope: TelegramInboundEnvelope) {
if (envelope.channel !== "TELEGRAM") {
return { ok: true, message: "skip_non_telegram" };
}
const teamId = await resolveTeamId(envelope);
if (!teamId) {
throw new Error("team_not_resolved");
}
const n = envelope.payloadNormalized;
const externalChatId = asString(n.threadExternalId) ?? asString(n.contactExternalId);
if (!externalChatId) {
throw new Error("thread_external_id_required");
}
if (envelope.eventType === "read_business_message") {
await markRead(teamId, externalChatId);
return { ok: true, message: "read_marked" };
}
const externalContactId = asString(n.contactExternalId) ?? externalChatId;
const businessConnectionId = asString(n.businessConnectionId);
const text = asString(n.text) ?? "[no text]";
const occurredAt = parseDate(envelope.occurredAt);
const direction = normalizeDirection(envelope.direction);
const contactFirstName = asString(n.contactFirstName);
const contactLastName = asString(n.contactLastName);
const contactUsername = asString(n.contactUsername);
const fallbackName = `Telegram ${externalContactId}`;
const displayName =
[contactFirstName, contactLastName].filter(Boolean).join(" ") ||
(contactUsername ? `@${contactUsername.replace(/^@/, "")}` : null) ||
fallbackName;
const contactId = await resolveContact({
teamId,
externalContactId,
displayName,
avatarUrl: asString(n.contactAvatarUrl),
});
const threadId = await upsertThread({
teamId,
contactId,
externalChatId,
businessConnectionId,
title: asString(n.chatTitle),
});
const contactInboxId = await upsertContactInbox({
teamId,
contactId,
sourceExternalId: externalChatId,
title: asString(n.chatTitle),
});
const rawEnvelope: Record<string, unknown> = {
version: envelope.version,
source: "backend.graphql.ingestTelegramInbound",
provider: envelope.provider,
channel: envelope.channel,
direction,
providerEventId: envelope.providerEventId,
receivedAt: envelope.receivedAt,
occurredAt: occurredAt.toISOString(),
payloadNormalized: n,
payloadRaw: envelope.payloadRaw ?? null,
};
let omniMessageId: string;
if (envelope.providerMessageId) {
const message = await prisma.omniMessage.upsert({
where: {
threadId_providerMessageId: {
threadId,
providerMessageId: envelope.providerMessageId,
},
},
create: {
teamId,
contactId,
threadId,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: envelope.providerMessageId,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
update: {
text,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
select: { id: true },
});
omniMessageId = message.id;
} else {
const message = await prisma.omniMessage.create({
data: {
teamId,
contactId,
threadId,
direction,
channel: "TELEGRAM",
status: "DELIVERED",
text,
providerMessageId: null,
providerUpdateId: envelope.providerEventId,
rawJson: rawEnvelope,
occurredAt,
},
select: { id: true },
});
omniMessageId = message.id;
}
await prisma.contactMessage.create({
data: {
contactId,
contactInboxId,
kind: "MESSAGE",
direction,
channel: "TELEGRAM",
content: text,
occurredAt,
},
});
return { ok: true, message: "inbound_ingested", omniMessageId };
}
export async function reportTelegramOutbound(input: TelegramOutboundReport) {
const statusRaw = input.status.trim().toUpperCase();
const status: OmniMessageStatus =
statusRaw === "SENT" ||
statusRaw === "FAILED" ||
statusRaw === "DELIVERED" ||
statusRaw === "READ" ||
statusRaw === "PENDING"
? (statusRaw as OmniMessageStatus)
: "FAILED";
const existing = await prisma.omniMessage.findUnique({
where: { id: input.omniMessageId },
select: { rawJson: true },
});
const raw = (existing?.rawJson && typeof existing.rawJson === "object" && !Array.isArray(existing.rawJson)
? (existing.rawJson as Record<string, unknown>)
: {}) as Record<string, unknown>;
await prisma.omniMessage.update({
where: { id: input.omniMessageId },
data: {
status,
...(input.providerMessageId ? { providerMessageId: input.providerMessageId } : {}),
rawJson: {
...raw,
telegramWorker: {
reportedAt: new Date().toISOString(),
status,
error: input.error ?? null,
response: (() => {
if (!input.responseJson) return null;
try {
return JSON.parse(input.responseJson);
} catch {
return input.responseJson;
}
})(),
},
},
},
});
return { ok: true, message: "outbound_reported" };
}
async function callTelegramBackendGraphql<T>(query: string, variables: Record<string, unknown>) {
const url = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_URL);
if (!url) {
throw new Error("TELEGRAM_BACKEND_GRAPHQL_URL is required");
}
const headers: Record<string, string> = {
"content-type": "application/json",
};
const secret = asString(process.env.TELEGRAM_BACKEND_GRAPHQL_SHARED_SECRET);
if (secret) {
headers["x-graphql-secret"] = secret;
}
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({ query, variables }),
});
const payload = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> };
if (!response.ok || payload.errors?.length) {
const errorMessage = payload.errors?.map((e) => e.message).filter(Boolean).join("; ") || `HTTP ${response.status}`;
throw new Error(errorMessage);
}
return payload.data as T;
}
export async function requestTelegramOutbound(input: TelegramOutboundRequest) {
type Out = {
enqueueTelegramOutbound: {
ok: boolean;
message: string;
runId?: string | null;
};
};
const query = `mutation Enqueue($input: TelegramOutboundTaskInput!) {
enqueueTelegramOutbound(input: $input) {
ok
message
runId
}
}`;
const data = await callTelegramBackendGraphql<Out>(query, { input });
const result = data.enqueueTelegramOutbound;
if (!result?.ok) {
throw new Error(result?.message || "enqueue failed");
}
return { ok: true, message: "outbound_enqueued", runId: result.runId ?? null };
}

View File

@@ -0,0 +1,16 @@
import { PrismaClient } from "@prisma/client";
declare global {
// eslint-disable-next-line no-var
var __omniChatPrisma: PrismaClient | undefined;
}
export const prisma =
globalThis.__omniChatPrisma ??
new PrismaClient({
log: ["error", "warn"],
});
if (process.env.NODE_ENV !== "production") {
globalThis.__omniChatPrisma = prisma;
}

14
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"types": ["node"],
"resolveJsonModule": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}