207 lines
12 KiB
C#
207 lines
12 KiB
C#
using System.Globalization;
|
||
using System.Net;
|
||
using System.Net.Http.Headers;
|
||
using System.Net.Http.Json;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
|
||
namespace Calls.Api.Recording;
|
||
|
||
public sealed class StepError(string message, bool permanent = false) : Exception(message)
|
||
{
|
||
/// <summary>Ошибка, которую сам повтор не исправит (нет доступа, нет настроек) — всё равно повторяем, но реже.</summary>
|
||
public bool Permanent { get; } = permanent;
|
||
}
|
||
|
||
public sealed record Segment(double Start, double End, string Text);
|
||
public sealed record Transcript(string? Language, List<Segment> Segments);
|
||
public sealed record AudioPart(string File, double Duration, long Bytes);
|
||
|
||
/// <summary>Whisper (OpenAI): части по очереди, тайминги каждой части сдвигаются на длительность предыдущих.</summary>
|
||
public sealed class Transcriber(IHttpClientFactory http, RecordingOptions opt)
|
||
{
|
||
public async Task<Transcript> TranscribeAsync(string dir, IReadOnlyList<AudioPart> parts, string mime, CancellationToken ct)
|
||
{
|
||
var all = new List<Segment>();
|
||
string? lang = null;
|
||
double offset = 0;
|
||
foreach (var part in parts)
|
||
{
|
||
var path = Path.Combine(dir, part.File);
|
||
var size = new FileInfo(path).Length;
|
||
if (size > opt.MaxPartBytes) throw new StepError($"частина {part.File} {size / 1048576.0:0.0} МБ більша за ліміт {opt.MaxPartBytes / 1048576} МБ");
|
||
var (l, segs) = await OneAsync(path, mime, ct);
|
||
lang ??= l;
|
||
all.AddRange(segs.Select(s => s with { Start = s.Start + offset, End = s.End + offset }));
|
||
offset += part.Duration;
|
||
}
|
||
return new Transcript(lang, all);
|
||
}
|
||
|
||
private async Task<(string? Lang, List<Segment> Segs)> OneAsync(string path, string mime, CancellationToken ct)
|
||
{
|
||
await using var fs = File.OpenRead(path);
|
||
using var form = new MultipartFormDataContent();
|
||
var file = new StreamContent(fs);
|
||
file.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
||
form.Add(file, "file", Path.GetFileName(path));
|
||
form.Add(new StringContent(opt.OpenAiModel), "model");
|
||
form.Add(new StringContent("verbose_json"), "response_format");
|
||
form.Add(new StringContent("segment"), "timestamp_granularities[]");
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, $"{opt.OpenAiBaseUrl}/audio/transcriptions") { Content = form };
|
||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", opt.OpenAiKey);
|
||
using var r = await http.CreateClient("openai").SendAsync(req, ct);
|
||
var body = await r.Content.ReadAsStringAsync(ct);
|
||
if (!r.IsSuccessStatusCode)
|
||
throw new StepError($"OpenAI {(int)r.StatusCode}: {Short(body)}", permanent: r.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden);
|
||
using var doc = JsonDocument.Parse(body);
|
||
var root = doc.RootElement;
|
||
if (!root.TryGetProperty("segments", out var segs) || segs.ValueKind != JsonValueKind.Array)
|
||
throw new StepError($"модель {opt.OpenAiModel} не повернула сегменти з таймінгами (потрібна whisper-1)", permanent: true);
|
||
var list = new List<Segment>();
|
||
foreach (var s in segs.EnumerateArray())
|
||
{
|
||
var text = s.GetProperty("text").GetString()?.Trim() ?? "";
|
||
if (text.Length > 0) list.Add(new Segment(s.GetProperty("start").GetDouble(), s.GetProperty("end").GetDouble(), text));
|
||
}
|
||
return (root.TryGetProperty("language", out var l) ? l.GetString() : null, list);
|
||
}
|
||
|
||
internal static string Short(string s) => s.Length > 300 ? s[..300] + "…" : s;
|
||
}
|
||
|
||
/// <summary>Пользователь CRM из env: вход в Supabase (password grant), токен кэшируется до истечения.</summary>
|
||
public sealed class CrmSession(IHttpClientFactory http, RecordingOptions opt)
|
||
{
|
||
private string? _token;
|
||
private DateTimeOffset _until;
|
||
|
||
public async Task<string> TokenAsync(CancellationToken ct, bool force = false)
|
||
{
|
||
if (!force && _token != null && _until > DateTimeOffset.UtcNow.AddMinutes(1)) return _token;
|
||
using var req = new HttpRequestMessage(HttpMethod.Post, $"{opt.SupabaseUrl}/auth/v1/token?grant_type=password")
|
||
{
|
||
Content = JsonContent.Create(new { email = opt.CrmUserEmail, password = opt.CrmUserPassword }),
|
||
};
|
||
req.Headers.Add("apikey", opt.SupabaseKey);
|
||
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
|
||
var body = await r.Content.ReadAsStringAsync(ct);
|
||
if (!r.IsSuccessStatusCode)
|
||
throw new StepError($"вхід користувача CRM ({opt.CrmUserEmail}) не вдався: {(int)r.StatusCode}", permanent: (int)r.StatusCode is 400 or 401);
|
||
using var doc = JsonDocument.Parse(body);
|
||
_token = doc.RootElement.GetProperty("access_token").GetString();
|
||
_until = DateTimeOffset.UtcNow.AddSeconds(doc.RootElement.TryGetProperty("expires_in", out var e) ? e.GetInt32() : 3600);
|
||
return _token!;
|
||
}
|
||
}
|
||
|
||
/// <summary>Supabase Storage: загрузка аудио под пользователем CRM. Бакет публичный (как вложения CRM) — ссылка getPublicUrl.</summary>
|
||
public sealed class AudioStorage(IHttpClientFactory http, RecordingOptions opt, CrmSession crm)
|
||
{
|
||
public string PublicUrl(string path) =>
|
||
$"{opt.SupabaseUrl}/storage/v1/object/public/{Uri.EscapeDataString(opt.Bucket)}/{string.Join('/', path.Split('/').Select(Uri.EscapeDataString))}";
|
||
|
||
public async Task UploadAsync(string path, string file, string mime, CancellationToken ct)
|
||
{
|
||
for (var attempt = 0; ; attempt++)
|
||
{
|
||
var token = await crm.TokenAsync(ct, force: attempt > 0);
|
||
await using var fs = File.OpenRead(file);
|
||
using var req = new HttpRequestMessage(HttpMethod.Post,
|
||
$"{opt.SupabaseUrl}/storage/v1/object/{Uri.EscapeDataString(opt.Bucket)}/{string.Join('/', path.Split('/').Select(Uri.EscapeDataString))}")
|
||
{ Content = new StreamContent(fs) };
|
||
req.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
|
||
req.Headers.Add("apikey", opt.SupabaseKey);
|
||
req.Headers.Add("x-upsert", "false");
|
||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||
using var r = await http.CreateClient("storage").SendAsync(req, ct);
|
||
var body = await r.Content.ReadAsStringAsync(ct);
|
||
if (r.IsSuccessStatusCode) return;
|
||
// Повтор после того, как прошлый раз загрузка прошла, а ответ потерялся: файл уже есть — это успех.
|
||
if (body.Contains("Duplicate", StringComparison.OrdinalIgnoreCase) || body.Contains("already exists", StringComparison.OrdinalIgnoreCase)) return;
|
||
if (r.StatusCode == HttpStatusCode.Unauthorized && attempt == 0) continue;
|
||
throw new StepError($"Supabase Storage {(int)r.StatusCode}: {Transcriber.Short(body)}",
|
||
permanent: r.StatusCode is HttpStatusCode.Forbidden || body.Contains("row-level security", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Вики CRM (REST API wiki-crm): папка «Дзвінки» в корне workspace и страница звонка — от пользователя CRM.</summary>
|
||
public sealed class WikiPublisher(IHttpClientFactory http, RecordingOptions opt, CrmSession crm)
|
||
{
|
||
public async Task<string> PublishAsync(string workspaceId, string title, string markdown, CancellationToken ct)
|
||
{
|
||
var tree = await SendAsync(HttpMethod.Get, "/api/tree", workspaceId, null, ct);
|
||
string? folderId = null, existing = null;
|
||
foreach (var n in tree.GetProperty("nodes").EnumerateArray())
|
||
if (n.GetProperty("kind").GetString() == "folder" && n.GetProperty("parentId").ValueKind == JsonValueKind.Null
|
||
&& n.GetProperty("title").GetString() == opt.WikiFolder) { folderId = n.GetProperty("id").GetString(); break; }
|
||
if (folderId == null)
|
||
folderId = (await SendAsync(HttpMethod.Post, "/api/folders", workspaceId, new { title = opt.WikiFolder }, ct)).GetProperty("id").GetString();
|
||
else
|
||
foreach (var n in tree.GetProperty("nodes").EnumerateArray())
|
||
// Повтор после потерянного ответа: такая страница в папке уже есть.
|
||
if (n.GetProperty("kind").GetString() == "page" && n.GetProperty("parentId").GetString() == folderId
|
||
&& n.GetProperty("title").GetString() == title) { existing = n.GetProperty("id").GetString(); break; }
|
||
if (existing != null) return existing;
|
||
var page = await SendAsync(HttpMethod.Post, "/api/pages", workspaceId, new { title, parentId = folderId, markdown }, ct);
|
||
return page.GetProperty("id").GetString()!;
|
||
}
|
||
|
||
private async Task<JsonElement> SendAsync(HttpMethod m, string path, string ws, object? body, CancellationToken ct)
|
||
{
|
||
for (var attempt = 0; ; attempt++)
|
||
{
|
||
using var req = new HttpRequestMessage(m, opt.WikiUrl + path);
|
||
if (body != null) req.Content = JsonContent.Create(body);
|
||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await crm.TokenAsync(ct, force: attempt > 0));
|
||
req.Headers.Add("X-Wiki-Workspace", ws);
|
||
using var r = await http.CreateClient("wiki").SendAsync(req, ct);
|
||
var text = await r.Content.ReadAsStringAsync(ct);
|
||
if (r.IsSuccessStatusCode) return JsonDocument.Parse(text).RootElement.Clone();
|
||
if (r.StatusCode == HttpStatusCode.Unauthorized && attempt == 0) continue;
|
||
if (r.StatusCode == HttpStatusCode.Forbidden)
|
||
throw new StepError($"користувач CRM {opt.CrmUserEmail} не має доступу до воркспейсу {ws} у вікі — додайте його учасником воркспейсу", permanent: true);
|
||
throw new StepError($"вікі {m} {path}: {(int)r.StatusCode} {Transcriber.Short(text)}");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Страница звонка в вики (Markdown, украинский).</summary>
|
||
public static class CallPage
|
||
{
|
||
public static string Title(DateTimeOffset start, TimeZoneInfo tz) =>
|
||
"Дзвінок " + TimeZoneInfo.ConvertTime(start, tz).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||
|
||
public static string Markdown(DateTimeOffset start, double durationSeconds, IEnumerable<Participant> people,
|
||
string audioUrl, Transcript t, TimeZoneInfo tz)
|
||
{
|
||
var local = TimeZoneInfo.ConvertTime(start, tz);
|
||
var sb = new StringBuilder();
|
||
sb.AppendLine($"**Дата і час:** {local.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture)} ({tz.Id}) ");
|
||
sb.AppendLine($"**Тривалість:** {Duration(durationSeconds)} ");
|
||
var names = people.Select(p => p.Guest ? $"{p.Name} (гість)" : p.Name).ToList();
|
||
sb.AppendLine($"**Учасники:** {(names.Count > 0 ? string.Join(", ", names) : "—")} ");
|
||
sb.AppendLine($"**Аудіо:** [запис дзвінка]({audioUrl})");
|
||
sb.AppendLine();
|
||
sb.AppendLine("## Транскрипція");
|
||
sb.AppendLine();
|
||
if (t.Segments.Count == 0) sb.AppendLine("_Мовлення не розпізнано._");
|
||
foreach (var s in t.Segments)
|
||
sb.AppendLine($"[{Clock(s.Start)}] {s.Text.Replace("\n", " ").Trim()} ");
|
||
return sb.ToString();
|
||
}
|
||
|
||
public static string Clock(double seconds)
|
||
{
|
||
var ts = TimeSpan.FromSeconds(Math.Max(0, Math.Floor(seconds)));
|
||
return $"{(int)ts.TotalHours:00}:{ts.Minutes:00}:{ts.Seconds:00}";
|
||
}
|
||
|
||
public static string Duration(double seconds)
|
||
{
|
||
var ts = TimeSpan.FromSeconds(Math.Round(seconds));
|
||
return ts.TotalHours >= 1 ? $"{(int)ts.TotalHours} год {ts.Minutes} хв {ts.Seconds} с" : $"{ts.Minutes} хв {ts.Seconds} с";
|
||
}
|
||
}
|