From 85846346d194d808f9a63b992332f6a66141c9b2 Mon Sep 17 00:00:00 2001 From: Bohdan Horbeshko Date: Thu, 3 Apr 2025 18:57:13 -0400 Subject: [PATCH] Login Wizard --- persistence/sessions.go | 2 +- telegram/client.go | 13 +++++ telegram/commands.go | 95 +++++++++++++++++----------------- telegram/connect.go | 13 +++-- telegram/loginwizard.go | 82 ++++++++++++++++++++++++++++++ xmpp/handlers.go | 58 ++++++++++++++++++--- xmpp/loginwizard.go | 110 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 315 insertions(+), 58 deletions(-) create mode 100644 telegram/loginwizard.go create mode 100644 xmpp/loginwizard.go diff --git a/persistence/sessions.go b/persistence/sessions.go index 6d5c3ae..47d108b 100644 --- a/persistence/sessions.go +++ b/persistence/sessions.go @@ -278,7 +278,7 @@ func PropertyType(key string) byte { case "timezone": return PropertyTypeString case "keeponline", "rawmessages", "asciiarrows", "oobmode", "carbons", "hideids", - "receipts", "nativeedits", "ignoregroupdeletions": + "receipts", "nativeedits", "ignoregroupdeletions": return PropertyTypeBool } return PropertyTypeUnknown diff --git a/telegram/client.go b/telegram/client.go index 385ccd3..bd24adb 100644 --- a/telegram/client.go +++ b/telegram/client.go @@ -44,6 +44,10 @@ type Client struct { cache *cache.Cache online bool + loginWizard *loginWizardMetadata + + lastAuthorizationStateType string + outbox map[string]string editOutbox map[string]string @@ -75,6 +79,15 @@ type clientLocks struct { authorizerReadLock sync.Mutex authorizerWriteLock sync.Mutex + + loginWizardReadLock sync.Mutex + loginWizardWriteLock sync.Mutex +} + +type loginWizardMetadata struct { + nextStage chan string + chanBusy bool + commandSent bool } // NewClient instantiates a Telegram App diff --git a/telegram/commands.go b/telegram/commands.go index 71fd322..e1f0c8a 100644 --- a/telegram/commands.go +++ b/telegram/commands.go @@ -51,22 +51,22 @@ var permissionsMember = client.ChatPermissions{ var permissionsReadonly = client.ChatPermissions{} var transportCommands = map[string]command{ - "help": command{0, []string{}, "help", nil}, - "login": command{1, []string{"phone"}, "sign in", nil}, - "logout": command{0, []string{}, "sign out", nil}, - "cancelauth": command{0, []string{}, "quit the signin wizard", nil}, - "code": command{1, []string{"xxxxx"}, "check one-time code", nil}, - "password": command{1, []string{"********"}, "check 2fa password", nil}, - "setusername": command{0, []string{"@username"}, "update @username", nil}, - "setname": command{1, []string{"first", "last"}, "update name", nil}, - "setbio": command{0, []string{"Lorem ipsum"}, "update about", nil}, - "setpassword": command{0, []string{"old", "new"}, "set or remove password", nil}, - "config": command{0, []string{"param", "value"}, "view or update configuration options", nil}, - "report": command{2, []string{"chat", "comment"}, "report a chat by id or @username", nil}, - "add": command{1, []string{"@username"}, "add @username to your chat list", nil}, - "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", nil}, - "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", nil}, - "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", nil}, + "help": command{0, []string{}, "help", false, nil}, + "login": command{1, []string{"phone"}, "sign in", false, nil}, + "logout": command{0, []string{}, "sign out", true, nil}, + "cancelauth": command{0, []string{}, "quit the signin wizard", false, nil}, + "code": command{1, []string{"xxxxx"}, "check one-time code", false, nil}, + "password": command{1, []string{"********"}, "check 2fa password", false, nil}, + "setusername": command{0, []string{"@username"}, "update @username", true, nil}, + "setname": command{1, []string{"first", "last"}, "update name", true, nil}, + "setbio": command{0, []string{"Lorem ipsum"}, "update about", true, nil}, + "setpassword": command{0, []string{"old", "new"}, "set or remove password", true, nil}, + "config": command{0, []string{"param", "value"}, "view or update configuration options", false, nil}, + "report": command{2, []string{"chat", "comment"}, "report a chat by id or @username", true, nil}, + "add": command{1, []string{"@username"}, "add @username to your chat list", 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}, + "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil}, } var notForGroups = []ChatType{ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel} @@ -75,37 +75,37 @@ var notForPMAndBasic = []ChatType{ChatTypePrivate, ChatTypeSecret, ChatTypeBasic var onlyForSecret = []ChatType{ChatTypePrivate, ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel} var chatCommands = map[string]command{ - "help": command{0, []string{}, "help", nil}, - "d": command{0, []string{"n"}, "delete your last message(s)", nil}, - "s": command{1, []string{"edited message"}, "edit your last message", nil}, - "silent": command{1, []string{"message"}, "send a message without sound", nil}, - "schedule": command{2, []string{"{online | 2006-01-02T15:04:05 | 15:04:05}", "message"}, "schedules a message either to timestamp or to whenever the user goes online", nil}, - "forward": command{2, []string{"message_id", "target_chat"}, "forwards a message", nil}, - "vcard": command{0, []string{}, "print vCard as text", nil}, - "add": command{1, []string{"@username"}, "add @username to your chat list", nil}, - "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", nil}, - "group": command{1, []string{"title"}, "create groupchat «title» with current user", ¬ForGroups}, - "supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", nil}, - "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", nil}, - "secret": command{0, []string{}, "create secretchat with current user", ¬ForGroups}, - "search": command{0, []string{"string", "[limit]"}, "search in current chat", nil}, - "history": command{0, []string{"limit"}, "get last [limit] messages from current chat", nil}, - "block": command{0, []string{}, "blacklist current user", ¬ForGroups}, - "unblock": command{0, []string{}, "unblacklist current user", ¬ForGroups}, - "invite": command{1, []string{"id or @username"}, "add user to current chat", ¬ForPM}, - "link": command{0, []string{}, "get invite link for current chat", ¬ForPM}, - "kick": command{1, []string{"id or @username"}, "remove user from current chat", ¬ForPM}, - "mute": command{0, []string{"id or @username", "hours"}, "mute the whole chat or a user in current chat", ¬ForPMAndBasic}, - "unmute": command{0, []string{"id or @username"}, "unmute the whole chat or a user in the current chat", ¬ForPMAndBasic}, - "ban": command{1, []string{"id or @username", "hours"}, "restrict @username from current chat for [hours] or forever", ¬ForPM}, - "unban": command{1, []string{"id or @username"}, "unbans @username in current chat (and devotes from admins)", ¬ForPM}, - "promote": command{1, []string{"id or @username", "title"}, "promote user to admin in current chat", ¬ForPM}, - "leave": command{0, []string{}, "leave current chat", ¬ForPM}, - "leave!": command{0, []string{}, "leave current chat (for owners)", ¬ForPM}, - "ttl": command{0, []string{"seconds"}, "set secret chat messages TTL before self-destroying", &onlyForSecret}, - "close": command{0, []string{}, "close current secret chat", &onlyForSecret}, - "delete": command{0, []string{}, "delete current chat from chat list", nil}, - "members": command{0, []string{"query"}, "search members [by optional query] in current chat (requires admin rights)", nil}, + "help": command{0, []string{}, "help", false, nil}, + "d": command{0, []string{"n"}, "delete your last message(s)", true, nil}, + "s": command{1, []string{"edited message"}, "edit your last message", true, nil}, + "silent": command{1, []string{"message"}, "send a message without sound", true, nil}, + "schedule": command{2, []string{"{online | 2006-01-02T15:04:05 | 15:04:05}", "message"}, "schedules a message either to timestamp or to whenever the user goes online", true, nil}, + "forward": command{2, []string{"message_id", "target_chat"}, "forwards a message", true, nil}, + "vcard": command{0, []string{}, "print vCard as text", true, nil}, + "add": command{1, []string{"@username"}, "add @username to your chat list", true, nil}, + "join": command{1, []string{"https://t.me/invite_link"}, "join to chat via invite link or @publicname", true, nil}, + "group": command{1, []string{"title"}, "create groupchat «title» with current user", true, ¬ForGroups}, + "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}, + "secret": command{0, []string{}, "create secretchat with current user", true, ¬ForGroups}, + "search": command{0, []string{"string", "[limit]"}, "search in current chat", true, nil}, + "history": command{0, []string{"limit"}, "get last [limit] messages from current chat", true, nil}, + "block": command{0, []string{}, "blacklist current user", true, ¬ForGroups}, + "unblock": command{0, []string{}, "unblacklist current user", true, ¬ForGroups}, + "invite": command{1, []string{"id or @username"}, "add user to current chat", true, ¬ForPM}, + "link": command{0, []string{}, "get invite link for current chat", true, ¬ForPM}, + "kick": command{1, []string{"id or @username"}, "remove user from current chat", true, ¬ForPM}, + "mute": command{0, []string{"id or @username", "hours"}, "mute the whole chat or a user in current chat", true, ¬ForPMAndBasic}, + "unmute": command{0, []string{"id or @username"}, "unmute the whole chat or a user in the current chat", true, ¬ForPMAndBasic}, + "ban": command{1, []string{"id or @username", "hours"}, "restrict @username from current chat for [hours] or forever", true, ¬ForPM}, + "unban": command{1, []string{"id or @username"}, "unbans @username in current chat (and devotes from admins)", true, ¬ForPM}, + "promote": command{1, []string{"id or @username", "title"}, "promote user to admin in current chat", true, ¬ForPM}, + "leave": command{0, []string{}, "leave current chat", true, ¬ForPM}, + "leave!": command{0, []string{}, "leave current chat (for owners)", true, ¬ForPM}, + "ttl": command{0, []string{"seconds"}, "set secret chat messages TTL before self-destroying", true, &onlyForSecret}, + "close": command{0, []string{}, "close current secret chat", true, &onlyForSecret}, + "delete": command{0, []string{}, "delete current chat from chat list", true, nil}, + "members": command{0, []string{"query"}, "search members [by optional query] in current chat (requires admin rights)", true, nil}, } var transportConfigurationOptions = map[string]configurationOption{ @@ -125,6 +125,7 @@ type command struct { RequiredArgs int Arguments []string Description string + LoginOnly bool NotFor *[]ChatType } type configurationOption struct { diff --git a/telegram/connect.go b/telegram/connect.go index f344759..d37c5fd 100644 --- a/telegram/connect.go +++ b/telegram/connect.go @@ -129,6 +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", "") return errors.Wrap(err, "Couldn't initialize a Telegram client instance") } @@ -137,6 +138,8 @@ 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") @@ -255,6 +258,8 @@ func (c *Client) interactor() { log.Infof("Telegram authorization state: %#v", stateType) log.Debugf("%#v", state) + c.lastAuthorizationStateType = stateType + switch stateType { // stage 0: set login case client.TypeAuthorizationStateWaitPhoneNumber: @@ -262,12 +267,12 @@ func (c *Client) interactor() { if c.Session.Login != "" { c.authorizer.PhoneNumber <- c.Session.Login } else { - gateway.SendServiceMessage(c.jid, "Please, enter your Telegram login via /login 12345", c.xmpp) + c.wizardStageOrPrompt("login", "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...") - gateway.SendServiceMessage(c.jid, "Please, enter authorization code via /code 12345", c.xmpp) + c.wizardStageOrPrompt("code", "Please, enter authorization code via /code 12345") // stage 1b: wait for registration case client.TypeAuthorizationStateWaitRegistration: log.Warn("Waiting for full name...") @@ -275,7 +280,7 @@ func (c *Client) interactor() { // stage 2: wait for 2fa case client.TypeAuthorizationStateWaitPassword: log.Warn("Waiting for 2FA password...") - gateway.SendServiceMessage(c.jid, "Please, enter 2FA passphrase via /password 12345", c.xmpp) + c.wizardStageOrPrompt("password", "Please, enter 2FA passphrase via /password 12345") } c.locks.authorizerReadLock.Unlock() } @@ -294,6 +299,7 @@ func (c *Client) forceClose() { func (c *Client) close() { c.locks.authorizerWriteLock.Lock() if c.authorizer != nil && !c.authorizer.isClosed { + log.Debug("Closing authorizer") c.authorizer.Close() } c.locks.authorizerWriteLock.Unlock() @@ -308,6 +314,7 @@ func (c *Client) close() { } func (c *Client) cancelAuth() { + c.StopLoginWizard() c.close() c.Session.Login = "" } diff --git a/telegram/loginwizard.go b/telegram/loginwizard.go new file mode 100644 index 0000000..0e5cc9e --- /dev/null +++ b/telegram/loginwizard.go @@ -0,0 +1,82 @@ +package telegram + +import ( + "dev.narayana.im/narayana/telegabber/xmpp/gateway" + + log "github.com/sirupsen/logrus" + "github.com/zelenin/go-tdlib/client" +) + +// StartLoginWizard initiates a loginWizard object +func (c *Client) StartLoginWizard(inCommand bool) { + if c.loginWizard == nil { + c.loginWizard = &loginWizardMetadata{ + nextStage: make(chan string, 1), + commandSent: inCommand, + } + } else { + c.loginWizard.commandSent = inCommand + } +} + +// StopLoginWizard safely destroys the loginWizard object +func (c *Client) StopLoginWizard() { + c.locks.loginWizardReadLock.Lock() + c.locks.loginWizardWriteLock.Lock() + if c.loginWizard != nil { + close(c.loginWizard.nextStage) + c.loginWizard = nil + } + c.locks.loginWizardReadLock.Unlock() + c.locks.loginWizardWriteLock.Unlock() +} + +// GetLoginWizardNextStage waits for the next stage from the channel +func (c *Client) GetLoginWizardNextStage() string { + c.locks.loginWizardReadLock.Lock() + defer c.locks.loginWizardReadLock.Unlock() + + if c.loginWizard != nil { + if c.loginWizard.commandSent { + log.Debugf("waiting for nextStage...") + nextStage := <-c.loginWizard.nextStage + c.loginWizard.commandSent = false + c.loginWizard.chanBusy = false + log.Debugf("yielded stage %v", nextStage) + return nextStage + } else { + if c.lastAuthorizationStateType == client.TypeAuthorizationStateWaitPhoneNumber || + c.lastAuthorizationStateType == client.TypeAuthorizationStateClosing || + c.Session.Login == "" { + return "login" + } + switch c.lastAuthorizationStateType { + case client.TypeAuthorizationStateWaitCode: + return "code" + case client.TypeAuthorizationStateWaitPassword: + return "password" + } + } + } + + return "" +} + +func (c *Client) wizardStageOrPrompt(stage, message string) { + c.locks.loginWizardWriteLock.Lock() + if c.loginWizard == nil { + c.locks.loginWizardWriteLock.Unlock() + if message != "" { + gateway.SendServiceMessage(c.jid, message, c.xmpp) + } + } else { + if !c.loginWizard.chanBusy { + log.Debugf("writing wizard stage %v", stage) + c.loginWizard.nextStage <- stage + } else { + log.Warn("Skipping stage %v, wizard cannot keep up", stage) + } + c.loginWizard.chanBusy = true + c.locks.loginWizardWriteLock.Unlock() + } +} diff --git a/xmpp/handlers.go b/xmpp/handlers.go index dcc2079..1857787 100644 --- a/xmpp/handlers.go +++ b/xmpp/handlers.go @@ -679,25 +679,41 @@ func handleGetDiscoItems(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoItems) { log.Debugf("discoItems: %#v", di) - _, ok := toToID(iq.To) + _, toOk := toToID(iq.To) if di.Node == gateway.NSCommand { answer.Payload = di chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To) var cmdType telegram.CommandType - if ok { + if toOk { cmdType = telegram.CommandTypeChat } else { cmdType = telegram.CommandTypeTransport } + var isOnline bool + bare, _, ok := gateway.SplitJID(iq.From) + if ok { + session, ok := sessions[bare] + if ok { + isOnline = session.Online() + } + } + + if !(toOk || isOnline) { + di.AddItem(iq.To, "loginwizard", "Login Wizard") + } + commands := telegram.GetCommands(cmdType) for _, name := range telegram.SortedCommandKeys(commands) { command := commands[name] if chatTypeErr == nil && !telegram.IsCommandForChatType(command, chatType) { continue } + if !isOnline && command.LoginOnly { + continue + } di.AddItem(iq.To, name, telegram.CommandToHelpString(name, command)) } } else { @@ -851,7 +867,13 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command return } - defer gateway.ResumableSend(component, answer) + cancelSend := false + + defer func() { + if !cancelSend { + gateway.ResumableSend(component, answer) + } + }() log.Debugf("command: %#v", command) @@ -938,12 +960,20 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command } answer.Payload = &stanza.Command{ - SessionId: command.Node, - Node: command.Node, - Status: stanza.CommandStatusCompleted, + SessionId: command.Node, + Node: command.Node, + Status: stanza.CommandStatusCompleted, CommandElements: elements, } } + } else if command.Node == "loginwizard" { + var session *telegram.Client + answer.Payload, cancelSend, session = loginWizardPayload(bare, form, resource) + + log.Debugf("immediate loginwizard payload: %#v", answer.Payload) + if cancelSend { + go sendLoginWizardResponse(component, answer, session) + } } else { // just for the case the client messed the order somehow sort.Slice(form.Fields, func(i int, j int) bool { @@ -1072,10 +1102,24 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command CommandElements: []stanza.CommandElement{&form}, } log.Debugf("form: %#v", form) + } else if command.Node == "loginwizard" { + var session *telegram.Client + answer.Payload, cancelSend, session = loginWizardPayload(bare, nil, resource) + + log.Debugf("immediate loginwizard payload: %#v", answer.Payload) + if cancelSend { + go sendLoginWizardResponse(component, answer, session) + } } else { cmdString = "/" + command.Node } } else if command.Action == stanza.CommandActionCancel { + if command.Node == "loginwizard" { + session, ok := sessions[bare] + if ok { + session.ProcessTransportCommand("/cancelauth", resource) + } + } answer.Payload = &stanza.Command{ SessionId: command.Node, Node: command.Node, @@ -1119,7 +1163,7 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command } - log.Debugf("command response: %#v", answer.Payload) + log.Debugf("command response: %#v %v", answer.Payload, cancelSend) } func iqAnswerSetError(answer *stanza.IQ, payload *extensions.QueryRegister, code int) { diff --git a/xmpp/loginwizard.go b/xmpp/loginwizard.go new file mode 100644 index 0000000..28f6ac1 --- /dev/null +++ b/xmpp/loginwizard.go @@ -0,0 +1,110 @@ +package xmpp + +import ( + "fmt" + + "dev.narayana.im/narayana/telegabber/telegram" + "dev.narayana.im/narayana/telegabber/xmpp/gateway" + + log "github.com/sirupsen/logrus" + "gosrc.io/xmpp" + "gosrc.io/xmpp/stanza" +) + +func setCommandPayloadError(payload *stanza.Command, err string) { + note := stanza.Note{ + Text: err, + Type: stanza.CommandNoteTypeErr, + } + payload.Status = stanza.CommandStatusCompleted + payload.CommandElements = append(payload.CommandElements, ¬e) +} + +func loginWizardPayload(bare string, requestForm *stanza.Form, resource string) (payload *stanza.Command, cancelSend bool, returnSession *telegram.Client) { + payload = &stanza.Command{ + SessionId: "loginwizard", + Node: "loginwizard", + } + + session, ok := sessions[bare] + if ok { + returnSession = session + + if requestForm == nil { + session.StartLoginWizard(false) + cancelSend = true + } else { + if len(requestForm.Fields) != 1 { + setCommandPayloadError(payload, "Hey, don't tinker with the form!") + return + } + field := requestForm.Fields[0] + if field != nil { + if len(field.ValuesList) < 1 { + setCommandPayloadError(payload, "No value") + return + } + switch field.Var { + case "login", "code", "password": + 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 + } + } + } else { + setCommandPayloadError(payload, fmt.Sprintf("Session is not initialized, add the transport (%v) to contacts first", gateway.Jid.Bare())) + } + + return +} + +func sendLoginWizardResponse(component *xmpp.Component, answer *stanza.IQ, session *telegram.Client) { + payload := &stanza.Command{ + SessionId: "loginwizard", + Node: "loginwizard", + } + + nextStage := "login" + if session != nil { + nextStage = session.GetLoginWizardNextStage() + } + log.Debugf("nextStage: %v", nextStage) + + if nextStage == "cancel" { + setCommandPayloadError(payload, "Cancelled") + session.StopLoginWizard() + } else if nextStage == "success" { + payload.Status = stanza.CommandStatusCompleted + session.StopLoginWizard() + } else { + required := "" + form := stanza.Form{ + Type: stanza.FormTypeForm, + Title: "Login Wizard", + Fields: []*stanza.Field{ + &stanza.Field{ + Var: nextStage, + Label: nextStage, + Required: &required, + }, + }, + } + payload.Status = stanza.CommandStatusExecuting + payload.CommandElements = append(payload.CommandElements, &form) + } + + answer.Payload = payload + + gateway.ResumableSend(component, answer) +}