Login Wizard

This commit is contained in:
Bohdan Horbeshko 2025-04-03 18:57:13 -04:00
parent 0368b8cad8
commit 85846346d1
7 changed files with 315 additions and 58 deletions

View file

@ -44,6 +44,10 @@ type Client struct {
cache *cache.Cache cache *cache.Cache
online bool online bool
loginWizard *loginWizardMetadata
lastAuthorizationStateType string
outbox map[string]string outbox map[string]string
editOutbox map[string]string editOutbox map[string]string
@ -75,6 +79,15 @@ type clientLocks struct {
authorizerReadLock sync.Mutex authorizerReadLock sync.Mutex
authorizerWriteLock 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 // NewClient instantiates a Telegram App

View file

@ -51,22 +51,22 @@ var permissionsMember = client.ChatPermissions{
var permissionsReadonly = client.ChatPermissions{} var permissionsReadonly = client.ChatPermissions{}
var transportCommands = map[string]command{ var transportCommands = map[string]command{
"help": command{0, []string{}, "help", nil}, "help": command{0, []string{}, "help", false, nil},
"login": command{1, []string{"phone"}, "sign in", nil}, "login": command{1, []string{"phone"}, "sign in", false, nil},
"logout": command{0, []string{}, "sign out", nil}, "logout": command{0, []string{}, "sign out", true, nil},
"cancelauth": command{0, []string{}, "quit the signin wizard", nil}, "cancelauth": command{0, []string{}, "quit the signin wizard", false, nil},
"code": command{1, []string{"xxxxx"}, "check one-time code", nil}, "code": command{1, []string{"xxxxx"}, "check one-time code", false, nil},
"password": command{1, []string{"********"}, "check 2fa password", nil}, "password": command{1, []string{"********"}, "check 2fa password", false, nil},
"setusername": command{0, []string{"@username"}, "update @username", nil}, "setusername": command{0, []string{"@username"}, "update @username", true, nil},
"setname": command{1, []string{"first", "last"}, "update name", nil}, "setname": command{1, []string{"first", "last"}, "update name", true, nil},
"setbio": command{0, []string{"Lorem ipsum"}, "update about", nil}, "setbio": command{0, []string{"Lorem ipsum"}, "update about", true, nil},
"setpassword": command{0, []string{"old", "new"}, "set or remove password", nil}, "setpassword": command{0, []string{"old", "new"}, "set or remove password", true, nil},
"config": command{0, []string{"param", "value"}, "view or update configuration options", 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", 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", 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", 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»", 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»", nil}, "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil},
} }
var notForGroups = []ChatType{ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel} var notForGroups = []ChatType{ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel}
@ -75,37 +75,37 @@ var notForPMAndBasic = []ChatType{ChatTypePrivate, ChatTypeSecret, ChatTypeBasic
var onlyForSecret = []ChatType{ChatTypePrivate, ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel} var onlyForSecret = []ChatType{ChatTypePrivate, ChatTypeBasicGroup, ChatTypeSupergroup, ChatTypeChannel}
var chatCommands = map[string]command{ var chatCommands = map[string]command{
"help": command{0, []string{}, "help", nil}, "help": command{0, []string{}, "help", false, nil},
"d": command{0, []string{"n"}, "delete your last message(s)", nil}, "d": command{0, []string{"n"}, "delete your last message(s)", true, nil},
"s": command{1, []string{"edited message"}, "edit your last message", nil}, "s": command{1, []string{"edited message"}, "edit your last message", true, nil},
"silent": command{1, []string{"message"}, "send a message without sound", 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", 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", nil}, "forward": command{2, []string{"message_id", "target_chat"}, "forwards a message", true, nil},
"vcard": command{0, []string{}, "print vCard as text", nil}, "vcard": command{0, []string{}, "print vCard as text", true, nil},
"add": command{1, []string{"@username"}, "add @username to your chat list", 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", 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", &notForGroups}, "group": command{1, []string{"title"}, "create groupchat «title» with current user", true, &notForGroups},
"supergroup": command{1, []string{"title", "description"}, "create new supergroup «title» with «description»", 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»", nil}, "channel": command{1, []string{"title", "description"}, "create new channel «title» with «description»", true, nil},
"secret": command{0, []string{}, "create secretchat with current user", &notForGroups}, "secret": command{0, []string{}, "create secretchat with current user", true, &notForGroups},
"search": command{0, []string{"string", "[limit]"}, "search <string> in current chat", nil}, "search": command{0, []string{"string", "[limit]"}, "search <string> in current chat", true, nil},
"history": command{0, []string{"limit"}, "get last [limit] messages from current chat", nil}, "history": command{0, []string{"limit"}, "get last [limit] messages from current chat", true, nil},
"block": command{0, []string{}, "blacklist current user", &notForGroups}, "block": command{0, []string{}, "blacklist current user", true, &notForGroups},
"unblock": command{0, []string{}, "unblacklist current user", &notForGroups}, "unblock": command{0, []string{}, "unblacklist current user", true, &notForGroups},
"invite": command{1, []string{"id or @username"}, "add user to current chat", &notForPM}, "invite": command{1, []string{"id or @username"}, "add user to current chat", true, &notForPM},
"link": command{0, []string{}, "get invite link for current chat", &notForPM}, "link": command{0, []string{}, "get invite link for current chat", true, &notForPM},
"kick": command{1, []string{"id or @username"}, "remove user from current chat", &notForPM}, "kick": command{1, []string{"id or @username"}, "remove user from current chat", true, &notForPM},
"mute": command{0, []string{"id or @username", "hours"}, "mute the whole chat or a user in current chat", &notForPMAndBasic}, "mute": command{0, []string{"id or @username", "hours"}, "mute the whole chat or a user in current chat", true, &notForPMAndBasic},
"unmute": command{0, []string{"id or @username"}, "unmute the whole chat or a user in the current chat", &notForPMAndBasic}, "unmute": command{0, []string{"id or @username"}, "unmute the whole chat or a user in the current chat", true, &notForPMAndBasic},
"ban": command{1, []string{"id or @username", "hours"}, "restrict @username from current chat for [hours] or forever", &notForPM}, "ban": command{1, []string{"id or @username", "hours"}, "restrict @username from current chat for [hours] or forever", true, &notForPM},
"unban": command{1, []string{"id or @username"}, "unbans @username in current chat (and devotes from admins)", &notForPM}, "unban": command{1, []string{"id or @username"}, "unbans @username in current chat (and devotes from admins)", true, &notForPM},
"promote": command{1, []string{"id or @username", "title"}, "promote user to admin in current chat", &notForPM}, "promote": command{1, []string{"id or @username", "title"}, "promote user to admin in current chat", true, &notForPM},
"leave": command{0, []string{}, "leave current chat", &notForPM}, "leave": command{0, []string{}, "leave current chat", true, &notForPM},
"leave!": command{0, []string{}, "leave current chat (for owners)", &notForPM}, "leave!": command{0, []string{}, "leave current chat (for owners)", true, &notForPM},
"ttl": command{0, []string{"seconds"}, "set secret chat messages TTL before self-destroying", &onlyForSecret}, "ttl": command{0, []string{"seconds"}, "set secret chat messages TTL before self-destroying", true, &onlyForSecret},
"close": command{0, []string{}, "close current secret chat", &onlyForSecret}, "close": command{0, []string{}, "close current secret chat", true, &onlyForSecret},
"delete": command{0, []string{}, "delete current chat from chat list", nil}, "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)", nil}, "members": command{0, []string{"query"}, "search members [by optional query] in current chat (requires admin rights)", true, nil},
} }
var transportConfigurationOptions = map[string]configurationOption{ var transportConfigurationOptions = map[string]configurationOption{
@ -125,6 +125,7 @@ type command struct {
RequiredArgs int RequiredArgs int
Arguments []string Arguments []string
Description string Description string
LoginOnly bool
NotFor *[]ChatType NotFor *[]ChatType
} }
type configurationOption struct { type configurationOption struct {

View file

@ -129,6 +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", "")
return errors.Wrap(err, "Couldn't initialize a Telegram client instance") 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 // 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")
@ -255,6 +258,8 @@ func (c *Client) interactor() {
log.Infof("Telegram authorization state: %#v", stateType) log.Infof("Telegram authorization state: %#v", stateType)
log.Debugf("%#v", state) log.Debugf("%#v", state)
c.lastAuthorizationStateType = stateType
switch stateType { switch stateType {
// stage 0: set login // stage 0: set login
case client.TypeAuthorizationStateWaitPhoneNumber: case client.TypeAuthorizationStateWaitPhoneNumber:
@ -262,12 +267,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 {
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 // stage 1: wait for auth code
case client.TypeAuthorizationStateWaitCode: case client.TypeAuthorizationStateWaitCode:
log.Warn("Waiting for authorization code...") 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 // stage 1b: wait for registration
case client.TypeAuthorizationStateWaitRegistration: case client.TypeAuthorizationStateWaitRegistration:
log.Warn("Waiting for full name...") log.Warn("Waiting for full name...")
@ -275,7 +280,7 @@ 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...")
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() c.locks.authorizerReadLock.Unlock()
} }
@ -294,6 +299,7 @@ func (c *Client) forceClose() {
func (c *Client) close() { func (c *Client) close() {
c.locks.authorizerWriteLock.Lock() c.locks.authorizerWriteLock.Lock()
if c.authorizer != nil && !c.authorizer.isClosed { if c.authorizer != nil && !c.authorizer.isClosed {
log.Debug("Closing authorizer")
c.authorizer.Close() c.authorizer.Close()
} }
c.locks.authorizerWriteLock.Unlock() c.locks.authorizerWriteLock.Unlock()
@ -308,6 +314,7 @@ func (c *Client) close() {
} }
func (c *Client) cancelAuth() { func (c *Client) cancelAuth() {
c.StopLoginWizard()
c.close() c.close()
c.Session.Login = "" c.Session.Login = ""
} }

82
telegram/loginwizard.go Normal file
View file

@ -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()
}
}

View file

@ -679,25 +679,41 @@ func handleGetDiscoItems(s xmpp.Sender, iq *stanza.IQ, di *stanza.DiscoItems) {
log.Debugf("discoItems: %#v", di) log.Debugf("discoItems: %#v", di)
_, ok := toToID(iq.To) _, toOk := toToID(iq.To)
if di.Node == gateway.NSCommand { if di.Node == gateway.NSCommand {
answer.Payload = di answer.Payload = di
chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To) chatType, chatTypeErr := getTelegramChatType(iq.From, iq.To)
var cmdType telegram.CommandType var cmdType telegram.CommandType
if ok { if toOk {
cmdType = telegram.CommandTypeChat cmdType = telegram.CommandTypeChat
} else { } else {
cmdType = telegram.CommandTypeTransport 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) commands := telegram.GetCommands(cmdType)
for _, name := range telegram.SortedCommandKeys(commands) { for _, name := range telegram.SortedCommandKeys(commands) {
command := commands[name] command := commands[name]
if chatTypeErr == nil && !telegram.IsCommandForChatType(command, chatType) { if chatTypeErr == nil && !telegram.IsCommandForChatType(command, chatType) {
continue continue
} }
if !isOnline && command.LoginOnly {
continue
}
di.AddItem(iq.To, name, telegram.CommandToHelpString(name, command)) di.AddItem(iq.To, name, telegram.CommandToHelpString(name, command))
} }
} else { } else {
@ -851,7 +867,13 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command
return return
} }
defer gateway.ResumableSend(component, answer) cancelSend := false
defer func() {
if !cancelSend {
gateway.ResumableSend(component, answer)
}
}()
log.Debugf("command: %#v", command) log.Debugf("command: %#v", command)
@ -944,6 +966,14 @@ func handleSetQueryCommand(s xmpp.Sender, iq *stanza.IQ, command *stanza.Command
CommandElements: elements, 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 { } else {
// just for the case the client messed the order somehow // just for the case the client messed the order somehow
sort.Slice(form.Fields, func(i int, j int) bool { 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}, CommandElements: []stanza.CommandElement{&form},
} }
log.Debugf("form: %#v", 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 { } else {
cmdString = "/" + command.Node cmdString = "/" + command.Node
} }
} else if command.Action == stanza.CommandActionCancel { } else if command.Action == stanza.CommandActionCancel {
if command.Node == "loginwizard" {
session, ok := sessions[bare]
if ok {
session.ProcessTransportCommand("/cancelauth", resource)
}
}
answer.Payload = &stanza.Command{ answer.Payload = &stanza.Command{
SessionId: command.Node, SessionId: command.Node,
Node: 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) { func iqAnswerSetError(answer *stanza.IQ, payload *extensions.QueryRegister, code int) {

110
xmpp/loginwizard.go Normal file
View file

@ -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, &note)
}
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)
}