This commit is contained in:
John Doe 2025-04-01 17:48:16 +03:00
commit 42e446cea0
13 changed files with 1131 additions and 0 deletions

72
.gitignore vendored Normal file
View file

@ -0,0 +1,72 @@
### VisualStudioCode template
.vscode/
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### GoLand+all template
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
### Go template
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
.gitlab-ci-local
local/
log_file.yaml
session.json
bin/
.env
.cursor/

129
.golangci.yaml Normal file
View file

@ -0,0 +1,129 @@
run:
concurrency: 4
timeout: 10m
issues-exit-code: 2
tests: true
build-tags: []
allow-parallel-runners: true
allow-serial-runners: true
go: '1.24'
output:
formats:
- format: colored-line-number
print-issued-lines: true
print-linter-name: true
path-prefix: ""
sort-results: true
sort-order:
- linter
- severity
- file
show-stats: true
linters:
enable:
- errcheck
- govet
- gosimple
- ineffassign
- staticcheck
- unused
- depguard
- asciicheck
- bodyclose
- canonicalheader
- copyloopvar
- dupl
- errorlint
- gocheckcompilerdirectives
- gochecknoinits
- gocognit
- goconst
- gocritic
- gocyclo
- gosec
- grouper
- inamedparam
- lll
- makezero
- nestif
- nilerr
- nilnil
- nlreturn
- noctx
- perfsprint
- prealloc
- revive
- testifylint
- whitespace
- importas
- wrapcheck
- nolintlint
linters-settings:
lll:
line-length: 150
goimports:
local-prefixes: "gitlab.com/v8s/trating"
depguard:
rules:
configuration:
files:
- $all
- "!**/internal/config/*.go"
deny:
- pkg: "github.com/spf13/viper"
desc: Should be used only in config package, to avoid boiler plate
replace-std:
list-mode: lax
files:
- "**/internal/**/*.go"
deny:
- pkg: "errors"
desc: Use github.com/pkg/errors for proper callstack logging (check README.md)
- pkg: "log"
desc: Use github.com/rs/zerolog/log as replacement
importas:
no-unaliased: true
alias:
# enforce easycfg like usage
- pkg: github.com/spf13/pflag
alias: cfg
wrapcheck:
ignoreSigs:
- github.com/pkg/errors.Wrap(
- github.com/pkg/errors.Wrapf(
- github.com/pkg/errors.New(
gocyclo:
min-complexity: 15
dupl:
threshold: 100
issues:
exclude-dirs-use-default: true
exclude-files: []
exclude-rules:
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
- lll
- path: internal/config/
linters:
- importas
- path: cmd/.*\.go
text: "exitAfterDefer"
linters:
- gocritic
- text: "should be written without leading space as"
linters: [nolintlint]

40
Taskfile.yml Normal file
View file

@ -0,0 +1,40 @@
# https://taskfile.dev
version: '3'
dotenv: [".env"]
tasks:
mod:
desc: Sync go.mod
cmds:
- go mod download
- go mod tidy
run:test:
desc: Run test command
cmds:
- go run ./cmd/test/...
run:
desc: Run CLI app
cmds:
- go run ./. {{.CLI_ARGS}}
build:
cmds:
- mkdir -p ./bin
- go build -o ./bin/telegram-mcp ./.
install:
desc: Install MCP server to user's local bin
deps: [build]
cmds:
- cp ./bin/telegram-mcp ~/.local/bin/
- chmod +x ~/.local/bin/telegram-mcp
lint:
desc: Run linter
cmd: golangci-lint run ./...

62
auth.go Normal file
View file

@ -0,0 +1,62 @@
package main
import (
"context"
"encoding/json"
"fmt"
"telegram-mcp/internal/tg"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
)
func authCommand(_ context.Context, cmd *cli.Command) error {
phone := cmd.String("phone")
appID := cmd.Root().Int("app-id")
apiHash := cmd.Root().String("api-hash")
sessionPath := cmd.Root().String("session")
log.Info().
Str("phone", phone).
Str("api-hash", apiHash).
Str("session", sessionPath).
Int64("app-id", appID).
Msg("Authenticate with Telegram")
err := tg.Auth(phone, appID, apiHash, sessionPath)
if err != nil {
log.Fatal().Err(err).Msg("Failed to authenticate with Telegram")
}
c := struct {
Telegram struct {
Command string `json:"command"`
Env struct {
AppID string `json:"TG_APP_ID"`
ApiHash string `json:"TG_API_HASH"`
} `json:"env"`
} `json:"telegram"`
}{
Telegram: struct {
Command string `json:"command"`
Env struct {
AppID string `json:"TG_APP_ID"`
ApiHash string `json:"TG_API_HASH"`
} `json:"env"`
}{
Command: "telegram-mcp",
Env: struct {
AppID string `json:"TG_APP_ID"`
ApiHash string `json:"TG_API_HASH"`
}{
AppID: fmt.Sprintf("%d", appID),
ApiHash: apiHash,
},
},
}
data, _ := json.MarshalIndent(c, "", "\t")
log.Info().RawJSON("config", data).Msg("Successfully authenticated with Telegram")
return nil
}

79
cmd/test/main.go Normal file
View file

@ -0,0 +1,79 @@
package main
import (
"context"
"os"
"os/signal"
"strconv"
"telegram-mcp/internal/tg"
"github.com/pkg/errors"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func main() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: zerolog.TimeFormatUnix,
})
appIDStr, apiHash, sessionPath := os.Getenv("TG_APP_ID"), os.Getenv("TG_API_HASH"), os.Getenv("TG_SESSION_PATH")
if appIDStr == "" {
log.Fatal().Msg("TG_APP_ID is required")
}
if apiHash == "" {
log.Fatal().Msg("TG_API_HASH is required")
}
if sessionPath == "" {
log.Fatal().Msg("TG_SESSION_PATH is required")
}
appID, err := strconv.Atoi(appIDStr)
if err != nil {
log.Fatal().Err(err).Msg("TG_APP_ID app id")
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
client := tg.New(appID, apiHash, sessionPath).T
if err := client.Run(ctx, func(ctx context.Context) error {
self, err := client.Self(ctx)
if err != nil {
return errors.Wrap(err, "get self")
}
log.Info().
Str("first_name", self.FirstName).
Str("last_name", self.LastName).
Str("username", self.Username).
Int64("id", self.ID).
Msg("Logged in as")
messages, err := getUnreadMessages(ctx, client)
if err != nil {
return errors.Wrap(err, "get unread messages")
}
for _, msg := range messages {
log.Info().
Int("id", msg.ID).
Str("text", msg.Text).
Time("date", msg.Date).
Int64("from_id", msg.FromID).
Str("from_name", msg.FromName).
Str("chat_type", msg.ChatType).
Str("chat_title", msg.ChatTitle).
Msg("Unread message")
}
return nil
}); err != nil {
log.Fatal().Err(err).Msg("client error")
}
}

280
cmd/test/unread.go Normal file
View file

@ -0,0 +1,280 @@
package main
import (
"context"
"sort"
"time"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
cfg "github.com/spf13/pflag"
"golang.org/x/time/rate"
)
const (
defaultMessageLimit = 10
maxDialogsLimit = 100
rateLimitPerSec = 5
)
//nolint:gochecknoglobals // CLI flags must be global
var (
messageLimit = cfg.Int("limit", defaultMessageLimit, "limit of unread messages to fetch")
)
//nolint:gochecknoglobals // Rate limiter should be global for consistent rate limiting across all functions
var telegramLimiter = rate.NewLimiter(rate.Limit(rateLimitPerSec), 1)
// UnreadMessage represents a simplified message structure
type UnreadMessage struct {
ID int
Text string
Date time.Time
FromID int64
FromName string
ChatType string
ChatTitle string
}
// DialogWithUnread represents a dialog with its unread count and latest message ID
type DialogWithUnread struct {
Dialog *tg.Dialog
UnreadCount int
TopMessage int
}
// getUnreadMessages fetches unread messages from different users
//
//nolint:gocognit,gocyclo // complexity is inherent to handling different types of Telegram messages and users
func getUnreadMessages(ctx context.Context, client *telegram.Client) ([]UnreadMessage, error) {
if err := telegramLimiter.Wait(ctx); err != nil {
return nil, errors.Wrap(err, "rate limiter wait")
}
api := client.API()
dialogsClass, err := api.MessagesGetDialogs(ctx, &tg.MessagesGetDialogsRequest{
OffsetPeer: &tg.InputPeerEmpty{},
OffsetDate: 0,
OffsetID: 0,
Limit: maxDialogsLimit,
Hash: 0,
Flags: 0,
ExcludePinned: false,
FolderID: 0,
})
if err != nil {
return nil, errors.Wrap(err, "get dialogs")
}
var dialogs *tg.MessagesDialogs
switch d := dialogsClass.(type) {
case *tg.MessagesDialogs:
dialogs = d
case *tg.MessagesDialogsSlice:
dialogs = &tg.MessagesDialogs{
Dialogs: d.Dialogs,
Messages: d.Messages,
Chats: d.Chats,
Users: d.Users,
}
default:
return nil, errors.New("unexpected dialogs response type")
}
// Create a slice of dialogs with unread count
dialogsWithUnread := make([]DialogWithUnread, 0, len(dialogs.Dialogs))
for _, dialog := range dialogs.Dialogs {
dialogItem, ok := dialog.(*tg.Dialog)
if !ok {
continue
}
if dialogItem.UnreadCount > 0 {
dialogsWithUnread = append(dialogsWithUnread, DialogWithUnread{
Dialog: dialogItem,
UnreadCount: dialogItem.UnreadCount,
TopMessage: dialogItem.TopMessage,
})
}
}
// Sort dialogs by TopMessage in descending order (newest first)
sort.Slice(dialogsWithUnread, func(i, j int) bool {
return dialogsWithUnread[i].TopMessage > dialogsWithUnread[j].TopMessage
})
// Map to store the latest message from each user
userMessages := make(map[int64]UnreadMessage)
processedCount := 0
for _, dialogWithUnread := range dialogsWithUnread {
dialogItem := dialogWithUnread.Dialog
var inputPeer tg.InputPeerClass
var chatType, chatTitle string
var fromID int64
var fromName string
switch peer := dialogItem.Peer.(type) {
case *tg.PeerUser:
for _, userItem := range dialogs.Users {
user, ok := userItem.(*tg.User)
if !ok || user.ID != peer.UserID {
continue
}
inputPeer = &tg.InputPeerUser{
UserID: user.ID,
AccessHash: user.AccessHash,
}
chatType = "user"
chatTitle = user.FirstName + " " + user.LastName
fromID = user.ID
fromName = chatTitle
break
}
case *tg.PeerChat:
inputPeer = &tg.InputPeerChat{
ChatID: peer.ChatID,
}
chatType = "chat"
for _, chatItem := range dialogs.Chats {
chat, ok := chatItem.(*tg.Chat)
if !ok || chat.ID != peer.ChatID {
continue
}
chatTitle = chat.Title
break
}
case *tg.PeerChannel:
for _, channelItem := range dialogs.Chats {
channel, ok := channelItem.(*tg.Channel)
if !ok || channel.ID != peer.ChannelID {
continue
}
inputPeer = &tg.InputPeerChannel{
ChannelID: channel.ID,
AccessHash: channel.AccessHash,
}
chatType = "channel"
chatTitle = channel.Title
break
}
}
if inputPeer == nil {
continue
}
if err := telegramLimiter.Wait(ctx); err != nil {
return nil, errors.Wrap(err, "rate limiter wait")
}
messagesClass, err := api.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
Peer: inputPeer,
OffsetID: 0,
OffsetDate: 0,
AddOffset: 0,
Limit: 1, // We only need the latest message
MaxID: 0,
MinID: 0,
Hash: 0,
})
if err != nil {
log.Error().Err(err).Msg("failed to get messages")
continue
}
var messages *tg.MessagesMessages
switch m := messagesClass.(type) {
case *tg.MessagesMessages:
messages = m
case *tg.MessagesMessagesSlice:
messages = &tg.MessagesMessages{
Messages: m.Messages,
Chats: m.Chats,
Users: m.Users,
}
case *tg.MessagesChannelMessages:
messages = &tg.MessagesMessages{
Messages: m.Messages,
Chats: m.Chats,
Users: m.Users,
}
default:
log.Error().Msg("unexpected messages response type")
continue
}
for _, msg := range messages.Messages {
message, ok := msg.(*tg.Message)
if !ok {
continue
}
if message.Out {
continue
}
if message.FromID != nil {
if from, ok := message.FromID.(*tg.PeerUser); ok {
for _, userItem := range messages.Users {
user, ok := userItem.(*tg.User)
if !ok || user.ID != from.UserID {
continue
}
fromID = user.ID
fromName = user.FirstName + " " + user.LastName
break
}
}
}
unreadMsg := UnreadMessage{
ID: message.ID,
Text: message.Message,
Date: time.Unix(int64(message.Date), 0),
FromID: fromID,
FromName: fromName,
ChatType: chatType,
ChatTitle: chatTitle,
}
// Only store if we haven't seen this user yet or if this message is newer
if existingMsg, exists := userMessages[fromID]; !exists || unreadMsg.Date.After(existingMsg.Date) {
userMessages[fromID] = unreadMsg
processedCount++
}
break // We only need the latest message
}
if len(userMessages) >= *messageLimit {
break
}
}
// Convert map to slice and sort by date
messages := make([]UnreadMessage, 0, len(userMessages))
for _, msg := range userMessages {
messages = append(messages, msg)
}
// Sort messages by date in descending order
sort.Slice(messages, func(i, j int) bool {
return messages[i].Date.After(messages[j].Date)
})
return messages, nil
}

