Backport XEP-0115 support from calls branch

This commit is contained in:
Bohdan Horbeshko 2026-08-19 15:40:57 -04:00
parent afb892d493
commit a7b4eb7d88
4 changed files with 236 additions and 24 deletions

View file

@ -609,6 +609,7 @@ func (c *Client) ProcessStatusUpdate(chatID int64, status string, show string, o
if isMUC {
newArgs = append(newArgs, gateway.SPFullFrom(gateway.MUCJID(chatID)))
} else {
newArgs = append(newArgs, gateway.SPCaps(gateway.PMCapsVer()))
newArgs = gateway.SPAppendFrom(newArgs, chatID)
}

View file

@ -2,8 +2,13 @@ package gateway
import (
"context"
"crypto/sha1"
"encoding/base64"
"encoding/xml"
"fmt"
"github.com/pkg/errors"
"io"
"sort"
"strconv"
"strings"
"sync"
@ -53,6 +58,12 @@ const NodeAvatarMetadataNotify string = NodeAvatarMetadata + "+notify"
const NodeAvatarData string = "urn:xmpp:avatar:data"
const NSCommand string = "http://jabber.org/protocol/commands"
// NSCaps is the XEP-0115 entity capabilities namespace
const NSCaps string = "http://jabber.org/protocol/caps"
// CapsNode is this software's advertised XEP-0115 node URI
const CapsNode string = "https://dev.narayana.im/narayana/telegabber/"
const NS_MAM2 = "urn:xmpp:mam:2"
const NS_MAM1 = "urn:xmpp:mam:1"
const NS_MAM0 = "urn:xmpp:mam:0"
@ -722,6 +733,9 @@ var SPResource = args.NewString()
// SPImmed skips queueing
var SPImmed = args.NewBool(args.Default(true))
// SPCaps is a XEP-0115 verification string
var SPCaps = args.NewString()
// SPMUCAffiliation is a XEP-0045 MUC affiliation
var SPMUCAffiliation = args.NewString()
@ -800,6 +814,16 @@ func newPresence(bareJid string, to string, args ...args.V) stanza.Presence {
})
}
}
if SPCaps.IsSet(args) {
ver := SPCaps.Get(args)
if ver != "" {
presence.Extensions = append(presence.Extensions, stanza.Caps{
Hash: "sha-1",
Node: CapsNode,
Ver: ver,
})
}
}
if SPMUCAffiliation.IsSet(args) {
affiliation := SPMUCAffiliation.Get(args)
if affiliation != "" {
@ -1053,3 +1077,121 @@ func SendPubSubDeviceListNotification(component *xmpp.Component, jid string, cha
func InviteToMUC(chatID int64, jid string, component *xmpp.Component) {
SendMUCInvite(jid, MUCNODE(chatID), component, Jid.Full())
}
// AddPMDiscoFeatures appends the disco#info identity/features for a 1:1 Telegram contact.
func AddPMDiscoFeatures(disco *stanza.DiscoInfo) {
disco.AddIdentity("", "account", "registered")
if backend, ok := E2EE.Backend(); ok {
disco.AddFeatures(backend.Namespaces().Disco...)
}
disco.AddFeatures(stanza.NSMsgChatMarkers)
disco.AddFeatures(stanza.NSMsgReceipts)
// Jingle / JMI features so clients (e.g. Dino) treat the contact as
// call-capable. Without these, JMI <proceed> never fires: the client
// disco-info's the caller before engaging its call state machine and
// silently drops the call when it sees no RTP support advertised.
disco.AddFeatures(
"urn:xmpp:jingle:1",
"urn:xmpp:jingle-message:0",
"urn:xmpp:jingle:apps:rtp:1",
"urn:xmpp:jingle:apps:rtp:audio",
"urn:xmpp:jingle:transports:ice-udp:1",
"urn:xmpp:jingle:apps:dtls:0",
)
}
// AddTransportDiscoFeatures appends the disco#info identity/features for the transport root JID.
func AddTransportDiscoFeatures(disco *stanza.DiscoInfo) {
disco.AddIdentity("Telegram Gateway", "gateway", "telegram")
disco.AddFeatures("jabber:iq:register")
}
// AddCommonDiscoFeatures appends features common to every root-node disco#info response.
func AddCommonDiscoFeatures(disco *stanza.DiscoInfo) {
disco.AddFeatures(
NSCommand,
"jabber:iq:version",
"urn:xmpp:time",
stanza.NSDiscoInfo,
NSCaps,
)
}
// PMCapsVer computes the XEP-0115 ver for a 1:1 contact's disco#info.
// Not cached: if AddPMDiscoFeatures ever starts varying silently (no
// signature change), a cached ver would go stale with no compiler warning.
func PMCapsVer() string {
disco := &stanza.DiscoInfo{}
AddPMDiscoFeatures(disco)
AddCommonDiscoFeatures(disco)
return DiscoVer(disco)
}
// DiscoVer computes a XEP-0115 ver string for a disco#info response.
func DiscoVer(disco *stanza.DiscoInfo) string {
hash := sha1.New()
discoToCaps(disco, hash)
return base64.StdEncoding.EncodeToString(hash.Sum(nil))
}
// discoToCaps writes disco's XEP-0115 S5.1 capabilities identifier string to w.
func discoToCaps(disco *stanza.DiscoInfo, w io.Writer) {
const sep = "<"
identities := make([]string, len(disco.Identity))
for i, identity := range disco.Identity {
identities[i] = fmt.Sprintf("%s/%s//%s", identity.Category, identity.Type, identity.Name)
}
sort.Strings(identities)
for _, identity := range identities {
io.WriteString(w, identity)
io.WriteString(w, sep)
}
vars := make([]string, len(disco.Features))
for i, feature := range disco.Features {
vars[i] = feature.Var
}
sort.Strings(vars)
for _, v := range vars {
io.WriteString(w, v)
io.WriteString(w, sep)
}
if disco.Form == nil {
return
}
fields := make([]*stanza.Field, len(disco.Form.Fields))
copy(fields, disco.Form.Fields)
sort.Slice(fields, func(i, j int) bool {
a, b := fields[i], fields[j]
if a.Var == "FORM_TYPE" {
return true
}
if b.Var == "FORM_TYPE" {
return false
}
return a.Var < b.Var
})
for _, field := range fields {
if field.Var == "FORM_TYPE" {
if len(field.ValuesList) > 0 {
io.WriteString(w, field.ValuesList[0])
io.WriteString(w, sep)
}
continue
}
io.WriteString(w, field.Var)
io.WriteString(w, sep)
values := field.ValuesList
if len(values) > 1 {
values = append([]string(nil), values...)
sort.Strings(values)
}
for _, value := range values {
io.WriteString(w, value)
io.WriteString(w, sep)
}
}
}

View file

@ -2,6 +2,7 @@ package gateway
import (
"encoding/xml"
"strings"
"testing"
"gosrc.io/xmpp/stanza"
@ -52,3 +53,87 @@ func TestPresencePhoto(t *testing.T) {
presence := newPresence("from@test", "to@test", SPPhoto("01b87fcd030b72895ff8e88db57ec525450f000d"))
testPresence(t, presence, "<presence from=\"from@test\" to=\"to@test\"><x xmlns=\"vcard-temp:x:update\"><photo>01b87fcd030b72895ff8e88db57ec525450f000d</photo></x></presence>")
}
func TestPresenceCaps(t *testing.T) {
presence := newPresence("from@test", "to@test", SPCaps("QgayPKawpkPSDYmwT/WM94uAlu0="))
testPresence(t, presence, "<presence from=\"from@test\" to=\"to@test\"><c xmlns=\"http://jabber.org/protocol/caps\" hash=\"sha-1\" node=\"https://dev.narayana.im/narayana/telegabber/\" ver=\"QgayPKawpkPSDYmwT/WM94uAlu0=\"></c></presence>")
}
// Canonical XEP-0115 S5.1 worked examples (Exodus, Psi, BombusMod).
func testDiscoVer(t *testing.T, disco *stanza.DiscoInfo, reference string) {
ver := DiscoVer(disco)
if ver != reference {
var sb strings.Builder
discoToCaps(disco, &sb)
t.Errorf("%v does not match %v\nRaw string: %v", ver, reference, sb.String())
}
}
func TestDiscoVerExodus(t *testing.T) {
disco := stanza.DiscoInfo{
Identity: []stanza.Identity{
{Name: "Exodus 0.9.1", Category: "client", Type: "pc"},
},
Features: []stanza.Feature{
{Var: "http://jabber.org/protocol/disco#info"},
{Var: "http://jabber.org/protocol/disco#items"},
{Var: "http://jabber.org/protocol/muc"},
{Var: "http://jabber.org/protocol/caps"},
},
}
testDiscoVer(t, &disco, "QgayPKawpkPSDYmwT/WM94uAlu0=")
}
func TestDiscoVerPsi(t *testing.T) {
disco := stanza.DiscoInfo{
Identity: []stanza.Identity{
{Name: "Psi 0.11", Category: "client", Type: "pc"},
{Name: "Ψ 0.11", Category: "client", Type: "pc"},
},
Features: []stanza.Feature{
{Var: "http://jabber.org/protocol/disco#info"},
{Var: "http://jabber.org/protocol/disco#items"},
{Var: "http://jabber.org/protocol/muc"},
{Var: "http://jabber.org/protocol/caps"},
},
Form: &stanza.Form{
Type: "result",
Fields: []*stanza.Field{
{Var: "FORM_TYPE", Type: "hidden", ValuesList: []string{"urn:xmpp:dataforms:softwareinfo"}},
{Var: "ip_version", Type: "text-multi", ValuesList: []string{"ipv4", "ipv6"}},
{Var: "os", ValuesList: []string{"Mac"}},
{Var: "os_version", ValuesList: []string{"10.5.1"}},
{Var: "software", ValuesList: []string{"Psi"}},
{Var: "software_version", ValuesList: []string{"0.11"}},
},
},
}
testDiscoVer(t, &disco, "MxdZjNKNku1+SiM9N92yqIK2HTQ=")
}
func TestDiscoVerBombusMod(t *testing.T) {
disco := stanza.DiscoInfo{
Identity: []stanza.Identity{
{Name: "BombusMod", Category: "client", Type: "mobile"},
},
Features: []stanza.Feature{
{Var: "http://jabber.org/protocol/activity"},
{Var: "http://jabber.org/protocol/activity+notify"},
{Var: "http://jabber.org/protocol/caps"},
{Var: "http://jabber.org/protocol/commands"},
{Var: "http://jabber.org/protocol/disco#info"},
{Var: "http://jabber.org/protocol/disco#items"},
{Var: "http://jabber.org/protocol/rosterx"},
{Var: "jabber:iq:last"},
{Var: "jabber:iq:privacy"},
{Var: "jabber:iq:roster"},
{Var: "jabber:iq:time"},
{Var: "jabber:iq:version"},
{Var: "jabber:x:oob"},
{Var: "urn:xmpp:ping"},
{Var: "urn:xmpp:time"},
},
}
testDiscoVer(t, &disco, "7Awj7pIUiI5UW/L2fdtzXZFQHsw=")
}

View file

@ -1174,33 +1174,12 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
return
}
} else {
disco.AddIdentity("", "account", "registered")
if backend, ok := gateway.E2EE.Backend(); ok {
disco.AddFeatures(backend.Namespaces().Disco...)
gateway.AddPMDiscoFeatures(disco)
}
}
disco.AddFeatures(stanza.NSMsgChatMarkers)
disco.AddFeatures(stanza.NSMsgReceipts)
// Jingle / JMI features so clients (e.g. Dino) treat the contact
// as call-capable. Without these, JMI <proceed> never fires:
// the client disco-info's the caller before engaging its call
// state machine and silently drops the call when it sees no RTP
// support advertised.
disco.AddFeatures(
"urn:xmpp:jingle:1",
"urn:xmpp:jingle-message:0",
"urn:xmpp:jingle:apps:rtp:1",
"urn:xmpp:jingle:apps:rtp:audio",
"urn:xmpp:jingle:transports:ice-udp:1",
"urn:xmpp:jingle:apps:dtls:0",
)
} else {
disco.AddIdentity("Telegram Gateway", "gateway", "telegram")
disco.AddFeatures("jabber:iq:register")
gateway.AddTransportDiscoFeatures(disco)
}
disco.AddFeatures(gateway.NSCommand)
disco.AddFeatures("jabber:iq:version")
disco.AddFeatures("urn:xmpp:time")
gateway.AddCommonDiscoFeatures(disco)
} else if di.Node == "x-roomuser-item" {
bare, _, fromOk := gateway.SplitJID(iq.From)
if fromOk {
@ -1217,6 +1196,11 @@ func handleGetDiscoInfo(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoInfo) {
}
} else if di.Node == "http://jabber.org/protocol/muc#traffic" {
// noop yet, empty result as intended, TODO: add XHTML whenever supported
} else if strings.HasPrefix(di.Node, gateway.CapsNode) {
// Caps-aware client verifying the ver we advertised in presence (XEP-0115 S5.4).
disco.SetNode(di.Node)
gateway.AddPMDiscoFeatures(disco)
gateway.AddCommonDiscoFeatures(disco)
} else {
chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To)