Compare commits

...
3 Commits
Author SHA1 Message Date
Andrey 290b4ccea9 🔖 release: 1.8.1 2026-09-15 07:14:10 +03:00
AndreyandClaude Opus 5 ca1b892e11 🐛 fix(calls): отбой клиента закрывает окно звонка у оператора
Клиент кладёт трубку, сервер переводит звонок в терминал, а peer connection у оператора узнаёт об этом позже — или не узнаёт вовсе. Проверка терминального статуса в аудиозвонке стояла последней, после состояния соединения, поэтому у оператора оставался экран активного разговора с кнопкой «Завершить», которой нечего было завершать: она молча выходила по той же проверке. Теперь терминальный статус решает первым (как в видеозвонке), а «Завершить» на уже завершённом звонке просто закрывает окно.

Проверено: четыре теста разбора фазы аудиозвонка.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:13:59 +03:00
AndreyandClaude Opus 5 e5d3e98ce3 🎨 style: порядок импортов по конфигу репозитория
Ruff в CI читает pyproject.toml из корня и сортирует импорты; локальный прогон
из каталога backend этого правила не видел.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 06:56:38 +03:00
7 changed files with 57 additions and 8 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
1.8.0
1.8.1
@@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlparse
from django.utils import timezone
from chatballs.calls.event_handlers import CallInviteDeliveryError, handle_call_invite_send
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.models import (
CallEndedBy,
CallInvite,
@@ -16,7 +17,6 @@ from chatballs.calls.models import (
CallStatus,
InviteDeliveryStatus,
)
from chatballs.calls.lifecycle import transition_call
from chatballs.calls.services import (
decline_call_for_identity,
open_call_for_identity,
@@ -18,6 +18,7 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Contact,
@@ -26,7 +27,6 @@ from chatballs.conversations.models import (
Conversation,
LifecycleState,
)
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.i18n import t
from chatballs.identity.audit_catalog import (
audit_action_label,
@@ -1,5 +1,6 @@
from django.db.models import Count, Max, Q
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.conversations.models import (
ConnectionIdentity,
Conversation,
@@ -8,7 +9,6 @@ from chatballs.conversations.models import (
MessageKind,
)
from chatballs.i18n import t
from chatballs.conversations.contact_avatars import contact_avatar_url_in
from chatballs.identity.avatars import user_avatar_url_in
from chatballs.integrations.features import features_payload
from chatballs.integrations.models import IntegrationProvider
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveAudioMode } from "./AudioCallOverlay";
import type { ApiCall } from "./model";
// Экран аудиозвонка у оператора: что он показывает в каждой фазе.
function call(status: ApiCall["status"]): ApiCall {
return { id: "c1", conversationId: 1, status, kind: "AUDIO", connectedAt: null, endedBy: null, durationSeconds: null } as ApiCall;
}
describe("resolveAudioMode", () => {
it("показывает разговор, пока звонок идёт", () => {
expect(resolveAudioMode(call("ACTIVE"), "", "connected", "none")).toBe("active");
});
it("считает звонок завершённым, даже когда соединение ещё считает себя живым", () => {
// Клиент положил трубку: сервер уже в терминале, а peer connection узнаёт
// об этом позже — или не узнаёт вовсе, если сокет мёртв.
expect(resolveAudioMode(call("ENDED"), "", "connected", "none")).toBe("status");
expect(resolveAudioMode(call("DECLINED"), "", "connected", "none")).toBe("status");
expect(resolveAudioMode(call("FAILED"), "", "connected", "none")).toBe("status");
});
it("до ответа клиента звонок исходящий", () => {
expect(resolveAudioMode(call("RINGING"), "", "idle", "none")).toBe("ringing");
});
it("принятый звонок без соединения — соединение", () => {
expect(resolveAudioMode(call("ACCEPTED"), "", "idle", "none")).toBe("connecting");
});
});
@@ -67,7 +67,13 @@ export function AudioCallOverlay(props: Props) {
// состоянии (карточку звонка могли открыть из списка), а сам запрос может
// упасть — тогда звонок заканчивается вторым путём, по сессии оператора.
const finish = async () => {
if (!call || isTerminalCallStatus(call.status)) return;
if (!call) return;
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
if (isTerminalCallStatus(call.status)) {
rtc.stop();
props.onClose();
return;
}
const token = props.access?.accessToken;
try {
if (token) props.onCallChange(await endCallByAccess(token));
@@ -123,16 +129,21 @@ export function AudioCallOverlay(props: Props) {
);
}
function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
export function resolveAudioMode(call: ApiCall | null, errorText: string, connection: string, mediaIssue: string): AudioCallMode {
if (errorText || !call) return "status";
if (mediaIssue === "devices" || mediaIssue === "unsupported") return "status";
// Завершённый звонок завершён, чем бы ни было занято RTC-соединение. Клиент
// кладёт трубку, сервер переводит звонок в терминал, а peer connection у
// оператора об этом узнаёт позже — а если сокет уже мёртв, то и не узнаёт
// вовсе. Пока эта проверка стояла последней, у оператора оставался экран
// активного разговора с кнопкой «Завершить», которой нечего было завершать.
if (isTerminalCallStatus(call.status)) return "status";
// Звонок всегда инициирует оператор, поэтому до ответа клиента это исходящий.
if (call.status === "REQUESTED" || call.status === "RINGING") return "ringing";
if (call.status === "ACCEPTED") return connection === "connected" ? "active" : "connecting";
if (connection === "reconnecting") return "reconnecting";
if (connection === "failed") return "status";
if (call.status === "ACTIVE" || connection === "connected") return "active";
if (isTerminalCallStatus(call.status)) return "status";
return "connecting";
}
@@ -69,7 +69,13 @@ export function VideoCallOverlay(props: Props) {
// «Завершить» обязано сработать всегда: нет токена доступа или запрос не
// прошёл — звонок заканчивается вторым путём, по сессии оператора.
const finish = async () => {
if (!call || TERMINAL[call.status]) return;
if (!call) return;
// Звонок уже закончил кто-то другой — завершать нечего, окно закрывается.
if (TERMINAL[call.status]) {
rtc.stop();
props.onClose();
return;
}
const token = props.access?.accessToken;
try {
if (token) props.onCallChange(await endCallByAccess(token));