59
go.mod Normal file
View file

@ -0,0 +1,59 @@
module telegram-mcp
go 1.24
require (
github.com/gotd/td v0.121.0
github.com/metoro-io/mcp-golang v0.8.0
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.34.0
github.com/spf13/pflag v1.0.6
github.com/urfave/cli/v3 v3.1.0
golang.org/x/time v0.11.0
)
require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-faster/jx v1.1.0 // indirect
github.com/go-faster/xor v1.0.0 // indirect
github.com/go-faster/yaml v0.4.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gotd/ige v0.2.2 // indirect
github.com/gotd/neo v0.1.5 // indirect
github.com/invopop/jsonschema v0.12.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ogen-go/ogen v1.10.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/tools v0.31.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
rsc.io/qr v0.2.0 // indirect
)

139
go.sum Normal file
View file

@ -0,0 +1,139 @@
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-faster/jx v1.1.0 h1:ZsW3wD+snOdmTDy9eIVgQdjUpXRRV4rqW8NS3t+20bg=
github.com/go-faster/jx v1.1.0/go.mod h1:vKDNikrKoyUmpzaJ0OkIkRQClNHFX/nF3dnTJZb3skg=
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/yaml v0.4.6 h1:lOK/EhI04gCpPgPhgt0bChS6bvw7G3WwI8xxVe0sw9I=
github.com/go-faster/yaml v0.4.6/go.mod h1:390dRIvV4zbnO7qC9FGo6YYutc+wyyUSHBgbXL52eXk=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/td v0.121.0 h1:U+KvqpJ5g/SOXIzSLMKLXW4i/ncGfnZEibS9SLtv44E=
github.com/gotd/td v0.121.0/go.mod h1:e1bdmxa/tV1pnvDmBNRh8bhF0iqw7de8F2pN4MmbJ4g=
github.com/invopop/jsonschema v0.12.0 h1:6ovsNSuvn9wEQVOyc72aycBMVQFKz7cPdMJn10CvzRI=
github.com/invopop/jsonschema v0.12.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/metoro-io/mcp-golang v0.8.0 h1:DkigHa3w7WwMFomcEz5wiMDX94DsvVm/3mCV3d1obnc=
github.com/metoro-io/mcp-golang v0.8.0/go.mod h1:ifLP9ZzKpN1UqFWNTpAHOqSvNkMK6b7d1FSZ5Lu0lN0=
github.com/ogen-go/ogen v1.10.1 h1:oeSN8AF9mhTVfapbMuL8pQTF2ToqyW9xXaStmOhHKTA=
github.com/ogen-go/ogen v1.10.1/go.mod h1:fXCg9PsNYEzJ8ABdmZ2A7j4hMi9EDHP53jzsNtIM3d0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/urfave/cli/v3 v3.1.0 h1:kQR+oiqpJkBAONxBjM4RWivD4AfPHL/f4vqe/gjYU8M=
github.com/urfave/cli/v3 v3.1.0/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 h1:Di6/M8l0O2lCLc6VVRWhgCiApHV8MnQurBnFSHsQtNY=
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

