From 40c1a8ceff96cd6591a048c5e59dccd8e19cc274 Mon Sep 17 00:00:00 2001 From: Bohdan Horbeshko Date: Thu, 30 Jul 2026 08:52:59 -0400 Subject: [PATCH] Ad-Hoc mapping for /omemo commands --- telegram/omemo_commands.go | 158 +++++++++++++++++++--------- xmpp/handlers.go | 18 ++++ xmpp/omemo_command.go | 204 +++++++++++++++++++++++++++++++++++++ 3 files changed, 334 insertions(+), 46 deletions(-) create mode 100644 xmpp/omemo_command.go diff --git a/telegram/omemo_commands.go b/telegram/omemo_commands.go index 95fbf38..afaee03 100644 --- a/telegram/omemo_commands.go +++ b/telegram/omemo_commands.go @@ -1,6 +1,7 @@ package telegram import ( + "errors" "fmt" "strings" @@ -8,73 +9,145 @@ import ( "dev.narayana.im/narayana/telegabber/xmpp/gateway" ) -// cmdOmemoChat implements the chat-scoped "/omemo" command: with no -// arguments, reports this chat's current OMEMO state (account-wide mode, -// any override, and the resulting effective state); "on"/"off" forces this -// specific chat regardless of the account-wide mode (persistence.Session.OMEMO, -// via e2ee.Mode/e2ee.ShouldEncrypt); "clear" removes that override, reverting -// to following the account-wide mode; "devices ..." manages this chat's -// peer's device trust (see cmdOmemoDevices). -func (c *Client) cmdOmemoChat(chatID int64, args []string) (string, bool) { +// OmemoDeviceStatus is one known device of a chat's peer - shared data +// backing both the text "/omemo devices" command and the ad-hoc command's +// form (xmpp/omemo_command.go), so both surfaces always agree. +type OmemoDeviceStatus struct { + ID string + Trusted bool +} + +// OmemoChatStatus is a chat's current OMEMO state - shared data backing +// both the text "/omemo" command and the ad-hoc command's form. +type OmemoChatStatus struct { + Mode string // account-wide mode: "on"/"auto"/"off" (e2ee.Mode.String()) + OverrideSet bool + Override bool // meaningful only if OverrideSet + Effective bool // e2ee.ShouldEncrypt's actual verdict right now + Devices []OmemoDeviceStatus +} + +// GetOmemoChatStatus reports chatID's current OMEMO state. +func (c *Client) GetOmemoChatStatus(chatID int64) (OmemoChatStatus, error) { backend, ok := gateway.E2EE.Backend() if !ok { - return "OMEMO is not enabled on this transport", false + return OmemoChatStatus{}, errors.New("OMEMO is not enabled on this transport") } owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatID, false)) + mode, err := e2ee.ParseMode(c.Session.OMEMO) + if err != nil { + return OmemoChatStatus{}, err + } + override, isSet, err := backend.Override(owner) + if err != nil { + return OmemoChatStatus{}, err + } + active, err := e2ee.ShouldEncrypt(backend, owner, mode) + if err != nil { + return OmemoChatStatus{}, err + } + devices, err := backend.Devices(owner, e2ee.PeerID(c.jid)) + if err != nil { + return OmemoChatStatus{}, err + } + + statuses := make([]OmemoDeviceStatus, len(devices)) + for i, d := range devices { + statuses[i] = OmemoDeviceStatus{ID: string(d.ID), Trusted: d.Trusted} + } + + return OmemoChatStatus{ + Mode: mode.String(), + OverrideSet: isSet, + Override: override, + Effective: active, + Devices: statuses, + }, nil +} + +// SetOmemoChatOverride forces (on non-nil) or clears (on nil) chatID's +// OMEMO override. +func (c *Client) SetOmemoChatOverride(chatID int64, on *bool) error { + backend, ok := gateway.E2EE.Backend() + if !ok { + return errors.New("OMEMO is not enabled on this transport") + } + owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatID, false)) + if on == nil { + return backend.ClearOverride(owner) + } + return backend.SetOverride(owner, *on) +} + +// SetOmemoDeviceBlocked blocks or unblocks one of chatID's peer's devices. +func (c *Client) SetOmemoDeviceBlocked(chatID int64, deviceID string, blocked bool) error { + backend, ok := gateway.E2EE.Backend() + if !ok { + return errors.New("OMEMO is not enabled on this transport") + } + owner := e2ee.OwnedPeer(c.Session.Login, gateway.CHATJID(chatID, false)) + peer := e2ee.PeerID(c.jid) + if blocked { + return backend.BlockDevice(owner, peer, e2ee.DeviceID(deviceID)) + } + return backend.UnblockDevice(owner, peer, e2ee.DeviceID(deviceID)) +} + +// cmdOmemoChat implements the chat-scoped "/omemo" text command: with no +// arguments, reports this chat's current OMEMO state (account-wide mode, +// any override, and the resulting effective state); "on"/"off" forces this +// specific chat regardless of the account-wide mode; "clear" removes that +// override, reverting to following the account-wide mode; "devices ..." +// manages this chat's peer's device trust (see cmdOmemoDevices). The +// ad-hoc command surface (xmpp/omemo_command.go) offers the same controls +// as a form, built from the same GetOmemoChatStatus/SetOmemoChatOverride/ +// SetOmemoDeviceBlocked methods this uses. +func (c *Client) cmdOmemoChat(chatID int64, args []string) (string, bool) { if len(args) == 0 { - return c.omemoChatStatus(backend, owner) + return c.omemoChatStatusString(chatID) } switch args[0] { case "on", "off": - if err := backend.SetOverride(owner, args[0] == "on"); err != nil { + on := args[0] == "on" + if err := c.SetOmemoChatOverride(chatID, &on); err != nil { return err.Error(), false } return fmt.Sprintf("OMEMO forced %s for this chat", args[0]), true case "clear": - if err := backend.ClearOverride(owner); err != nil { + if err := c.SetOmemoChatOverride(chatID, nil); err != nil { return err.Error(), false } return "OMEMO override cleared for this chat, now following the account-wide mode", true case "devices": - return c.cmdOmemoDevices(backend, owner, args[1:]) + return c.cmdOmemoDevices(chatID, args[1:]) default: return "Usage: /omemo [on|off|clear|devices list|devices block |devices accept ]", false } } -func (c *Client) omemoChatStatus(backend e2ee.Backend, owner e2ee.PeerID) (string, bool) { - mode, err := e2ee.ParseMode(c.Session.OMEMO) - if err != nil { - return err.Error(), false - } - override, isSet, err := backend.Override(owner) - if err != nil { - return err.Error(), false - } - active, err := e2ee.ShouldEncrypt(backend, owner, mode) +func (c *Client) omemoChatStatusString(chatID int64) (string, bool) { + status, err := c.GetOmemoChatStatus(chatID) if err != nil { return err.Error(), false } overrideText := "not set (following the account-wide mode)" - if isSet { - overrideText = fmt.Sprintf("forced %s", onOffString(override)) + if status.OverrideSet { + overrideText = fmt.Sprintf("forced %s", onOffString(status.Override)) } return fmt.Sprintf("Account-wide mode: %s\nThis chat's override: %s\nEffective right now: %s", - mode, overrideText, onOffString(active)), true + status.Mode, overrideText, onOffString(status.Effective)), true } -// cmdOmemoDevices lists, blocks, or accepts (unblocks) devices of owner's -// chat's real peer. TOFU trust itself always stays on (a never-before-seen +// cmdOmemoDevices lists, blocks, or accepts (unblocks) devices of chatID's +// real peer. TOFU trust itself always stays on (a never-before-seen // device is always trusted automatically) - block/accept only manage a -// manual override layered on top of that, per Backend.BlockDevice's doc -// comment. -func (c *Client) cmdOmemoDevices(backend e2ee.Backend, owner e2ee.PeerID, args []string) (string, bool) { - peer := e2ee.PeerID(c.jid) - +// manual override layered on top of that, per e2ee.Backend.BlockDevice's +// doc comment. +func (c *Client) cmdOmemoDevices(chatID int64, args []string) (string, bool) { sub := "list" if len(args) > 0 { sub = args[0] @@ -82,15 +155,15 @@ func (c *Client) cmdOmemoDevices(backend e2ee.Backend, owner e2ee.PeerID, args [ switch sub { case "list": - devices, err := backend.Devices(owner, peer) + status, err := c.GetOmemoChatStatus(chatID) if err != nil { return err.Error(), false } - if len(devices) == 0 { + if len(status.Devices) == 0 { return "No known devices for this chat's peer yet", true } - lines := make([]string, 0, len(devices)) - for _, d := range devices { + lines := make([]string, 0, len(status.Devices)) + for _, d := range status.Devices { lines = append(lines, fmt.Sprintf("%s: %s", d.ID, trustString(d.Trusted))) } return strings.Join(lines, "\n"), true @@ -98,17 +171,10 @@ func (c *Client) cmdOmemoDevices(backend e2ee.Backend, owner e2ee.PeerID, args [ if len(args) < 2 { return notEnoughArguments, false } - device := e2ee.DeviceID(args[1]) - var err error - if sub == "block" { - err = backend.BlockDevice(owner, peer, device) - } else { - err = backend.UnblockDevice(owner, peer, device) - } - if err != nil { + if err := c.SetOmemoDeviceBlocked(chatID, args[1], sub == "block"); err != nil { return err.Error(), false } - return fmt.Sprintf("Device %s %sed", device, sub), true + return fmt.Sprintf("Device %s %sed", args[1], sub), true default: return "Usage: /omemo devices [list|block |accept ]", false } diff --git a/xmpp/handlers.go b/xmpp/handlers.go index c708b52..a7aa99f 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -1859,6 +1859,15 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command CommandElements: elements, } } + } else if command.Node == "omemo" { + session, ok := sessions[bare] + if ok { + if toOk { + answer.Payload = submitOmemoChatForm(session, toId, form) + } else { + answer.Payload = submitOmemoTransportForm(session, form) + } + } } else if !toOk && command.Node == "loginwizard" { var session *telegram.Client answer.Payload, cancelSend, session = loginWizardPayload(bare, form, resource, command.Action) @@ -1952,6 +1961,15 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command log.Debugf("field: %#v", field) } } + } else if command.Node == "omemo" { + session, ok := sessions[bare] + if ok { + if toOk { + fields = buildOmemoChatFormFields(session, toId) + } else { + fields = buildOmemoTransportFormFields(session) + } + } } else { for i, arg := range cmd.Arguments { var required *string diff --git a/xmpp/omemo_command.go b/xmpp/omemo_command.go new file mode 100644 index 0000000..78fafda --- /dev/null +++ b/xmpp/omemo_command.go @@ -0,0 +1,204 @@ +package xmpp + +import ( + "fmt" + + "dev.narayana.im/narayana/telegabber/telegram" + "dev.narayana.im/narayana/telegabber/xmpp/gateway" + + "gosrc.io/xmpp/stanza" +) + +// buildOmemoTransportFormFields builds the initial (no form submitted yet) +// field list for the transport-level "/omemo" ad-hoc command: a single +// list-single field for the account-wide mode, pre-selected to the current +// value. Plugs into handleSetQueryCommand's shared per-command +// field-then-form-then-answer construction, the same way "config"'s own +// field-building does. +func buildOmemoTransportFormFields(session *telegram.Client) []*stanza.Field { + mode, _ := session.Session.Get("omemo") + + return []*stanza.Field{ + { + Var: "mode", + Label: "OMEMO mode", + Type: stanza.FieldTypeListSingle, + Options: []stanza.Option{ + {Label: "Automatic (default)", ValuesList: []string{"auto"}}, + {Label: "Always on", ValuesList: []string{"on"}}, + {Label: "Always off", ValuesList: []string{"off"}}, + }, + ValuesList: []string{mode}, + }, + } +} + +// buildOmemoChatFormFields builds the initial field list for the +// chat-scoped "/omemo" ad-hoc command: a read-only effective-status field, +// a list-single override control, and two list-single "block"/"accept" +// fields - each an empty-by-default choice (no selection = no action) +// among the CURRENT opposite-state devices (block's options are the +// currently-trusted devices, accept's are the currently-blocked ones). +// This mirrors handleSetQueryCommand's existing mute/kick/ban/unmute/unban +// field-building exactly (a list-single of opposite-state members with a +// blank leading option), rather than a separate reported/item table plus +// list-multi fields - one form field per action, same as those commands. +func buildOmemoChatFormFields(session *telegram.Client, chatID int64) []*stanza.Field { + status, err := session.GetOmemoChatStatus(chatID) + if err != nil { + return []*stanza.Field{ + {Type: stanza.FieldTypeFixed, Label: "Error", ValuesList: []string{err.Error()}}, + } + } + + overrideValue := "" + if status.OverrideSet { + overrideValue = omemoOnOff(status.Override) + } + + fields := []*stanza.Field{ + { + Var: "effective", + Label: "Effective right now", + Type: stanza.FieldTypeFixed, + ValuesList: []string{fmt.Sprintf("%s (account-wide mode: %s)", omemoOnOff(status.Effective), status.Mode)}, + }, + { + Var: "override", + Label: "Override for this chat", + Type: stanza.FieldTypeListSingle, + Options: []stanza.Option{ + {Label: "Follow account-wide mode", ValuesList: []string{""}}, + {Label: "Always on", ValuesList: []string{"on"}}, + {Label: "Always off", ValuesList: []string{"off"}}, + }, + ValuesList: []string{overrideValue}, + }, + } + + // allow an empty selection ("no action"), same as mute/unmute's own + // blank leading option + blockOptions := []stanza.Option{{ValuesList: []string{""}}} + acceptOptions := []stanza.Option{{ValuesList: []string{""}}} + for _, d := range status.Devices { + option := stanza.Option{Label: d.ID, ValuesList: []string{d.ID}} + if d.Trusted { + blockOptions = append(blockOptions, option) + } else { + acceptOptions = append(acceptOptions, option) + } + } + + return append(fields, + &stanza.Field{Var: "block", Label: "Block device", Type: stanza.FieldTypeListSingle, Options: blockOptions}, + &stanza.Field{Var: "accept", Label: "Accept (unblock) device", Type: stanza.FieldTypeListSingle, Options: acceptOptions}, + ) +} + +// submitOmemoTransportForm applies a submitted transport-level "/omemo" +// form. +func submitOmemoTransportForm(session *telegram.Client, form *stanza.Form) *stanza.Command { + var mode string + for _, field := range form.Fields { + if field != nil && field.Var == "mode" && len(field.ValuesList) > 0 { + mode = field.ValuesList[0] + } + } + + var note stanza.Note + if value, err := session.Session.Set("omemo", mode); err != nil { + note = stanza.Note{Text: err.Error(), Type: stanza.CommandNoteTypeErr} + } else { + gateway.DirtySessions = true + note = stanza.Note{Text: fmt.Sprintf("omemo mode set to %s", value), Type: stanza.CommandNoteTypeInfo} + } + + return &stanza.Command{ + SessionId: "omemo", + Node: "omemo", + Status: stanza.CommandStatusCompleted, + CommandElements: []stanza.CommandElement{¬e}, + } +} + +// submitOmemoChatForm applies a submitted chat-scoped "/omemo" form: the +// override change (if submitted), then at most one block and one accept +// (each field is a single empty-or-one-device selection, not a multi-pick +// - see buildOmemoChatFormFields) - all in one round trip, since XEP-0050 +// single-stage commands only get one. +func submitOmemoChatForm(session *telegram.Client, chatID int64, form *stanza.Form) *stanza.Command { + var overrideValue string + var overrideSubmitted bool + var blockID, acceptID string + for _, field := range form.Fields { + if field == nil { + continue + } + switch field.Var { + case "override": + overrideSubmitted = true + if len(field.ValuesList) > 0 { + overrideValue = field.ValuesList[0] + } + case "block": + if len(field.ValuesList) > 0 { + blockID = field.ValuesList[0] + } + case "accept": + if len(field.ValuesList) > 0 { + acceptID = field.ValuesList[0] + } + } + } + + var notes []stanza.CommandElement + if overrideSubmitted { + switch overrideValue { + case "": + if err := session.SetOmemoChatOverride(chatID, nil); err != nil { + notes = append(notes, &stanza.Note{Text: err.Error(), Type: stanza.CommandNoteTypeErr}) + } else { + notes = append(notes, &stanza.Note{Text: "OMEMO override cleared for this chat", Type: stanza.CommandNoteTypeInfo}) + } + case "on", "off": + on := overrideValue == "on" + if err := session.SetOmemoChatOverride(chatID, &on); err != nil { + notes = append(notes, &stanza.Note{Text: err.Error(), Type: stanza.CommandNoteTypeErr}) + } else { + notes = append(notes, &stanza.Note{Text: fmt.Sprintf("OMEMO forced %s for this chat", overrideValue), Type: stanza.CommandNoteTypeInfo}) + } + } + } + + if blockID != "" { + if err := session.SetOmemoDeviceBlocked(chatID, blockID, true); err != nil { + notes = append(notes, &stanza.Note{Text: fmt.Sprintf("Failed to block device %s: %v", blockID, err), Type: stanza.CommandNoteTypeErr}) + } else { + notes = append(notes, &stanza.Note{Text: fmt.Sprintf("Device %s blocked", blockID), Type: stanza.CommandNoteTypeInfo}) + } + } + if acceptID != "" { + if err := session.SetOmemoDeviceBlocked(chatID, acceptID, false); err != nil { + notes = append(notes, &stanza.Note{Text: fmt.Sprintf("Failed to accept device %s: %v", acceptID, err), Type: stanza.CommandNoteTypeErr}) + } else { + notes = append(notes, &stanza.Note{Text: fmt.Sprintf("Device %s accepted", acceptID), Type: stanza.CommandNoteTypeInfo}) + } + } + if len(notes) == 0 { + notes = append(notes, &stanza.Note{Text: "No changes", Type: stanza.CommandNoteTypeInfo}) + } + + return &stanza.Command{ + SessionId: "omemo", + Node: "omemo", + Status: stanza.CommandStatusCompleted, + CommandElements: notes, + } +} + +func omemoOnOff(b bool) string { + if b { + return "on" + } + return "off" +}