Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
- **Search** — Search chats, messages, and global Telegram directory
- **Contacts** — Contact list with online status indicators
- **Group Info** — Member list, admin roles, group description
- **Authentication** — Phone/SMS code, 2FA password (QR code login not yet implemented)
- **Authentication** — Phone/SMS code and 2FA password, plus QR login for `telegram-mcp`
- **First-Run Wizard** — Prompts for API credentials and saves config automatically
- **Notifications** — Desktop notifications via `notify-send` / `osascript`
- **Responsive Layout** — Dual-panel (wide) or single-panel (narrow terminals)
Expand Down Expand Up @@ -232,9 +232,15 @@ The repo also ships `telegram-mcp`, an [MCP](https://modelcontextprotocol.io) se
The MCP server uses a separate session (`session-mcp.json`), so log in once even if the TUI is already logged in:

```bash
bin/telegram-mcp login # phone → code → 2FA, writes ~/.local/share/tele-tui/session-mcp.json
bin/telegram-mcp login # phone → code → 2FA
bin/telegram-mcp login --qr # scan in Telegram → Settings → Devices
```

QR tokens refresh automatically until the login is accepted or cancelled. If
the account has two-step verification enabled, the password is read without
echoing it to the terminal. Both login modes write
`~/.local/share/tele-tui/session-mcp.json` by default.

### Client configuration

Register the server in your MCP client, e.g.:
Expand Down
109 changes: 100 additions & 9 deletions cmd/telegram-mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@
// Subcommands:
//
// serve (default) run the MCP server on stdin/stdout
// login interactive login, writes the MCP session file
// (~/.local/share/tele-tui/session-mcp.json by default)
// login interactive phone login, writes the MCP session file
// login --qr QR login via an already authorized Telegram app
// (~/.local/share/tele-tui/session-mcp.json by default)
package main

import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/signal"
Expand All @@ -20,9 +23,12 @@ import (
"syscall"
"time"

"golang.org/x/term"

"github.com/imtaqin/telegram-cli/internal/config"
"github.com/imtaqin/telegram-cli/internal/mcpserver"
"github.com/imtaqin/telegram-cli/internal/telegram"
"github.com/imtaqin/telegram-cli/internal/ui/widgets"
)

const loginHint = "session not authorized, run 'telegram-mcp login' first"
Expand All @@ -32,9 +38,15 @@ func main() {
log.SetOutput(os.Stderr)
log.SetPrefix("telegram-mcp: ")

cmd := "serve"
if len(os.Args) > 1 {
cmd = os.Args[1]
opts, err := parseCommand(os.Args[1:])
if errors.Is(err, flag.ErrHelp) {
printUsage(os.Stdout)
return
}
if err != nil {
fmt.Fprintf(os.Stderr, "telegram-mcp: %v\n", err)
printUsage(os.Stderr)
os.Exit(2)
}

cfg, err := config.Load()
Expand All @@ -55,15 +67,53 @@ func main() {
cfg.Storage.SessionFile = strings.TrimSuffix(cfg.Storage.SessionFile, ".json") + "-mcp.json"
}

switch cmd {
switch opts.command {
case "login":
runLogin(cfg)
if opts.qr {
runQRLogin(cfg)
} else {
runLogin(cfg)
}
case "serve":
runServe(cfg)
}
}

type commandOptions struct {
command string
qr bool
}

func parseCommand(args []string) (commandOptions, error) {
opts := commandOptions{command: "serve"}
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
opts.command = args[0]
args = args[1:]
}

switch opts.command {
case "login", "serve":
default:
fmt.Fprintf(os.Stderr, "usage: telegram-mcp [login|serve]\n")
os.Exit(2)
return commandOptions{}, fmt.Errorf("unknown command %q", opts.command)
}

fs := flag.NewFlagSet("telegram-mcp "+opts.command, flag.ContinueOnError)
fs.SetOutput(io.Discard)
fs.BoolVar(&opts.qr, "qr", false, "log in by scanning a QR code")
if err := fs.Parse(args); err != nil {
return commandOptions{}, err
}
if fs.NArg() != 0 {
return commandOptions{}, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}
if opts.qr && opts.command != "login" {
return commandOptions{}, errors.New("--qr is only valid with the login command")
}
return opts, nil
}

func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: telegram-mcp [login [--qr]|serve]")
}

// runLogin performs interactive authentication in the terminal and
Expand Down Expand Up @@ -140,6 +190,47 @@ func runLogin(cfg *config.Config) {
log.Fatalf("login succeeded but GetMe failed: %v", err)
}

printLoggedIn(me)
}