63
internal/tg/auth.go Normal file
View file

@ -0,0 +1,63 @@
package tg
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/gotd/td/telegram"
"github.com/gotd/td/telegram/auth"
"github.com/gotd/td/tg"
"github.com/rs/zerolog/log"
)
func Auth(phone string, appID int64, appHash string, sessionPath string) error {
client := telegram.NewClient(int(appID), appHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{
Path: sessionPath,
},
})
sessionDir := filepath.Dir(sessionPath)
if err := os.MkdirAll(sessionDir, 0700); err != nil {
return fmt.Errorf("mkdir(%s): %w", sessionDir, err)
}
if err := client.Run(context.Background(), func(ctx context.Context) error {
// Authenticate if needed
flow := auth.NewFlow(auth.Constant(phone, "", auth.CodeAuthenticatorFunc(func(ctx context.Context, _ *tg.AuthSentCode) (string, error) {
fmt.Print("Enter code: ")
code, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(code), nil
})), auth.SendCodeOptions{})
if err := client.Auth().IfNecessary(ctx, flow); err != nil {
return fmt.Errorf("auth: %w", err)
}
// Get current user info
self, err := client.Self(ctx)
if err != nil {
return fmt.Errorf("get self info: %w", err)
}
log.Info().
Str("first_name", self.FirstName).
Str("last_name", self.LastName).
Str("username", self.Username).
Int64("id", self.ID).
Msg("Logged in as")
return nil
}); err != nil {
return fmt.Errorf("client error: %w", err)
}
return nil
}

