refactor chat delivery to graphql + hatchet services
This commit is contained in:
512
backend/src/service.ts
Normal file
512
backend/src/service.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user