Add Telegram avatar proxy and realtime CRM websocket updates

This commit is contained in:
Ruslan Bakiev
2026-02-23 08:09:53 +07:00
parent f81a0fde55
commit 0f1028b0fa
5 changed files with 412 additions and 4 deletions

View File

@@ -492,6 +492,12 @@ const loginBusy = ref(false);
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
const lifecycleNowMs = ref(Date.now());
let lifecycleClock: ReturnType<typeof setInterval> | null = null;
const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
let crmRealtimeSocket: WebSocket | null = null;
let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshInFlight = false;
let crmRealtimeReconnectAttempt = 0;
watch(
() => pilotLiveLogs.value.length,
@@ -846,6 +852,7 @@ async function bootstrapSession() {
try {
await loadMe();
if (!authMe.value) {
stopCrmRealtime();
pilotMessages.value = [];
chatConversations.value = [];
telegramConnectStatus.value = "not_connected";
@@ -854,7 +861,11 @@ async function bootstrapSession() {
return;
}
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
if (process.client) {
startCrmRealtime();
}
} catch {
stopCrmRealtime();
authMe.value = null;
pilotMessages.value = [];
chatConversations.value = [];
@@ -911,6 +922,7 @@ async function login() {
});
await loadMe();
startPilotBackgroundPolling();
startCrmRealtime();
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
} catch (e: any) {
loginError.value = e?.data?.message || e?.message || "Login failed";
@@ -921,6 +933,7 @@ async function login() {
async function logout() {
await gqlFetch<{ logout: { ok: boolean } }>(logoutMutation);
stopCrmRealtime();
stopPilotBackgroundPolling();
authMe.value = null;
pilotMessages.value = [];
@@ -966,6 +979,121 @@ async function refreshCrmData() {
}));
}
function clearCrmRealtimeReconnectTimer() {
if (!crmRealtimeReconnectTimer) return;
clearTimeout(crmRealtimeReconnectTimer);
crmRealtimeReconnectTimer = null;
}
function clearCrmRealtimeRefreshTimer() {
if (!crmRealtimeRefreshTimer) return;
clearTimeout(crmRealtimeRefreshTimer);
crmRealtimeRefreshTimer = null;
}
async function runCrmRealtimeRefresh() {
if (!authMe.value || crmRealtimeRefreshInFlight) return;
crmRealtimeRefreshInFlight = true;
try {
await Promise.all([refreshCrmData(), loadTelegramConnectStatus()]);
} catch {
// ignore transient realtime refresh errors
} finally {
crmRealtimeRefreshInFlight = false;
}
}
function scheduleCrmRealtimeRefresh(delayMs = 250) {
clearCrmRealtimeRefreshTimer();
crmRealtimeRefreshTimer = setTimeout(() => {
crmRealtimeRefreshTimer = null;
void runCrmRealtimeRefresh();
}, delayMs);
}
function scheduleCrmRealtimeReconnect() {
clearCrmRealtimeReconnectTimer();
const attempt = Math.min(crmRealtimeReconnectAttempt + 1, 8);
crmRealtimeReconnectAttempt = attempt;
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000);
crmRealtimeReconnectTimer = setTimeout(() => {
crmRealtimeReconnectTimer = null;
startCrmRealtime();
}, delayMs);
}
function stopCrmRealtime() {
clearCrmRealtimeReconnectTimer();
clearCrmRealtimeRefreshTimer();
if (crmRealtimeSocket) {
const socket = crmRealtimeSocket;
crmRealtimeSocket = null;
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
try {
socket.close(1000, "client stop");
} catch {
// ignore socket close errors
}
}
crmRealtimeState.value = "idle";
}
function startCrmRealtime() {
if (process.server || !authMe.value) return;
if (crmRealtimeSocket) {
const state = crmRealtimeSocket.readyState;
if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return;
}
clearCrmRealtimeReconnectTimer();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${protocol}//${window.location.host}/ws/crm-updates`;
const socket = new WebSocket(url);
crmRealtimeSocket = socket;
crmRealtimeState.value = "connecting";
socket.onopen = () => {
crmRealtimeState.value = "open";
crmRealtimeReconnectAttempt = 0;
};
socket.onmessage = (event) => {
const raw = typeof event.data === "string" ? event.data : "";
if (!raw) return;
try {
const payload = JSON.parse(raw) as { type?: string };
if (payload.type === "dashboard.changed") {
scheduleCrmRealtimeRefresh();
}
} catch {
// ignore malformed realtime payloads
}
};
socket.onerror = () => {
crmRealtimeState.value = "error";
};
socket.onclose = () => {
const wasActive = crmRealtimeSocket === socket;
if (wasActive) {
crmRealtimeSocket = null;
}
if (!authMe.value) {
crmRealtimeState.value = "idle";
return;
}
crmRealtimeState.value = "error";
scheduleCrmRealtimeReconnect();
};
}
async function sendPilotText(rawText: string) {
const text = rawText.trim();
if (!text || pilotSending.value) return;
@@ -2059,15 +2187,22 @@ onMounted(() => {
if (!authResolved.value) {
void bootstrapSession().finally(() => {
if (authMe.value) startPilotBackgroundPolling();
if (authMe.value) {
startPilotBackgroundPolling();
startCrmRealtime();
}
});
return;
}
if (authMe.value) startPilotBackgroundPolling();
if (authMe.value) {
startPilotBackgroundPolling();
startCrmRealtime();
}
});
onBeforeUnmount(() => {
stopCrmRealtime();
if (pilotRecording.value) {
stopPilotRecording("fill");
}
@@ -2866,6 +3001,7 @@ function openDocumentsTab(push = false) {
const peopleListMode = ref<"contacts" | "deals">("contacts");
const peopleSearch = ref("");
const peopleSortMode = ref<PeopleSortMode>("lastContact");
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
{ value: "lastContact", label: "Last contact" },
{ value: "name", label: "Name" },
@@ -2875,6 +3011,31 @@ const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
const selectedDealId = ref(deals.value[0]?.id ?? "");
const selectedDealStepsExpanded = ref(false);
function contactInitials(name: string) {
const words = String(name ?? "")
.trim()
.split(/\s+/)
.filter(Boolean);
if (!words.length) return "?";
return words
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
function avatarSrcForThread(thread: { id: string; avatar: string }) {
if (brokenAvatarByContactId.value[thread.id]) return "";
return String(thread.avatar ?? "").trim();
}
function markAvatarBroken(contactId: string) {
if (!contactId) return;
brokenAvatarByContactId.value = {
...brokenAvatarByContactId.value,
[contactId]: true,
};
}
const commThreads = computed(() => {
const sorted = [...commItems.value].sort((a, b) => a.at.localeCompare(b.at));
const map = new Map<string, CommItem[]>();
@@ -4978,7 +5139,15 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<div class="flex items-start gap-2">
<div class="avatar shrink-0">
<div class="h-8 w-8 rounded-full ring-1 ring-base-300/70">
<img :src="thread.avatar" :alt="thread.contact">
<img
v-if="avatarSrcForThread(thread)"
:src="avatarSrcForThread(thread)"
:alt="thread.contact"
@error="markAvatarBroken(thread.id)"
>
<span v-else class="flex h-full w-full items-center justify-center text-[10px] font-semibold text-base-content/65">
{{ contactInitials(thread.contact) }}
</span>
</div>
</div>