From 42e446cea0024ec0b01fd39b1e1f19b66c74e0f9 Mon Sep 17 00:00:00 2001 From: John Doe Date: Tue, 1 Apr 2025 17:48:16 +0300 Subject: [PATCH] Initial --- .gitignore | 72 +++++++++++ .golangci.yaml | 129 +++++++++++++++++++ Taskfile.yml | 40 ++++++ auth.go | 62 ++++++++++ cmd/test/main.go | 79 ++++++++++++ cmd/test/unread.go | 280 ++++++++++++++++++++++++++++++++++++++++++ go.mod | 59 +++++++++ go.sum | 139 +++++++++++++++++++++ internal/tg/auth.go | 63 ++++++++++ internal/tg/client.go | 20 +++ internal/tg/me.go | 51 ++++++++ main.go | 79 ++++++++++++ serve.go | 58 +++++++++ 13 files changed, 1131 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yaml create mode 100644 Taskfile.yml create mode 100644 auth.go create mode 100644 cmd/test/main.go create mode 100644 cmd/test/unread.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/tg/auth.go create mode 100644 internal/tg/client.go create mode 100644 internal/tg/me.go create mode 100644 main.go create mode 100644 serve.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c7174c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..f03b29d --- /dev/null +++ b/.golangci.yaml @@ -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] diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..d62d965 --- /dev/null +++ b/Taskfile.yml @@ -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 ./... + + diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..7129e6d --- /dev/null +++ b/auth.go @@ -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 +} diff --git a/cmd/test/main.go b/cmd/test/main.go new file mode 100644 index 0000000..20e3643 --- /dev/null +++ b/cmd/test/main.go @@ -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") + } +} diff --git a/cmd/test/unread.go b/cmd/test/unread.go new file mode 100644 index 0000000..06eb5e6 --- /dev/null +++ b/cmd/test/unread.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d25591 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..95b5ca6 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/tg/auth.go b/internal/tg/auth.go new file mode 100644 index 0000000..5909c55 --- /dev/null +++ b/internal/tg/auth.go @@ -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 +} diff --git a/internal/tg/client.go b/internal/tg/client.go new file mode 100644 index 0000000..3ed86b9 --- /dev/null +++ b/internal/tg/client.go @@ -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, + } +} diff --git a/internal/tg/me.go b/internal/tg/me.go new file mode 100644 index 0000000..cb05228 --- /dev/null +++ b/internal/tg/me.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..227a905 --- /dev/null +++ b/main.go @@ -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()) + } +} diff --git a/serve.go b/serve.go new file mode 100644 index 0000000..babfdad --- /dev/null +++ b/serve.go @@ -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 +}