Add post-login initial configuration stages to the login wizard

This commit is contained in:
Bohdan Horbeshko 2025-07-20 14:45:29 -04:00
parent bb333edf69
commit f875e679da
7 changed files with 267 additions and 49 deletions

View file

@ -72,6 +72,25 @@ var ConfigKeys = []string{
"ignoregroupdeletions", "ignoregroupdeletions",
} }
var Presets = map[string][][]string{
"modern": [][]string{
{"asciiarrows", "false"},
{"oobmode", "true"},
{"carbons", "true"},
{"hideids", "true"},
{"receipts", "true"},
{"nativeedits", "true"},
},
"classic": [][]string{
{"asciiarrows", "true"},
{"oobmode", "false"},
{"carbons", "false"},
{"hideids", "false"},
{"receipts", "false"},
{"nativeedits", "false"},
},
}
var sessionDB *SessionsYamlDB var sessionDB *SessionsYamlDB
var sessionsLock sync.Mutex var sessionsLock sync.Mutex
@ -369,3 +388,16 @@ func toBool(s string) (bool, error) {
return false, errors.New("Invalid boolean value") return false, errors.New("Invalid boolean value")
} }
// NormalizeProperty converts typed properties with uncertain values to certain ones
func NormalizeProperty(key, value string) string {
if PropertyType(key) == PropertyTypeBool {
if value == "0" {
value = "false"
}
if value == "1" {
value = "true"
}
}
return value
}

View file

