feat(ui): add password login

This commit is contained in:
ayn2op 2026-08-06 21:45:16 -04:00
parent 16b4465fb9
commit 228bfaeb05
10 changed files with 129 additions and 51 deletions

View file

@ -40,7 +40,21 @@ go build .
## Usage
### Token (UI, recommended)
### Password (UI, recommended)
1. Run the `discordo` executable with no arguments.
2. Open the "Password" tab, enter your email address or E.164-formatted phone number and password, then click the "Login" button.
### QR (UI)
1. Run the `discordo` executable with no arguments.
2. Click on the "Login with QR" button.
3. Follow the instructions in the QR Login screen.
### Token (UI)
1. Run the `discordo` executable with no arguments.
@ -54,14 +68,6 @@ Set the value of the `DISCORDO_TOKEN` environment variable to the authentication
DISCORDO_TOKEN="OTI2MDU5NTQxNDE2Nzc5ODA2.Yc2KKA.2iZ-5JxgxG-9Ub8GHzBSn-NJjNg" discordo
```
### QR (UI)
1. Run the `discordo` executable with no arguments.
2. Click on the "Login with QR" button.
3. Follow the instructions in the QR Login screen.
## Configuration
The configuration file allows you to configure and customize the behavior, keybindings, and theme of the application.

View file

@ -8,6 +8,7 @@ import (
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/ui/login/password"
"github.com/ayn2op/discordo/internal/ui/login/qr"
"github.com/ayn2op/discordo/internal/ui/login/token"
"github.com/ayn2op/tview"
@ -23,7 +24,7 @@ type Model struct {
}
func NewModel(cfg *config.Config) *Model {
tabs := tabs.NewModel([]tabs.Tab{token.NewModel(), qr.NewModel()})
tabs := tabs.NewModel([]tabs.Tab{password.NewModel(), qr.NewModel(), token.NewModel()})
l := layers.New()
ui.ConfigureBox(l.Box, &cfg.Theme)
@ -38,7 +39,9 @@ func NewModel(cfg *config.Config) *Model {
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case errMsg:
return m.showErrorDialog(msg.err)
return m.showErrorDialog(msg)
case password.ErrMsg:
return m.showErrorDialog(msg)
case copyErrorMsg:
return setClipboard(string(msg))
}

View file

@ -5,9 +5,7 @@ import (
"golang.design/x/clipboard"
)
type errMsg struct {
err error
}
type errMsg error
type copyErrorMsg string

View file

@ -0,0 +1,37 @@
package password
import (
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/tabs"
)
type Model struct {
*tview.Form
}
func NewModel() *Model {
form := tview.NewForm().
AddInputField("Login", "", 0).
AddPasswordField("Password", "", 0, 0).
AddButton("Login")
return &Model{Form: form}
}
var _ tabs.Tab = (*Model)(nil)
func (m *Model) Label() string {
return "Password"
}
func (m *Model) Update(msg tview.Msg) tview.Cmd {
switch msg.(type) {
case tview.FormSubmitMsg:
login := m.GetFormItem(0).(*tview.InputField).Text()
password := m.GetFormItem(1).(*tview.InputField).Text()
if login == "" || password == "" {
return nil
}
return loginCmd(login, password)
}
return m.Form.Update(msg)
}

View file

@ -0,0 +1,36 @@
package password
import (
"errors"
"github.com/ayn2op/arikawa/v3/utils/httputil"
"github.com/ayn2op/discordo/internal/http"
"github.com/ayn2op/tview"
)
type (
TokenMsg string
ErrMsg error
)
func loginCmd(login, password string) tview.Cmd {
return func() tview.Msg {
headers := http.Headers()
headers.Set("Referer", "https://discord.com/login")
client := http.NewClient("")
client.OnRequest = append(client.OnRequest, httputil.WithHeaders(headers))
loginResp, err := client.Login(login, password)
if err != nil {
return ErrMsg(err)
}
if loginResp.Token == "" {
if loginResp.MFA {
return ErrMsg(errors.New("multi-factor authentication is required; use token or QR login"))
}
return ErrMsg(errors.New("login response did not include a token"))
}
return TokenMsg(loginResp.Token)
}
}

View file

@ -101,7 +101,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
return tview.Batch(m.heartbeat(), m.sendHeartbeat())
case errMsg:
m.msg = msg.err.Error()
m.msg = msg.Error()
return m.close()
}

View file

@ -19,15 +19,10 @@ import (
"github.com/skip2/go-qrcode"
)
type errMsg struct {
err error
}
func newErrMsg(err error) errMsg {
return errMsg{err: err}
}
type TokenMsg string
type (
TokenMsg string
errMsg error
)
const remoteAuthGatewayURL = "wss://remote-auth-gateway.discord.gg/?v=2"
@ -44,7 +39,7 @@ func (m *Model) connect() tview.Cmd {
dialer := gateway.NewDialer()
conn, _, err := dialer.Dial(remoteAuthGatewayURL, headers)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
return connCreateMsg{conn: conn}
}
@ -54,7 +49,7 @@ func (m *Model) close() tview.Cmd {
return func() tview.Msg {
if m.conn != nil {
if err := m.conn.Close(); err != nil {
return newErrMsg(err)
return errMsg(err)
}
}
return connCloseMsg{}
@ -92,14 +87,14 @@ func (m *Model) listen() tview.Cmd {
_, data, err := m.conn.ReadMessage()
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
var payload struct {
Op string `json:"op"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
return errMsg(err)
}
switch payload.Op {
@ -109,7 +104,7 @@ func (m *Model) listen() tview.Cmd {
TimeoutMS int `json:"timeout_ms"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return helloMsg{heartbeatInterval: payload.HeartbeatInterval, timeoutMS: payload.TimeoutMS}
case "nonce_proof":
@ -117,7 +112,7 @@ func (m *Model) listen() tview.Cmd {
EncryptedNonce string `json:"encrypted_nonce"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return nonceProofMsg{encryptedNonce: payload.EncryptedNonce}
case "pending_remote_init":
@ -125,7 +120,7 @@ func (m *Model) listen() tview.Cmd {
Fingerprint string `json:"fingerprint"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return pendingRemoteInitMsg{fingerprint: payload.Fingerprint}
case "pending_ticket":
@ -133,7 +128,7 @@ func (m *Model) listen() tview.Cmd {
EncryptedUserPayload string `json:"encrypted_user_payload"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return pendingTicketMsg{encryptedUserPayload: payload.EncryptedUserPayload}
case "cancel":
@ -143,7 +138,7 @@ func (m *Model) listen() tview.Cmd {
Ticket string `json:"ticket"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return pendingLoginMsg{ticket: payload.Ticket}
default:
@ -170,7 +165,7 @@ func (m *Model) sendHeartbeat() tview.Cmd {
Op string `json:"op"`
}{"heartbeat"}
if err := m.conn.WriteJSON(data); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return nil
}
@ -184,7 +179,7 @@ func (m *Model) generatePrivateKey() tview.Cmd {
return func() tview.Msg {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
return privateKeyMsg{privateKey: privateKey}
}
@ -193,11 +188,11 @@ func (m *Model) generatePrivateKey() tview.Cmd {
func (m *Model) sendInit() tview.Cmd {
return func() tview.Msg {
if m.privateKey == nil {
return newErrMsg(errors.New("missing private key"))
return errMsg(errors.New("missing private key"))
}
spki, err := x509.MarshalPKIXPublicKey(m.privateKey.Public())
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
encodedPublicKey := base64.StdEncoding.EncodeToString(spki)
data := struct {
@ -205,7 +200,7 @@ func (m *Model) sendInit() tview.Cmd {
EncodedPublicKey string `json:"encoded_public_key"`
}{"init", encodedPublicKey}
if err := m.conn.WriteJSON(data); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return nil
}
@ -215,12 +210,12 @@ func (m *Model) sendNonceProof(encryptedNonce string) tview.Cmd {
return func() tview.Msg {
decodedNonce, err := base64.StdEncoding.DecodeString(encryptedNonce)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
decryptedNonce, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedNonce, nil)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
encodedNonce := base64.RawURLEncoding.EncodeToString(decryptedNonce)
@ -229,7 +224,7 @@ func (m *Model) sendNonceProof(encryptedNonce string) tview.Cmd {
Nonce string `json:"nonce"`
}{"nonce_proof", encodedNonce}
if err := m.conn.WriteJSON(data); err != nil {
return newErrMsg(err)
return errMsg(err)
}
return nil
}
@ -244,7 +239,7 @@ func (m *Model) generateQRCode(fingerprint string) tview.Cmd {
content := "https://discord.com/ra/" + fingerprint
qrCode, err := qrcode.New(content, qrcode.Low)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
qrCode.DisableBorder = true
return qrCodeMsg{qrCode: qrCode}
@ -260,17 +255,17 @@ func (m *Model) decryptUserPayload(encryptedPayload string) tview.Cmd {
return func() tview.Msg {
decodedPayload, err := base64.StdEncoding.DecodeString(encryptedPayload)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
decryptedPayload, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedPayload, nil)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
parts := strings.Split(string(decryptedPayload), ":")
if len(parts) != 4 {
return newErrMsg(errors.New("invalid user payload"))
return errMsg(errors.New("invalid user payload"))
}
return userMsg{discriminator: parts[1], username: parts[3]}
@ -290,17 +285,17 @@ func (m *Model) exchangeTicket(ticket string) tview.Cmd {
encryptedToken, err := client.ExchangeRemoteAuthTicket(ticket)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
decodedToken, err := base64.StdEncoding.DecodeString(encryptedToken)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
decryptedToken, err := rsa.DecryptOAEP(sha256.New(), nil, m.privateKey, decodedToken, nil)
if err != nil {
return newErrMsg(err)
return errMsg(err)
}
return TokenMsg(string(decryptedToken))
}

View file

@ -29,7 +29,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
if token == "" {
return nil
}
return tokenCommand(token)
return tokenCmd(token)
}
return m.Form.Update(msg)
}

View file

@ -6,7 +6,7 @@ import (
type TokenMsg string
func tokenCommand(token string) tview.Cmd {
func tokenCmd(token string) tview.Cmd {
return func() tview.Msg {
return TokenMsg(token)
}

View file

@ -8,6 +8,7 @@ import (
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/ui/chat"
"github.com/ayn2op/discordo/internal/ui/login"
"github.com/ayn2op/discordo/internal/ui/login/password"
"github.com/ayn2op/discordo/internal/ui/login/qr"
"github.com/ayn2op/discordo/internal/ui/login/token"
"github.com/ayn2op/tview"
@ -115,10 +116,12 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case tokenMsg:
return m.showChat(string(msg))
case token.TokenMsg:
case password.TokenMsg:
return tview.Batch(m.showChat(string(msg)), setToken(string(msg)))
case qr.TokenMsg:
return tview.Batch(m.showChat(string(msg)), setToken(string(msg)))
case token.TokenMsg:
return tview.Batch(m.showChat(string(msg)), setToken(string(msg)))
case chat.LogoutMsg:
return tview.Batch(