20
internal/tg/client.go Normal file
View file

@ -0,0 +1,20 @@
package tg
import "github.com/gotd/td/telegram"
type Client struct {
T *telegram.Client
}
func New(appID int, appHash, sessionPath string) *Client {
client := telegram.NewClient(appID, appHash, telegram.Options{
SessionStorage: &telegram.FileSessionStorage{
Path: sessionPath,
},
NoUpdates: true,
})
return &Client{
T: client,
}
}

51
internal/tg/me.go Normal file
View file

@ -0,0 +1,51 @@
package tg
import (
"context"
"encoding/json"
mcp "github.com/metoro-io/mcp-golang"
"github.com/pkg/errors"
)
type MeResponse struct {
ID int64 `json:"id" jsonschema:"required,description=User ID"`
FirstName string `json:"first_name" jsonschema:"required,description=User's first name"`
LastName string `json:"last_name" jsonschema:"description=User's last name"`
Username string `json:"username" jsonschema:"description=User's username"`
}
type EmptyArguments struct{}
func (c *Client) GetMe(_ EmptyArguments) (*mcp.ToolResponse, error) {
var toolResponse *mcp.ToolResponse
if err := c.T.Run(context.Background(), func(ctx context.Context) error {
self, err := c.T.Self(ctx)
if err != nil {
return errors.Wrap(err, "failed to get self info")
}
// Create response
response := MeResponse{
ID: self.ID,
FirstName: self.FirstName,
LastName: self.LastName,
Username: self.Username,
}
// Convert response to JSON
jsonData, err := json.Marshal(response)
if err != nil {
return errors.Wrap(err, "failed to marshal response")
}
toolResponse = mcp.NewToolResponse(mcp.NewTextContent(string(jsonData)))
return nil
}); err != nil {
return nil, errors.Wrap(err, "invalid session")
}
return toolResponse, nil
}

