diff --git a/persistence/sessions.go b/persistence/sessions.go index 27d361b..c75dea0 100644 --- a/persistence/sessions.go +++ b/persistence/sessions.go @@ -72,6 +72,25 @@ var ConfigKeys = []string{ "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 sessionsLock sync.Mutex @@ -369,3 +388,16 @@ func toBool(s string) (bool, error) { 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 +} diff --git a/telegram/client.go b/telegram/client.go index 524ade3..0ce3cc1 100644 --- a/telegram/client.go +++ b/telegram/client.go @@ -54,6 +54,44 @@ type IntPair struct { 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 type Client struct { client *client.Client @@ -71,6 +109,7 @@ type Client struct { online bool loginWizard *loginWizardMetadata + loginStage LoginStage lastAuthorizationStateType string @@ -108,6 +147,7 @@ type clientLocks struct { pinOutboxLock sync.Mutex lastMsgHashesLock sync.Mutex lastMsgIdsLock sync.RWMutex + loginFinish barrier authorizerReadLock sync.Mutex authorizerWriteLock sync.Mutex @@ -117,7 +157,7 @@ type clientLocks struct { } type loginWizardMetadata struct { - nextStage chan string + nextStage chan LoginStage chanBusy bool commandSent bool } diff --git a/telegram/commands.go b/telegram/commands.go index ad4e066..a174009 100644 --- a/telegram/commands.go +++ b/telegram/commands.go @@ -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}, "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}, + "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} @@ -346,7 +349,7 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin } // sign out case "logout": - if !c.Online() { + if !c.Online() && !c.locks.loginFinish.IsPending() { return notOnline, false } @@ -360,6 +363,8 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin } c.Session.Login = "" + c.wizardStageOrPrompt(LoginStageCancel, "") + c.online = false // cancel auth case "cancelauth": if c.Online() { @@ -465,6 +470,9 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin case "false": go c.MigrateFromMUCs() } + if c.loginStage == LoginStageMUC { + c.wizardStageOrPrompt(LoginStageSuccess, "") + } } gateway.DirtySessions = true @@ -519,11 +527,46 @@ func (c *Client) ProcessTransportCommand(cmdline string, resource string) (strin return c.cmdChannel(args, cmdline) case "help": 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 } +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 // 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) { diff --git a/telegram/connect.go b/telegram/connect.go index f78f964..b0a2ca0 100644 --- a/telegram/connect.go +++ b/telegram/connect.go @@ -129,7 +129,7 @@ func (c *Client) Connect(resource string) error { tdlibClient, err := client.NewClient(c.authorizer, c.options...) if err != nil { c.locks.authorizationReady.Unlock() - c.wizardStageOrPrompt("cancel", "") + c.wizardStageOrPrompt(LoginStageCancel, "") 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 log.Warn("Authorization successful!") - c.wizardStageOrPrompt("success", "") - c.me, err = c.client.GetMe() if err != nil { 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.locks.loginFinish.Wait() + go c.updateHandler() c.online = true c.locks.authorizationReady.Unlock() @@ -259,7 +259,7 @@ func (c *Client) interactor() { if !ok { log.Warn("Interactor is disconnected") c.locks.authorizerReadLock.Unlock() - return + break } stateType := state.AuthorizationStateType() @@ -275,12 +275,12 @@ func (c *Client) interactor() { if c.Session.Login != "" { c.authorizer.PhoneNumber <- c.Session.Login } 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 case client.TypeAuthorizationStateWaitCode: 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 case client.TypeAuthorizationStateWaitRegistration: log.Warn("Waiting for full name...") @@ -288,10 +288,13 @@ func (c *Client) interactor() { // stage 2: wait for 2fa case client.TypeAuthorizationStateWaitPassword: 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() } + 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() { @@ -322,6 +325,7 @@ func (c *Client) close() { } func (c *Client) cancelAuth() { + c.wizardStageOrPrompt(LoginStageCancel, "") c.StopLoginWizard() c.close() c.Session.Login = "" diff --git a/telegram/loginwizard.go b/telegram/loginwizard.go index 296e748..3062f8c 100644 --- a/telegram/loginwizard.go +++ b/telegram/loginwizard.go @@ -7,11 +7,32 @@ import ( "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 func (c *Client) StartLoginWizard(inCommand bool) { if c.loginWizard == nil { c.loginWizard = &loginWizardMetadata{ - nextStage: make(chan string, 1), + nextStage: make(chan LoginStage, 1), commandSent: inCommand, } } else { @@ -32,7 +53,7 @@ func (c *Client) StopLoginWizard() { } // GetLoginWizardNextStage waits for the next stage from the channel -func (c *Client) GetLoginWizardNextStage() string { +func (c *Client) GetLoginWizardNextStage() LoginStage { c.locks.loginWizardReadLock.Lock() defer c.locks.loginWizardReadLock.Unlock() @@ -45,25 +66,40 @@ func (c *Client) GetLoginWizardNextStage() string { log.Debugf("yielded stage %v", nextStage) return nextStage } else { + log.Debugf("commandSent is false") + if c.lastAuthorizationStateType == client.TypeAuthorizationStateWaitPhoneNumber || c.lastAuthorizationStateType == client.TypeAuthorizationStateClosing || c.Session.Login == "" { - return "login" + return LoginStageLogin } switch c.lastAuthorizationStateType { case client.TypeAuthorizationStateWaitCode: - return "code" + return LoginStageCode 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.setLoginStage(stage) if c.loginWizard == nil { c.locks.loginWizardWriteLock.Unlock() if message != "" { diff --git a/xmpp/handlers.go b/xmpp/handlers.go index dceee3a..b94dfd6 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -1454,24 +1454,15 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command var warnString, errString string for _, field := range form.Fields { 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 // 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'; // implementations MUST support both styles of lexical representation. - if persistence.PropertyType(field.Var) == persistence.PropertyTypeBool { - if fieldValue == "0" { - fieldValue = "false" - } - if fieldValue == "1" { - fieldValue = "true" - } + fieldValue := persistence.NormalizeProperty(field.Var, field.ValuesList[0]) + + if gateway.MessageOutgoingPermissionVersion == 0 && field.Var == "carbons" && fieldValue == "true" { + warnString = "The server did not allow to enable carbons" + continue } 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" { 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) if cancelSend { @@ -1685,7 +1676,7 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command log.Debugf("form: %#v", form) } else if !toOk && command.Node == "loginwizard" { 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) if cancelSend { diff --git a/xmpp/loginwizard.go b/xmpp/loginwizard.go index d1b161a..53b45e5 100644 --- a/xmpp/loginwizard.go +++ b/xmpp/loginwizard.go @@ -3,6 +3,7 @@ package xmpp import ( "fmt" + "dev.narayana.im/narayana/telegabber/persistence" "dev.narayana.im/narayana/telegabber/telegram" "dev.narayana.im/narayana/telegabber/xmpp/gateway" @@ -11,7 +12,7 @@ import ( "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{ SessionId: "loginwizard", Node: "loginwizard", @@ -21,9 +22,13 @@ func loginWizardPayload(bare string, requestForm *stanza.Form, resource string) if ok { returnSession = session + var command string + if requestForm == nil { session.StartLoginWizard(false) cancelSend = true + } else if action == stanza.CommandActionComplete || action == stanza.CommandActionExecute { + command = "/finish" } else { if len(requestForm.Fields) != 1 { 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") return } + value := field.ValuesList[0] 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: setCommandPayloadError(payload, "Unknown field") 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 { 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", } - nextStage := "login" + nextStage := telegram.LoginStageLogin if session != nil { nextStage = session.GetLoginWizardNextStage() } log.Debugf("nextStage: %v", nextStage) - if nextStage == "cancel" { + if nextStage == telegram.LoginStageNone || nextStage == telegram.LoginStageCancel { setCommandPayloadError(payload, "Cancelled") session.StopLoginWizard() - } else if nextStage == "success" { + } else if nextStage == telegram.LoginStageSuccess { payload.Status = stanza.CommandStatusCompleted session.StopLoginWizard() } else { 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{ Type: stanza.FormTypeForm, Title: "Login Wizard", Fields: []*stanza.Field{ &stanza.Field{ - Var: nextStage, - Label: nextStage, + Var: string(nextStage), + Label: string(nextStage), Required: &required, + Type: fieldType, + Options: options, }, }, } payload.Status = stanza.CommandStatusExecuting 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