login: reimplement login in connector interface

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
This commit is contained in:
Sumner Evans
2024-06-04 13:06:10 -06:00
parent f2219a1e06
commit 6511adc480
14 changed files with 661 additions and 25 deletions
+103
View File
@@ -0,0 +1,103 @@
package connector
import (
"context"
"errors"
"github.com/gotd/td/telegram"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
)
type TelegramClient struct {
main *TelegramConnector
userLogin *bridgev2.UserLogin
client *telegram.Client
clientCancel context.CancelFunc
}
var _ bridgev2.NetworkAPI = (*TelegramClient)(nil)
// connectTelegramClient blocks until client is connected, calling Run
// internally.
// Technique from: https://github.com/gotd/contrib/blob/master/bg/connect.go
func connectTelegramClient(ctx context.Context, client *telegram.Client) (context.CancelFunc, error) {
ctx, cancel := context.WithCancel(ctx)
errC := make(chan error, 1)
initDone := make(chan struct{})
go func() {
defer close(errC)
errC <- client.Run(ctx, func(ctx context.Context) error {
close(initDone)
<-ctx.Done()
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return ctx.Err()
})
}()
select {
case <-ctx.Done(): // context canceled
cancel()
return func() {}, ctx.Err()
case err := <-errC: // startup timeout
cancel()
return func() {}, err
case <-initDone: // init done
}
return cancel, nil
}
func (t *TelegramClient) Connect(ctx context.Context) (err error) {
t.clientCancel, err = connectTelegramClient(ctx, t.client)
return
}
func (t *TelegramClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.PortalInfo, error) {
panic("unimplemented getchatinfo")
}
func (t *TelegramClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error) {
panic("unimplemented getuserinfo")
}
func (t *TelegramClient) HandleMatrixEdit(ctx context.Context, msg *bridgev2.MatrixEdit) error {
panic("unimplemented edit")
}
func (t *TelegramClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (message *database.Message, err error) {
panic("unimplemented message")
}
func (t *TelegramClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error {
panic("unimplemented remove")
}
func (t *TelegramClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (emojiID networkid.EmojiID, err error) {
panic("unimplemented reaction")
}
func (t *TelegramClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error {
panic("unimplemented reaction remove")
}
func (t *TelegramClient) IsLoggedIn() bool {
_, err := t.client.Self(context.TODO())
return err == nil
}
func (t *TelegramClient) IsThisUser(ctx context.Context, userID networkid.UserID) bool {
panic("unimplemented istheiruser")
}
func (t *TelegramClient) LogoutRemote(ctx context.Context) {
_, err := t.client.API().AuthLogOut(ctx)
if err != nil {
zerolog.Ctx(ctx).Err(err).Msg("failed to logout on Telegram")
}
}
+81
View File
@@ -0,0 +1,81 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"strconv"
"github.com/gotd/td/telegram"
"github.com/rs/zerolog"
"go.mau.fi/util/dbutil"
"go.mau.fi/zerozap"
"go.uber.org/zap"
"maunium.net/go/mautrix/bridgev2"
"go.mau.fi/mautrix-telegram/pkg/store"
)
type TelegramConfig struct {
AppID int `yaml:"app_id"`
AppHash string `yaml:"app_hash"`
}
type TelegramConnector struct {
Bridge *bridgev2.Bridge
Config *TelegramConfig
store *store.Container
}
func NewConnector() *TelegramConnector {
return &TelegramConnector{
Config: &TelegramConfig{},
}
}
func (tg *TelegramConnector) Init(bridge *bridgev2.Bridge) {
// TODO
tg.store = store.NewStore(bridge.DB.Database, dbutil.ZeroLogger(bridge.Log.With().Str("db_section", "telegram").Logger()))
tg.Bridge = bridge
}
func (tg *TelegramConnector) Start(ctx context.Context) error {
return tg.store.Upgrade(ctx)
}
func (tg *TelegramConnector) LoadUserLogin(ctx context.Context, login *bridgev2.UserLogin) error {
loginID, err := strconv.ParseInt(string(login.ID), 10, 64)
if err != nil {
return err
}
logger := zerolog.Ctx(ctx).With().
Str("component", "telegram_client").
Int64("login_id", loginID).
Logger()
login.Client = &TelegramClient{
main: tg,
userLogin: login,
client: telegram.NewClient(tg.Config.AppID, tg.Config.AppHash, telegram.Options{
SessionStorage: tg.store.GetSessionStore(loginID),
Logger: zap.New(zerozap.New(logger)),
}),
}
return nil
}
+229
View File
@@ -0,0 +1,229 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package connector
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/gotd/td/session"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/auth"
"github.com/gotd/td/tg"
"github.com/rs/zerolog"
"go.mau.fi/zerozap"
"go.uber.org/zap"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/database"
"maunium.net/go/mautrix/bridgev2/networkid"
)
const LoginFlowIDPhone = "phone"
func (tg *TelegramConnector) GetLoginFlows() []bridgev2.LoginFlow {
return []bridgev2.LoginFlow{{
Name: "Phone Number",
Description: "Login using your Telegram phone number",
ID: LoginFlowIDPhone,
}}
}
func (tg *TelegramConnector) CreateLogin(ctx context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error) {
if flowID != LoginFlowIDPhone {
return nil, fmt.Errorf("unknown flow ID %s", flowID)
}
return &PhoneLogin{user: user, main: tg}, nil
}
const (
phoneNumberStep = "fi.mau.telegram.phone_number"
codeStep = "fi.mau.telegram.code"
passwordStep = "fi.mau.telegram.password"
completeStep = "fi.mau.telegram.complete"
)
type PhoneLogin struct {
user *bridgev2.User
main *TelegramConnector
storage *session.StorageMemory
client *telegram.Client
clientCancel context.CancelFunc
phone string
hash string
}
var _ bridgev2.LoginProcessUserInput = (*PhoneLogin)(nil)
func (p *PhoneLogin) Cancel() {
p.clientCancel()
}
func (p *PhoneLogin) Start(ctx context.Context) (*bridgev2.LoginStep, error) {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeUserInput,
StepID: phoneNumberStep,
Instructions: "Please enter your phone number",
UserInputParams: &bridgev2.LoginUserInputParams{
Fields: []bridgev2.LoginInputDataField{
{
Type: bridgev2.LoginInputFieldTypePhoneNumber,
ID: phoneNumberStep,
Name: "Phone Number",
Description: "Include the country code with +",
},
},
},
}, nil
}
func (p *PhoneLogin) SubmitUserInput(ctx context.Context, input map[string]string) (*bridgev2.LoginStep, error) {
if phone, ok := input[phoneNumberStep]; ok {
p.phone = phone
p.storage = &session.StorageMemory{}
p.client = telegram.NewClient(p.main.Config.AppID, p.main.Config.AppHash, telegram.Options{
SessionStorage: p.storage,
Logger: zap.New(zerozap.New(zerolog.Ctx(ctx).With().Str("component", "telegram_login_client").Logger())),
})
var err error
p.clientCancel, err = connectTelegramClient(context.Background(), p.client)
if err != nil {
return nil, err
}
sentCode, err := p.client.Auth().SendCode(ctx, p.phone, auth.SendCodeOptions{})
if err != nil {
return nil, err
}
switch s := sentCode.(type) {
case *tg.AuthSentCode:
p.hash = s.PhoneCodeHash
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeUserInput,
StepID: codeStep,
Instructions: "Please enter the code sent to your phone",
UserInputParams: &bridgev2.LoginUserInputParams{
Fields: []bridgev2.LoginInputDataField{
{
Type: bridgev2.LoginInputFieldType2FACode,
ID: codeStep,
Name: "Code",
},
},
},
}, nil
case *tg.AuthSentCodeSuccess:
switch a := s.Authorization.(type) {
case *tg.AuthAuthorization:
// Looks that we are already authorized.
return p.handleAuthSuccess(ctx, a)
case *tg.AuthAuthorizationSignUpRequired:
return nil, fmt.Errorf("phone number does not correspond with an existing Telegram account and sign-up is not supported")
default:
return nil, fmt.Errorf("unexpected authorization type: %T", sentCode)
}
default:
return nil, fmt.Errorf("unexpected sent code type: %T", sentCode)
}
} else if code, ok := input[codeStep]; ok {
authorization, err := p.client.Auth().SignIn(ctx, p.phone, code, p.hash)
if errors.Is(err, auth.ErrPasswordAuthNeeded) {
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeUserInput,
StepID: passwordStep,
Instructions: "Please enter your password",
UserInputParams: &bridgev2.LoginUserInputParams{
Fields: []bridgev2.LoginInputDataField{
{
Type: bridgev2.LoginInputFieldTypePassword,
ID: passwordStep,
Name: "Password",
},
},
},
}, nil
} else if errors.Is(err, &auth.SignUpRequired{}) {
return nil, fmt.Errorf("sign-up is not supported")
} else if err != nil {
return nil, fmt.Errorf("failed to submit code: %w", err)
}
return p.handleAuthSuccess(ctx, authorization)
} else if password, ok := input[passwordStep]; ok {
authorization, err := p.client.Auth().Password(ctx, password)
if err != nil {
return nil, fmt.Errorf("failed to submit password: %w", err)
}
return p.handleAuthSuccess(ctx, authorization)
}
return nil, fmt.Errorf("unexpected state during phone login")
}
func makeUserLoginID(userID int64) networkid.UserLoginID {
return networkid.UserLoginID(strconv.FormatInt(userID, 10))
}
func (p *PhoneLogin) handleAuthSuccess(ctx context.Context, authorization *tg.AuthAuthorization) (*bridgev2.LoginStep, error) {
// Now that we have the Telegram user ID, store it in the database and
// close the login client.
sessionStore := p.main.store.GetSessionStore(authorization.User.GetID())
var sessionData []byte
sessionData, err := p.storage.Bytes(sessionData)
if err != nil {
return nil, err
}
err = sessionStore.StoreSession(ctx, sessionData)
if err != nil {
return nil, err
}
p.clientCancel()
userLoginID := makeUserLoginID(authorization.User.GetID())
ul, err := p.user.NewLogin(ctx, &database.UserLogin{
ID: userLoginID,
Metadata: map[string]any{
"phone": p.phone,
},
}, nil)
if err != nil {
return nil, fmt.Errorf("failed to save new login: %w", err)
}
backgroundCtx := ul.Log.WithContext(context.Background())
err = p.main.LoadUserLogin(backgroundCtx, ul)
if err != nil {
return nil, fmt.Errorf("failed to prepare connection after login: %w", err)
}
err = ul.Client.Connect(backgroundCtx)
if err != nil {
return nil, fmt.Errorf("failed to connect after login: %w", err)
}
user, err := ul.Client.(*TelegramClient).client.Self(ctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(fmt.Sprintf("%s %s", user.FirstName, user.LastName))
return &bridgev2.LoginStep{
Type: bridgev2.LoginStepTypeComplete,
StepID: completeStep,
Instructions: fmt.Sprintf("Successfully logged in as %d / +%s (%s)", user.ID, user.Phone, name),
CompleteParams: &bridgev2.LoginCompleteParams{
UserLoginID: ul.ID,
},
}, nil
}
+132
View File
@@ -0,0 +1,132 @@
package msgconv
import (
"context"
"encoding/base64"
"fmt"
"github.com/gotd/td/tg"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type ConvertedMessage struct {
Parts []*ConvertedMessagePart
}
type ConvertedMessagePart struct {
Type event.Type
Content *event.MessageEventContent
Extra map[string]any
}
func getLargestPhotoSize(sizes []tg.PhotoSizeClass) (largest tg.PhotoSizeClass) {
var maxSize int
for _, s := range sizes {
var currentSize int
switch size := s.(type) {
case *tg.PhotoSize:
currentSize = size.GetSize()
case *tg.PhotoCachedSize:
currentSize = max(size.GetW(), size.GetH())
case *tg.PhotoSizeProgressive:
currentSize = max(size.GetW(), size.GetH())
case *tg.PhotoPathSize:
currentSize = len(size.GetBytes())
case *tg.PhotoStrippedSize:
currentSize = len(size.GetBytes())
}
if currentSize > maxSize {
maxSize = currentSize
largest = s
}
}
return
}
func (mc *MessageConverter) ToMatrix(ctx context.Context, msg tg.MessageClass) *ConvertedMessage {
log := mc.getLogger(ctx).With().Str("action", "to_matrix").Logger()
cm := &ConvertedMessage{
Parts: make([]*ConvertedMessagePart, 0),
}
switch v := msg.(type) {
case *tg.Message:
if v.Message != "" {
converted := ConvertedMessagePart{
Type: event.EventMessage,
Content: &event.MessageEventContent{
MsgType: event.MsgText,
Body: v.Message,
},
}
cm.Parts = append(cm.Parts, &converted)
}
if m, ok := v.GetMedia(); ok {
switch media := m.(type) {
case *tg.MessageMediaPhoto: // messageMediaPhoto#695150d7
fmt.Printf("photo %v\n", media)
if media.GetSpoiler() {
// TODO do something
fmt.Printf("SPOILER\n")
}
if p, ok := media.GetPhoto(); ok {
switch photo := p.(type) {
case *tg.Photo: // photo#fb197a65
fmt.Printf("photo: %v\n", photo)
largest := getLargestPhotoSize(photo.GetSizes())
file := tg.InputPhotoFileLocation{
ID: photo.GetID(),
AccessHash: photo.GetAccessHash(),
FileReference: photo.GetFileReference(),
ThumbSize: largest.GetType(),
}
mxc := id.ContentURIString(
fmt.Sprintf("mxc://telegram.sumner.user.beeper.com/p.i%d.a%d.f%s.t%s", photo.GetID(), photo.GetAccessHash(), base64.RawURLEncoding.EncodeToString(photo.GetFileReference()), largest.GetType()),
)
fmt.Printf("%s\n", mxc)
// data, err := mc.downloadFile(ctx, &file)
// if err != nil {
// panic(err)
// }
// err = os.WriteFile("/home/sumner/tmp/test.jpg", data, 0644)
// if err != nil {
// panic(err)
// }
default:
log.Error().Type("msg", msg).Msg("Unhandled photo type")
}
}
case *tg.MessageMediaGeo: // messageMediaGeo#56e0d474
case *tg.MessageMediaContact: // messageMediaContact#70322949
case *tg.MessageMediaUnsupported: // messageMediaUnsupported#9f84f49e
case *tg.MessageMediaDocument: // messageMediaDocument#4cf4d72d
case *tg.MessageMediaWebPage: // messageMediaWebPage#ddf10c3b
case *tg.MessageMediaVenue: // messageMediaVenue#2ec0533f
case *tg.MessageMediaGame: // messageMediaGame#fdb19008
case *tg.MessageMediaInvoice: // messageMediaInvoice#f6a548d3
case *tg.MessageMediaGeoLive: // messageMediaGeoLive#b940c666
case *tg.MessageMediaPoll: // messageMediaPoll#4bd6e798
case *tg.MessageMediaDice: // messageMediaDice#3f7ee58b
case *tg.MessageMediaStory: // messageMediaStory#68cb6283
case *tg.MessageMediaGiveaway: // messageMediaGiveaway#daad85b0
case *tg.MessageMediaGiveawayResults: // messageMediaGiveawayResults#c6991068
default:
log.Error().Type("msg", msg).Msg("Unhandled media type")
}
}
case *tg.MessageService:
fmt.Printf("%v\n", v)
default:
log.Error().Type("msg", msg).Msg("Unhandled message type")
}
return cm
}
+25
View File
@@ -0,0 +1,25 @@
package msgconv
import (
"context"
"github.com/gotd/td/telegram"
"github.com/rs/zerolog"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
type PortalMethods interface {
DownloadMedia(ctx context.Context, uri id.ContentURIString, file *event.EncryptedFileInfo) ([]byte, error)
UploadMedia(ctx context.Context, roomID id.RoomID, data []byte, fileName, mimeType string) (url id.ContentURIString, file *event.EncryptedFileInfo, err error)
}
type MessageConverter struct {
PortalMethods
Client *telegram.Client
}
func (*MessageConverter) getLogger(ctx context.Context) zerolog.Logger {
return zerolog.Ctx(ctx).With().Str("component", "message_converter").Logger()
}
+41
View File
@@ -0,0 +1,41 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package store
import (
"context"
"go.mau.fi/util/dbutil"
"go.mau.fi/mautrix-telegram/pkg/store/upgrades"
)
type Container struct {
db *dbutil.Database
}
func NewStore(db *dbutil.Database, log dbutil.DatabaseLogger) *Container {
return &Container{db: db.Child("telegram_version", upgrades.Table, log)}
}
func (c *Container) Upgrade(ctx context.Context) error {
return c.db.Upgrade(ctx)
}
func (c *Container) GetSessionStore(telegramUserID int64) *SessionStore {
return &SessionStore{c.db, telegramUserID}
}
+39
View File
@@ -0,0 +1,39 @@
package store
import (
"context"
"github.com/gotd/td/session"
"go.mau.fi/util/dbutil"
)
// SessionStore is a wrapper around a database that implements
// [session.Storage] scoped to a specific Telegram user ID.
type SessionStore struct {
db *dbutil.Database
telegramUserID int64
}
var _ session.Storage = (*SessionStore)(nil)
const (
loadSessionQuery = `SELECT session_data FROM telegram_session WHERE user_id=$1`
storeSessionQuery = `
INSERT INTO telegram_session (user_id, session_data)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET session_data=excluded.session_data
`
)
// LoadSession loads session data from the database.
func (s *SessionStore) LoadSession(ctx context.Context) (sessionData []byte, err error) {
row := s.db.QueryRow(ctx, loadSessionQuery, s.telegramUserID)
err = row.Scan(&sessionData)
return
}
// StoreSession stores session data for a login into the database.
func (s *SessionStore) StoreSession(ctx context.Context, data []byte) error {
_, err := s.db.Exec(ctx, storeSessionQuery, s.telegramUserID, data)
return err
}
+7
View File
@@ -0,0 +1,7 @@
-- v0 -> v1: Latest revision
-- TODO do I need to have bridge ID here?
CREATE TABLE telegram_session (
user_id INTEGER PRIMARY KEY,
session_data BYTEA NOT NULL
);
+32
View File
@@ -0,0 +1,32 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package upgrades
import (
"embed"
"go.mau.fi/util/dbutil"
)
var Table dbutil.UpgradeTable
//go:embed *.sql
var rawUpgrades embed.FS
func init() {
Table.RegisterFS(rawUpgrades)
}