79
main.go Normal file
View file

@ -0,0 +1,79 @@
package main
import (
"context"
"os"
"path/filepath"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
)
const (
dir = ".telegram-mcp"
)
func main() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
homeDir, err := os.UserHomeDir()
if err != nil {
log.Fatal().Err(err).Msg("Failed to get home dir")
}
configDir := filepath.Join(homeDir, dir)
sesionPath := filepath.Join(configDir, "session.json")
app := &cli.Command{
Name: "telegram-mcp",
Usage: "Telegram MCP server",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "app-id",
Usage: "Telegram App ID",
Required: true,
Sources: cli.EnvVars("TG_APP_ID"),
},
&cli.StringFlag{
Name: "api-hash",
Usage: "Telegram API Hash",
Required: true,
Sources: cli.EnvVars("TG_API_HASH"),
},
&cli.StringFlag{
Name: "session",
Usage: "Path to session file",
Value: sesionPath,
Sources: cli.EnvVars("TG_SESSION_PATH"),
},
&cli.BoolFlag{
Name: "dry",
Usage: "Test configuration",
Local: true,
HideDefault: true,
},
},
Commands: []*cli.Command{
{
Name: "auth",
Usage: "Authenticate with Telegram",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "phone",
Usage: "Phone number to authenticate with",
Required: true,
Aliases: []string{"p"},
},
},
Action: authCommand,
},
},
Action: serve,
}
if err := app.Run(context.Background(), os.Args); err != nil {
log.Fatal().Msg(err.Error())
}
}

