Ad-Hoc mapping for /omemo commands

This commit is contained in:
Bohdan Horbeshko 2026-07-30 08:52:59 -04:00
parent aa7d31a4f7
commit 40c1a8ceff
3 changed files with 334 additions and 46 deletions

View file

@ -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 <id>|devices accept <id>]", 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 <id>|accept <id>]", false
}

View file

@ -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

204
xmpp/omemo_command.go Normal file
View file

@ -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{&note},
}
}
// 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"
}