using System.Text.Json; using System.Text.Json.Serialization; using SwyxTray.Swyx; namespace SwyxTray.Web; /// /// Die ueber den WebSocket ausgetauschten Nachrichten. Alles ist UTF-8-JSON, /// eine Nachricht je WebSocket-Frame. /// /// Leitungen werden nach aussen 1-basiert gezaehlt ("Leitung 1"), so wie sie im /// Kontextmenue und in SwyxIt! erscheinen — intern sind sie 0-basiert. /// internal static class Protocol { public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public static string Serialize(T message) => JsonSerializer.Serialize(message, Json); } /// Nachricht der Webseite an die App. internal sealed class ClientCommand { /// Frei waehlbar; wird in der Antwort zurueckgegeben. public int? Id { get; set; } public string? Cmd { get; set; } /// Bei call und resolve. public string? Number { get; set; } /// Nur bei contacts: der Suchbegriff (Name oder Nummer). public string? Query { get; set; } /// 1-basierte Leitung; fehlt sie, waehlt die App selbst. public int? Line { get; set; } /// Nur bei focus: gesuchter Fenstertitel. public string? Title { get; set; } /// /// Bei focus und opentab: die zu oeffnende Adresse. /// public string? Url { get; set; } /// Nur bei tabresult (Plugin-Port): Auftrag ausgefuehrt? public bool? Ok { get; set; } /// /// Bei closetab (Hauptport): der zu schliessende Tab. Bei /// tabresult auf open (Plugin-Port): Id des neuen Tabs. /// public int? TabId { get; set; } /// Nur bei tabresult auf list: die offenen Tabs. public List? Tabs { get; set; } } /// Ein offener Tab, wie ihn das Plugin bei list meldet. internal sealed class TabInfo { public int? Id { get; set; } public string? Title { get; set; } public string? Url { get; set; } public bool? Active { get; set; } } /// Begruessung unmittelbar nach dem Verbindungsaufbau. internal sealed record HelloMessage(string App, string Version, int Protocol, int Session) { public string Type => "hello"; } /// Ein Adressdatensatz in der Antwort auf contacts und in /// der addresses-Nachricht. internal sealed record ContactMessage(string Name, string Number, string Description) { public static ContactMessage From(SwyxContact contact) => new(contact.Name, contact.Number, contact.Description); } /// /// Die zwischengespeicherten Adressdaten aus dem Swyx-Client (globales und /// persoenliches Telefonbuch). Wird nach dem snapshot beim Verbinden, /// auf status und nach jedem Neueinlesen mit Aenderung gesendet — die /// Seite muss die Daten also nie selbst anfordern. /// internal sealed record AddressesMessage(IReadOnlyList Addresses) { public string Type => "addresses"; public static AddressesMessage From(IEnumerable addresses) => new(addresses.Select(ContactMessage.From).ToList()); } /// /// Antwort auf genau ein Kommando. Focused wird nur bei focus /// gesetzt: true = Fenster in den Vordergrund geholt, false = /// stattdessen den Browser mit der URL gestartet. Tabs steht nur in /// der Antwort auf tabs, TabId nur in der auf opentab, /// Contacts nur in der auf contacts, Name nur in der /// auf resolve (leer, wenn der Client die Nummer nicht kennt), /// Addresses nur in der auf addresses (der komplette Cache). /// internal sealed record ResultMessage( int? Id, bool Ok, string? Error = null, int? Line = null, bool? Focused = null, int? TabId = null, IReadOnlyList? Tabs = null, IReadOnlyList? Contacts = null, string? Name = null, IReadOnlyList? Addresses = null) { public string Type => "result"; } /// /// Auftrag an das Firefox-Plugin, nur auf dem Plugin-Port. Drei Aktionen: /// list (offene Tabs melden), open (neuen Tab mit Url /// oeffnen), close (Tab TabId schliessen). Das Plugin antwortet /// jeweils mit {"cmd":"tabresult","id":…,"ok":…} — bei list /// zusaetzlich mit tabs, bei open mit tabId. Die Id /// stammt hier vom Server, nicht von einer Webseite. /// internal sealed record TabRequestMessage(int Id, string Action, string? Url = null, int? TabId = null) { public string Type => "tab"; } internal sealed record LineMessage( int Line, string State, int StateCode, string StateText, string Peer, string PeerNumber, string PeerName, bool Busy, bool Selected); /// /// Anruf-Ereignis, abgeleitet aus dem Vergleich zweier aufeinanderfolgender /// Zustaende. Wird zusaetzlich zum snapshot gesendet, damit eine Seite auf /// eingehende Rufe reagieren kann, ohne selbst Zustaende zu vergleichen. /// /// Event ist incoming (Leitung beginnt zu klingeln), /// outgoing (Wahl beginnt), connected (Gespraech steht) oder /// ended (Leitung wieder frei). Bei connected steht in /// Direction, ob der Ruf ein- oder ausgehend war, sofern erkennbar; /// bei ended stammen die Peer-Angaben aus dem letzten belegten Zustand. /// internal sealed record CallEventMessage( string Event, int Line, string Peer, string PeerNumber, string PeerName, string? Direction = null) { public string Type => "call"; public static IReadOnlyList Diff(SwyxSnapshot previous, SwyxSnapshot current) { var events = new List(); var before = previous.Lines.ToDictionary(l => l.Index); foreach (var line in current.Lines) { before.TryGetValue(line.Index, out var prev); var wasRinging = prev?.State.IsRinging() ?? false; var wasDialing = prev?.State.IsDialing() ?? false; var wasTalking = prev is not null && (prev.State.IsActive() || prev.State.IsOnHold()); if (line.State.IsRinging() && !wasRinging) { events.Add(From("incoming", line)); } else if (line.State.IsDialing() && !wasDialing && !wasRinging && !wasTalking) { events.Add(From("outgoing", line)); } else if (line.State.IsActive() && !wasTalking) { // Halten und Zurueckholen ist kein neues Gespraech; der Wechsel // aus Klingeln oder Wahl dagegen schon. events.Add(From("connected", line, wasRinging ? "incoming" : wasDialing ? "outgoing" : null)); } } var after = current.Lines.ToDictionary(l => l.Index); foreach (var prev in previous.Lines.Where(l => l.IsBusy)) { if (!after.TryGetValue(prev.Index, out var line) || !line.IsBusy) { events.Add(From("ended", prev)); } } return events; } private static CallEventMessage From(string evt, SwyxLineInfo line, string? direction = null) => new(evt, line.Index + 1, line.PeerDisplayLong, line.PeerNumber, line.PeerName, direction); } /// /// Vollstaendiger Zustand. Wird nach dem hello, bei jeder Aenderung und auf /// status gesendet — die Webseite muss also nie einen Zustand mitfuehren. /// internal sealed record SnapshotMessage( bool Connected, bool ServerUp, string Overall, string StatusText, string User, string Server, IReadOnlyList Lines) { public string Type => "snapshot"; public static SnapshotMessage From(SwyxSnapshot snapshot) => new( Connected: snapshot.IsConnected, ServerUp: snapshot.IsServerUp, Overall: snapshot.Overall.ToString(), StatusText: snapshot.StatusText, User: snapshot.UserName, Server: snapshot.ServerName, Lines: snapshot.Lines.Select(l => new LineMessage( Line: l.Index + 1, State: l.State.ToString(), StateCode: (int)l.State, StateText: l.State.ToDisplayText(), Peer: l.PeerDisplayLong, PeerNumber: l.PeerNumber, PeerName: l.PeerName, Busy: l.IsBusy, Selected: l.IsSelected)).ToList()); }