@ -54,6 +54,44 @@ type IntPair struct {
MessageId int64 MessageId int64
} }
type barrier struct {
mu sync.Mutex
open bool
releaseCh chan struct{}
}
// Wait blocks until the barrier is released.
func (b *barrier) Wait() {
b.mu.Lock()
if !b.open {
b.open = true
b.releaseCh = make(chan struct{}) // Reinitialize the channel
}
b.mu.Unlock()
// Wait for the barrier to be released
<-b.releaseCh
}
// Done releases the barrier.
func (b *barrier) Done() {
b.mu.Lock()
defer b.mu.Unlock()
if b.open {
close(b.releaseCh) // Close the channel to release waiting goroutines
b.open = false // Mark the barrier as closed
}
}
// IsPending checks if the barrier is currently being waited
func (b *barrier) IsPending() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.open
}
// Client stores the metadata for lazily invoked TDlib instance // Client stores the metadata for lazily invoked TDlib instance
type Client struct { type Client struct {
client *client.Client client *client.Client
@ -71,6 +109,7 @@ type Client struct {
online bool online bool
loginWizard *loginWizardMetadata loginWizard *loginWizardMetadata
loginStage LoginStage
lastAuthorizationStateType string lastAuthorizationStateType string
@ -108,6 +147,7 @@ type clientLocks struct {
pinOutboxLock sync.Mutex pinOutboxLock sync.Mutex
lastMsgHashesLock sync.Mutex lastMsgHashesLock sync.Mutex
lastMsgIdsLock sync.RWMutex lastMsgIdsLock sync.RWMutex
loginFinish barrier
authorizerReadLock sync.Mutex authorizerReadLock sync.Mutex
authorizerWriteLock sync.Mutex authorizerWriteLock sync.Mutex
@ -117,7 +157,7 @@ type clientLocks struct {
} }
type loginWizardMetadata struct { type loginWizardMetadata struct {
nextStage chan string nextStage chan LoginStage
chanBusy bool chanBusy bool
commandSent bool commandSent bool
} }

View file

@ -67,6 +67,9 @@ var transportCommands = map[string]command{
"join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil}, "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil},
"supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil}, "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", true, nil},
"channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil}, "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil},
"preset": command{1, []string{"modern|classic|pass"}, "apply a config preset", false, nil},
"pass": command{0, []string{}, "proceed to next login stage", false, nil},
"finish": command{0, []string{}, "skip post-login configuration", false, nil},
} }
var notForGroups = []ChatType{ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel} var notForGroups = []ChatType{ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel}
@ -346,7 +349,7 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin
} }
// sign out // sign out
case "logout": case "logout":
if !c.Online() { if !c.Online() && !c.locks.loginFinish.IsPending() {
return notOnline, false return notOnline, false
} }
@ -360,6 +363,8 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin
} }
c.Session.Login = "" c.Session.Login = ""
c.wizardStageOrPrompt(LoginStageCancel, "")
c.online = false
// cancel auth // cancel auth
case "cancelauth": case "cancelauth":
if c.Online() { if c.Online() {
@ -465,6 +470,9 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin
case "false": case "false":
go c.MigrateFromMUCs() go c.MigrateFromMUCs()
} }
if c.loginStage == LoginStageMUC {
c.wizardStageOrPrompt(LoginStageSuccess, "")
}
} }
gateway.DirtySessions = true gateway.DirtySessions = true
@ -519,11 +527,46 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin
return c.cmdChannel(args, cmdline) return c.cmdChannel(args, cmdline)
case "help": case "help":
return c.helpString(CommandTypeTransport, 0), true return c.helpString(CommandTypeTransport, 0), true
case "preset":
cancelNextStage := false
defer func() {
if !cancelNextStage {
go c.promptMUC()
}
}()
switch args[0] {
case "modern", "classic":
for _, row := range persistence.Presets[args[0]] {
_, err := c.Session.Set(row[0], row[1])
if err != nil {
return err.Error(), false
}
}
gateway.DirtySessions = true
return fmt.Sprintf("Applied preset %s", args[0]), true
default:
cancelNextStage = true
return "Invalid argument. Allowed ones are " + transportCommands["preset"].Arguments[0], false
}
case "pass":
switch c.loginStage {
case LoginStagePreset:
go c.promptMUC()
case LoginStageMUC:
c.wizardStageOrPrompt(LoginStageSuccess, "")
}
case "finish":
c.wizardStageOrPrompt(LoginStageSuccess, "")
} }
return "", true return "", true
} }
func (c *Client) promptMUC() {
c.wizardStageOrPrompt(LoginStageMUC, "Enable MUCs? Telegabber still supports the legacy approach of mapping Telegram group chats to personal messages in XMPP which might be suitable for some cases like logging or reliable participation in all group chats. Use `/config muc {true|false}` or /finish")
}
// ProcessChatCommand executes a command sent in a mapped chat // ProcessChatCommand executes a command sent in a mapped chat
// and returns a response, the status of command support and the execution success result // and returns a response, the status of command support and the execution success result
func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool, bool) { func (c *Client) ProcessChatCommand(chatID int64, cmdline string) (string, bool, bool) {

View file

@ -129,7 +129,7 @@ func (c *Client) Connect(resource string) error {
tdlibClient, err := client.NewClient(c.authorizer, c.options...) tdlibClient, err := client.NewClient(c.authorizer, c.options...)
if err != nil { if err != nil {
c.locks.authorizationReady.Unlock() c.locks.authorizationReady.Unlock()
c.wizardStageOrPrompt("cancel", "") c.wizardStageOrPrompt(LoginStageCancel, "")
return errors.Wrap(err, "Couldn't initialize a Telegram client instance") return errors.Wrap(err, "Couldn't initialize a Telegram client instance")
} }
@ -138,8 +138,6 @@ func (c *Client) Connect(resource string) error {
// stage 3: if a client is succesfully created, AuthorizationStateReady is already reached // stage 3: if a client is succesfully created, AuthorizationStateReady is already reached
log.Warn("Authorization successful!") log.Warn("Authorization successful!")
c.wizardStageOrPrompt("success", "")
c.me, err = c.client.GetMe() c.me, err = c.client.GetMe()
if err != nil { if err != nil {
log.Error("Could not retrieve me info") log.Error("Could not retrieve me info")
@ -147,6 +145,8 @@ func (c *Client) Connect(resource string) error {
c.Session.Login = c.me.PhoneNumber c.Session.Login = c.me.PhoneNumber
} }
c.locks.loginFinish.Wait()
go c.updateHandler() go c.updateHandler()
c.online = true c.online = true
c.locks.authorizationReady.Unlock() c.locks.authorizationReady.Unlock()
@ -259,7 +259,7 @@ func (c *Client) interactor() {
if !ok { if !ok {
log.Warn("Interactor is disconnected") log.Warn("Interactor is disconnected")
c.locks.authorizerReadLock.Unlock() c.locks.authorizerReadLock.Unlock()
return break
} }
stateType := state.AuthorizationStateType() stateType := state.AuthorizationStateType()
@ -275,12 +275,12 @@ func (c *Client) interactor() {
if c.Session.Login != "" { if c.Session.Login != "" {
c.authorizer.PhoneNumber <- c.Session.Login c.authorizer.PhoneNumber <- c.Session.Login
} else { } else {
c.wizardStageOrPrompt("login", "Please, enter your Telegram login via /login 12345, or use the Login Wizard via Ad-Hoc commands") c.wizardStageOrPrompt(LoginStageLogin, "Please, enter your Telegram login via /login 12345, or use the Login Wizard via Ad-Hoc commands")
} }
// stage 1: wait for auth code // stage 1: wait for auth code
case client.TypeAuthorizationStateWaitCode: case client.TypeAuthorizationStateWaitCode:
log.Warn("Waiting for authorization code...") log.Warn("Waiting for authorization code...")
c.wizardStageOrPrompt("code", "Please, enter authorization code via /code 12345") c.wizardStageOrPrompt(LoginStageCode, "Please, enter authorization code via /code 12345")
// stage 1b: wait for registration // stage 1b: wait for registration
case client.TypeAuthorizationStateWaitRegistration: case client.TypeAuthorizationStateWaitRegistration:
log.Warn("Waiting for full name...") log.Warn("Waiting for full name...")
@ -288,10 +288,13 @@ func (c *Client) interactor() {
// stage 2: wait for 2fa // stage 2: wait for 2fa
case client.TypeAuthorizationStateWaitPassword: case client.TypeAuthorizationStateWaitPassword:
log.Warn("Waiting for 2FA password...") log.Warn("Waiting for 2FA password...")
c.wizardStageOrPrompt("password", "Please, enter 2FA passphrase via /password 12345") c.wizardStageOrPrompt(LoginStagePassword, "Please, enter 2FA passphrase via /password 12345")
} }
c.locks.authorizerReadLock.Unlock() c.locks.authorizerReadLock.Unlock()
} }
if c.loginStage != LoginStageCancel {
c.wizardStageOrPrompt(LoginStagePreset, "Do you want to use a config preset? `/preset modern` enables brand new XMPP features, `/preset classic` targets legacy clients stuck in 00s. /pass proceeds to the next stage.")
}
} }
func (c *Client) forceClose() { func (c *Client) forceClose() {
@ -322,6 +325,7 @@ func (c *Client) close() {
} }
func (c *Client) cancelAuth() { func (c *Client) cancelAuth() {
c.wizardStageOrPrompt(LoginStageCancel, "")
c.StopLoginWizard() c.StopLoginWizard()
c.close() c.close()
c.Session.Login = "" c.Session.Login = ""

View file

@ -7,11 +7,32 @@ import (
"github.com/zelenin/go-tdlib/client" "github.com/zelenin/go-tdlib/client"
) )
type LoginStage string
const (
LoginStageNone LoginStage = ""
LoginStageLogin LoginStage = "login"
LoginStageCode LoginStage = "code"
LoginStagePassword LoginStage = "password"
LoginStagePreset LoginStage = "preset"
LoginStageMUC LoginStage = "muc"
LoginStageSuccess LoginStage = "success"
LoginStageCancel LoginStage = "cancel"
)
// setLoginStage updates loginState and triggers session initialization on login sucess
func (c *Client) setLoginStage(stage LoginStage) {
c.loginStage = stage
switch c.loginStage {
case LoginStageSuccess, LoginStageCancel:
c.locks.loginFinish.Done()
}
}
// StartLoginWizard initiates a loginWizard object // StartLoginWizard initiates a loginWizard object
func (c *Client) StartLoginWizard(inCommand bool) { func (c *Client) StartLoginWizard(inCommand bool) {
if c.loginWizard == nil { if c.loginWizard == nil {
c.loginWizard = &loginWizardMetadata{ c.loginWizard = &loginWizardMetadata{
nextStage: make(chan string, 1), nextStage: make(chan LoginStage, 1),
commandSent: inCommand, commandSent: inCommand,
} }
} else { } else {
@ -32,7 +53,7 @@ func (c *Client) StopLoginWizard() {
} }
// GetLoginWizardNextStage waits for the next stage from the channel // GetLoginWizardNextStage waits for the next stage from the channel
func (c *Client) GetLoginWizardNextStage() string { func (c *Client) GetLoginWizardNextStage() LoginStage {
c.locks.loginWizardReadLock.Lock() c.locks.loginWizardReadLock.Lock()
defer c.locks.loginWizardReadLock.Unlock() defer c.locks.loginWizardReadLock.Unlock()
@ -45,25 +66,40 @@ func (c *Client) GetLoginWizardNextStage() string {
log.Debugf("yielded stage %v", nextStage) log.Debugf("yielded stage %v", nextStage)
return nextStage return nextStage
} else { } else {
log.Debugf("commandSent is false")
if c.lastAuthorizationStateType == client.TypeAuthorizationStateWaitPhoneNumber || if c.lastAuthorizationStateType == client.TypeAuthorizationStateWaitPhoneNumber ||
c.lastAuthorizationStateType == client.TypeAuthorizationStateClosing || c.lastAuthorizationStateType == client.TypeAuthorizationStateClosing ||
c.Session.Login == "" { c.Session.Login == "" {
return "login" return LoginStageLogin
} }
switch c.lastAuthorizationStateType { switch c.lastAuthorizationStateType {
case client.TypeAuthorizationStateWaitCode: case client.TypeAuthorizationStateWaitCode:
return "code" return LoginStageCode
case client.TypeAuthorizationStateWaitPassword: case client.TypeAuthorizationStateWaitPassword:
return "password" return LoginStagePassword
}
switch c.loginStage {
case LoginStagePreset:
return LoginStageMUC
case LoginStageMUC:
return LoginStageNone
} }
} }
} }
return "" return LoginStageNone
} }
func (c *Client) wizardStageOrPrompt(stage, message string) { func (c *Client) wizardStageOrPrompt(stage LoginStage, message string) {
log.Debugf("loginStage: %v stage: %v", c.loginStage, stage)
if c.loginStage == stage {
return
}
c.locks.loginWizardWriteLock.Lock() c.locks.loginWizardWriteLock.Lock()
c.setLoginStage(stage)
if c.loginWizard == nil { if c.loginWizard == nil {
c.locks.loginWizardWriteLock.Unlock() c.locks.loginWizardWriteLock.Unlock()
if message != "" { if message != "" {

View file

@ -1454,24 +1454,15 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command
var warnString, errString string var warnString, errString string
for _, field := range form.Fields { for _, field := range form.Fields {
if len(field.ValuesList) > 0 { if len(field.ValuesList) > 0 {
fieldValue := field.ValuesList[0]
if gateway.MessageOutgoingPermissionVersion == 0 && field.Var == "carbons" && fieldValue == "true" {
warnString = "The server did not allow to enable carbons"
continue
}
// 10. In accordance with Section 3.2.2.1 of XML Schema Part 2: Datatypes, the allowable // 10. In accordance with Section 3.2.2.1 of XML Schema Part 2: Datatypes, the allowable
// lexical representations for the xs:boolean datatype are the strings "0" and "false" // lexical representations for the xs:boolean datatype are the strings "0" and "false"
// for the concept 'false' and the strings "1" and "true" for the concept 'true'; // for the concept 'false' and the strings "1" and "true" for the concept 'true';
// implementations MUST support both styles of lexical representation. // implementations MUST support both styles of lexical representation.
if persistence.PropertyType(field.Var) == persistence.PropertyTypeBool { fieldValue := persistence.NormalizeProperty(field.Var, field.ValuesList[0])
if fieldValue == "0" {
fieldValue = "false" if gateway.MessageOutgoingPermissionVersion == 0 && field.Var == "carbons" && fieldValue == "true" {
} warnString = "The server did not allow to enable carbons"
if fieldValue == "1" { continue
fieldValue = "true"
}
} }
oldValue, err := session.Session.Get(field.Var) oldValue, err := session.Session.Get(field.Var)
@ -1524,7 +1515,7 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command
} }
} else if !toOk && command.Node == "loginwizard" { } else if !toOk && command.Node == "loginwizard" {
var session *telegram.Client var session *telegram.Client
answer.Payload, cancelSend, session = loginWizardPayload(bare, form, resource) answer.Payload, cancelSend, session = loginWizardPayload(bare, form, resource, command.Action)
log.Debugf("immediate loginwizard payload: %#v", answer.Payload) log.Debugf("immediate loginwizard payload: %#v", answer.Payload)
if cancelSend { if cancelSend {
@ -1685,7 +1676,7 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command
log.Debugf("form: %#v", form) log.Debugf("form: %#v", form)
} else if !toOk && command.Node == "loginwizard" { } else if !toOk && command.Node == "loginwizard" {
var session *telegram.Client var session *telegram.Client
answer.Payload, cancelSend, session = loginWizardPayload(bare, nil, resource) answer.Payload, cancelSend, session = loginWizardPayload(bare, nil, resource, "")
log.Debugf("immediate loginwizard payload: %#v", answer.Payload) log.Debugf("immediate loginwizard payload: %#v", answer.Payload)
if cancelSend { if cancelSend {

View file

@ -3,6 +3,7 @@ package xmpp
import ( import (
"fmt" "fmt"
"dev.narayana.im/narayana/telegabber/persistence"
"dev.narayana.im/narayana/telegabber/telegram" "dev.narayana.im/narayana/telegabber/telegram"
"dev.narayana.im/narayana/telegabber/xmpp/gateway" "dev.narayana.im/narayana/telegabber/xmpp/gateway"
@ -11,7 +12,7 @@ import (
"gosrc.io/xmpp/stanza" "gosrc.io/xmpp/stanza"
) )
func loginWizardPayload(bare string, requestForm *stanza.Form, resource string) (payload *stanza.Command, cancelSend bool, returnSession *telegram.Client) { func loginWizardPayload(bare string, requestForm *stanza.Form, resource string, action string) (payload *stanza.Command, cancelSend bool, returnSession *telegram.Client) {
payload = &stanza.Command{ payload = &stanza.Command{
SessionId: "loginwizard", SessionId: "loginwizard",
Node: "loginwizard", Node: "loginwizard",
@ -21,9 +22,13 @@ func loginWizardPayload(bare string, requestForm *stanza.Form, resource string)
if ok { if ok {
returnSession = session returnSession = session
var command string
if requestForm == nil { if requestForm == nil {
session.StartLoginWizard(false) session.StartLoginWizard(false)
cancelSend = true cancelSend = true
} else if action == stanza.CommandActionComplete || action == stanza.CommandActionExecute {
command = "/finish"
} else { } else {
if len(requestForm.Fields) != 1 { if len(requestForm.Fields) != 1 {
setCommandPayloadError(payload, "Hey, don't tinker with the form!") setCommandPayloadError(payload, "Hey, don't tinker with the form!")
@ -35,24 +40,35 @@ func loginWizardPayload(bare string, requestForm *stanza.Form, resource string)
setCommandPayloadError(payload, "No value") setCommandPayloadError(payload, "No value")
return return
} }
value := field.ValuesList[0]
switch field.Var { switch field.Var {
case "login", "code", "password": case "login", "code", "password", "preset":
if field.Var == "preset" && value == "" {
command = "/pass"
} else {
command = fmt.Sprintf("/%v %v", field.Var, value)
}
case "muc":
fieldValue := persistence.NormalizeProperty(field.Var, value)
command = fmt.Sprintf("/config muc %v", fieldValue)
default: default:
setCommandPayloadError(payload, "Unknown field") setCommandPayloadError(payload, "Unknown field")
return return
} }
session.StartLoginWizard(true)
response, success := session.ProcessTransportCommand(fmt.Sprintf("/%v %v", field.Var, field.ValuesList[0]), resource)
if !success {
setCommandPayloadError(payload, response)
session.StopLoginWizard()
return
}
cancelSend = true
} }
} }
if command != "" {
session.StartLoginWizard(true)
response, success := session.ProcessTransportCommand(command, resource)
if !success {
setCommandPayloadError(payload, response)
session.StopLoginWizard()
return
}
cancelSend = true
}
} else { } else {
setCommandPayloadError(payload, fmt.Sprintf("Session is not initialized, add the transport (%v) to contacts first", gateway.Jid.Bare())) setCommandPayloadError(payload, fmt.Sprintf("Session is not initialized, add the transport (%v) to contacts first", gateway.Jid.Bare()))
} }
@ -66,33 +82,89 @@ func sendLoginWizardResponse(component *xmpp.Component, answer *stanza.IQ, sessi
Node: "loginwizard", Node: "loginwizard",
} }
nextStage := "login" nextStage := telegram.LoginStageLogin
if session != nil { if session != nil {
nextStage = session.GetLoginWizardNextStage() nextStage = session.GetLoginWizardNextStage()
} }
log.Debugf("nextStage: %v", nextStage) log.Debugf("nextStage: %v", nextStage)
if nextStage == "cancel" { if nextStage == telegram.LoginStageNone || nextStage == telegram.LoginStageCancel {
setCommandPayloadError(payload, "Cancelled") setCommandPayloadError(payload, "Cancelled")
session.StopLoginWizard() session.StopLoginWizard()
} else if nextStage == "success" { } else if nextStage == telegram.LoginStageSuccess {
payload.Status = stanza.CommandStatusCompleted payload.Status = stanza.CommandStatusCompleted
session.StopLoginWizard() session.StopLoginWizard()
} else { } else {
required := "" required := ""
var fieldType string
var finishAction bool
var options []stanza.Option
var note string
switch nextStage {
case telegram.LoginStagePreset:
fieldType = stanza.FieldTypeListSingle
finishAction = true
options = []stanza.Option{
stanza.Option{
ValuesList: []string{""},
},
stanza.Option{
Label: "Modern",
ValuesList: []string{"modern"},
},
stanza.Option{
Label: "Classic",
ValuesList: []string{"classic"},
},
}
note = "Do you want to use a config preset?\nModern enables brand new XMPP features,\nClassic targets legacy clients stuck in 00s."
case telegram.LoginStageMUC:
fieldType = stanza.FieldTypeBool
finishAction = true
value, err := session.Session.Get("muc")
if err != nil {
log.Error("Achtung! Programming error in retrieving MUC config option")
value = "false"
}
options = append(options, stanza.Option{
ValuesList: []string{value},
})
note = "Enable MUCs? Telegabber still supports the legacy group-to-PM mapping too."
}
form := stanza.Form{ form := stanza.Form{
Type: stanza.FormTypeForm, Type: stanza.FormTypeForm,
Title: "Login Wizard", Title: "Login Wizard",
Fields: []*stanza.Field{ Fields: []*stanza.Field{
&stanza.Field{ &stanza.Field{
Var: nextStage, Var: string(nextStage),
Label: nextStage, Label: string(nextStage),
Required: &required, Required: &required,
Type: fieldType,
Options: options,
}, },
}, },
} }
payload.Status = stanza.CommandStatusExecuting payload.Status = stanza.CommandStatusExecuting
payload.CommandElements = append(payload.CommandElements, &form) payload.CommandElements = append(payload.CommandElements, &form)
actions := stanza.Actions{
Next: &struct{}{},
}
if finishAction {
actions.Complete = &struct{}{}
}
payload.CommandElements = append(payload.CommandElements, &actions)
if note != "" {
payload.CommandElements = append(payload.CommandElements, &stanza.Note{
Text: note,
Type: stanza.CommandNoteTypeInfo,
})
}
} }
answer.Payload = payload answer.Payload = payload