From a7b4eb7d88a2fefaad3c0b2540ba919d5593be02 Mon Sep 17 00:00:00 2001 From: Bohdan Horbeshko Date: Wed, 19 Aug 2026 15:40:57 -0400 Subject: [PATCH] Backport XEP-0115 support from calls branch --- telegram/utils.go | 1 + xmpp/gateway/gateway.go | 142 +++++++++++++++++++++++++++++++++++ xmpp/gateway/gateway_test.go | 85 +++++++++++++++++++++ xmpp/handlers.go | 32 ++------ 4 files changed, 236 insertions(+), 24 deletions(-) diff --git a/telegram/utils.go b/telegram/utils.go index 202255a..49ef836 100644 --- a/telegram/utils.go +++ b/telegram/utils.go @@ -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) } diff --git a/xmpp/gateway/gateway.go b/xmpp/gateway/gateway.go index d5ab72f..54e383d 100644 --- a/xmpp/gateway/gateway.go +++ b/xmpp/gateway/gateway.go @@ -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 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) + } + } +} diff --git a/xmpp/gateway/gateway_test.go b/xmpp/gateway/gateway_test.go index 6191844..629aeba 100644 --- a/xmpp/gateway/gateway_test.go +++ b/xmpp/gateway/gateway_test.go @@ -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, "01b87fcd030b72895ff8e88db57ec525450f000d") } + +func TestPresenceCaps(t *testing.T) { + presence := newPresence("from@test", "to@test", SPCaps("QgayPKawpkPSDYmwT/WM94uAlu0=")) + testPresence(t, 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=") +} diff --git a/xmpp/handlers.go b/xmpp/handlers.go index d0eab98..d28b7cb 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -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 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)