58
serve.go Normal file
View file

@ -0,0 +1,58 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"telegram-mcp/internal/tg"
mcp "github.com/metoro-io/mcp-golang"
"github.com/metoro-io/mcp-golang/transport/stdio"
"github.com/rs/zerolog/log"
"github.com/urfave/cli/v3"
)
func serve(ctx context.Context, cmd *cli.Command) error {
appID := cmd.Int("app-id")
appHash := cmd.String("api-hash")
sessionPath := cmd.String("session")
dryRun := cmd.Bool("dry")
_, err := os.Stat(sessionPath)
if err != nil {
return fmt.Errorf("session file not found(%s): %w", sessionPath, err)
}
server := mcp.NewServer(stdio.NewStdioServerTransport())
client := tg.New(int(appID), appHash, sessionPath)
if dryRun {
answer, err := client.GetMe(tg.EmptyArguments{})
if err != nil {
return fmt.Errorf("get user: %w", err)
}
data, err := json.MarshalIndent(answer, "", " ")
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
log.Info().RawJSON("answer", data).Msg("Check GetMe: OK")
return nil
}
err = server.RegisterTool("me", "Get current Telegram account info", client.GetMe)
if err != nil {
return fmt.Errorf("register tool: %w", err)
}
if err := server.Serve(); err != nil {
return fmt.Errorf("serve: %w", err)
}
<-ctx.Done()
return nil
}