func runQRLogin(cfg *config.Config) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

me, err := telegram.LoginWithQR(ctx, cfg, telegram.QRLoginOptions{
ShowQRCode: func(_ context.Context, token telegram.QRLoginToken) error {
fmt.Fprint(os.Stderr, "\x1b[2J\x1b[H")
fmt.Fprintln(os.Stderr, "Telegram QR Login")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "On your logged-in Telegram phone:")
fmt.Fprintln(os.Stderr, "Settings -> Devices -> Link Desktop Device")
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, widgets.RenderQRCode(token.URL, 256))
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "QR expires at %s and will refresh automatically.\n", token.ExpiresAt.Format("15:04:05"))
return nil
},
PasswordPrompt: func(_ context.Context, retry bool) ([]byte, error) {
if retry {
fmt.Fprintln(os.Stderr, "The password was empty or invalid. Try again.")
}
fmt.Fprint(os.Stderr, "Enter your Telegram 2FA password (input is hidden): ")
password, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return password, err
},
})
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
log.Fatalf("QR login failed: %v", err)
}

printLoggedIn(me)
}

func printLoggedIn(me *telegram.User) {
name := strings.TrimSpace(me.FirstName + " " + me.LastName)
if me.Username != "" {
fmt.Fprintf(os.Stderr, "Logged in as %s (@%s, id %d)\n", name, me.Username, me.ID)
Expand Down
86 changes: 86 additions & 0 deletions cmd/telegram-mcp/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"bytes"
"os"
"os/exec"
"strings"
"testing"
)

const helpTestProcessEnv = "TELEGRAM_MCP_HELP_TEST_PROCESS"

func TestParseCommand(t *testing.T) {
tests := []struct {
name string
args []string
command string
qr bool
wantErr bool
}{
{name: "default serve", command: "serve"},
{name: "phone login", args: []string{"login"}, command: "login"},
{name: "QR login", args: []string{"login", "--qr"}, command: "login", qr: true},
{name: "serve rejects QR flag", args: []string{"serve", "--qr"}, wantErr: true},
{name: "default serve rejects QR flag", args: []string{"--qr"}, wantErr: true},
{name: "unknown command", args: []string{"unknown"}, wantErr: true},
{name: "unexpected argument", args: []string{"login", "extra"}, wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseCommand(tt.args)
if tt.wantErr {
if err == nil {
t.Fatalf("expected an error, got %+v", got)
}
return
}
if err != nil {
t.Fatalf("parse command: %v", err)
}
if got.command != tt.command || got.qr != tt.qr {
t.Fatalf("expected command=%q qr=%t, got command=%q qr=%t", tt.command, tt.qr, got.command, got.qr)
}
})
}
}

func TestMainHelpExitsSuccessfully(t *testing.T) {
if args, ok := os.LookupEnv(helpTestProcessEnv); ok {
os.Args = append([]string{"telegram-mcp"}, strings.Fields(args)...)
main()
return
}

tests := []struct {
name string
args []string
}{
{name: "long flag", args: []string{"--help"}},
{name: "short flag", args: []string{"-h"}},
{name: "login help", args: []string{"login", "--help"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=^TestMainHelpExitsSuccessfully$")
cmd.Env = append(os.Environ(), helpTestProcessEnv+"="+strings.Join(tt.args, " "))

var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

if err := cmd.Run(); err != nil {
t.Fatalf("telegram-mcp %s failed: %v\nstderr:\n%s", strings.Join(tt.args, " "), err, stderr.String())
}
if !strings.Contains(stdout.String(), "usage: telegram-mcp [login [--qr]|serve]") {
t.Fatalf("expected usage on stdout, got %q", stdout.String())
}
if stderr.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", stderr.String())
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/image v0.38.0
golang.org/x/term v0.45.0
)

require (
Expand Down Expand Up @@ -75,7 +76,6 @@ require (
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.48.0 // indirect
Expand Down
Loading