Initial commit: FunStat MCP Server Go implementation
- Telegram integration for customer statistics - MCP server implementation with rate limiting - Cache system for performance optimization - Multi-language support - RESTful API endpoints 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Build output
|
||||
/bin/
|
||||
/dist/
|
||||
/build/
|
||||
108
README.md
Normal file
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Funstat MCP (Go)
|
||||
|
||||
This project is a complete Go reimplementation of the original Funstat MCP
|
||||
server. It exposes the same MCP tools wrapped around the Telegram
|
||||
`@openaiw_bot`, but with a native Go runtime and HTTP transport.
|
||||
|
||||
## Features
|
||||
|
||||
- Streamable HTTP transport (`/sse` + `/messages`) compatible with MCP clients
|
||||
such as Codex CLI, Cursor, and Claude Code.
|
||||
- Full parity with the original nine Funstat tools (search, topchat, text,
|
||||
human lookup, user info, user messages, balance, menu, start).
|
||||
- Built-in rate limiting and response caching to stay within Telegram limits.
|
||||
- Telegram session bootstrapping from a Telethon string session or persisted Go
|
||||
session storage.
|
||||
- Configurable proxy, cache TTL, and rate limit settings via environment
|
||||
variables.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Go 1.22 or newer (the module was developed with Go 1.24).
|
||||
- A valid Telegram API ID and API hash.
|
||||
- A logged-in Telegram session. The Go server expects either:
|
||||
- `TELEGRAM_SESSION_STRING` (or `TELEGRAM_SESSION_STRING_FILE`) containing a
|
||||
Telethon *StringSession*, which will be converted automatically into Go
|
||||
session storage, **or**
|
||||
- `TELEGRAM_SESSION_PATH` pointing to a Go session file created by this
|
||||
service on a previous run (`~/.funstatmcp/session.json` by default).
|
||||
- Network access to Telegram (configure proxies via the `FUNSTAT_PROXY_*`
|
||||
variables if necessary).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `TELEGRAM_API_ID` | **Required.** Telegram API ID. |
|
||||
| `TELEGRAM_API_HASH` | **Required.** Telegram API hash. |
|
||||
| `TELEGRAM_SESSION_STRING` | Base64 Telethon StringSession (optional if session file already exists). |
|
||||
| `TELEGRAM_SESSION_STRING_FILE` | Path to a file containing the StringSession. |
|
||||
| `TELEGRAM_SESSION_PATH` | Optional path for Go session storage (`.session` suffix is appended if missing). |
|
||||
| `FUNSTAT_BOT_USERNAME` | Bot username (default `@openaiw_bot`). |
|
||||
| `FUNSTAT_RATE_LIMIT_PER_SECOND` | Requests per second (default `18`). |
|
||||
| `FUNSTAT_RATE_LIMIT_WINDOW` | Duration window, e.g. `1s` (default `1s`). |
|
||||
| `FUNSTAT_CACHE_TTL` | Cache lifetime for command responses (default `1h`). |
|
||||
| `FUNSTAT_PROXY_TYPE` | Proxy type (`socks5` supported). |
|
||||
| `FUNSTAT_PROXY_HOST` | Proxy host. |
|
||||
| `FUNSTAT_PROXY_PORT` | Proxy port. |
|
||||
| `FUNSTAT_PROXY_USERNAME` / `FUNSTAT_PROXY_PASSWORD` | Optional proxy credentials. |
|
||||
| `FUNSTAT_HOST` | Bind host (default `127.0.0.1`). |
|
||||
| `FUNSTAT_PORT` | Bind port (default `8091`). |
|
||||
| `FUNSTAT_REQUIRE_SESSION` | When `true`, enables strict session ID enforcement (not yet required by the Go transport). |
|
||||
|
||||
## Running the Server
|
||||
|
||||
```bash
|
||||
export TELEGRAM_API_ID=...
|
||||
export TELEGRAM_API_HASH=...
|
||||
export TELEGRAM_SESSION_STRING=... # or TELEGRAM_SESSION_STRING_FILE
|
||||
|
||||
go run ./cmd/funstat-mcp
|
||||
```
|
||||
|
||||
The server starts on `http://127.0.0.1:8091` by default with:
|
||||
|
||||
- `GET /sse` — server-sent events stream for MCP clients.
|
||||
- `POST /messages` — JSON-RPC 2.0 endpoint for MCP requests.
|
||||
- `GET /health` — simple health probe.
|
||||
|
||||
For convenience a helper script is available:
|
||||
|
||||
```bash
|
||||
./scripts/start_server.sh
|
||||
```
|
||||
|
||||
## Tool Coverage
|
||||
|
||||
The server exposes the following MCP tools (identical to the Python version):
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `funstat_search` | 搜索 Telegram 群组/频道。 |
|
||||
| `funstat_topchat` | 获取热门群组列表。 |
|
||||
| `funstat_text` | 按文本搜索消息。 |
|
||||
| `funstat_human` | 按姓名搜索用户。 |
|
||||
| `funstat_user_info` | 查询用户详情。 |
|
||||
| `funstat_user_messages` | 抓取用户聊天记录并自动翻页。 |
|
||||
| `funstat_balance` | 查询积分余额。 |
|
||||
| `funstat_menu` | 显示 Funstat 菜单。 |
|
||||
| `funstat_start` | Funstat 欢迎信息。 |
|
||||
|
||||
## Session Notes
|
||||
|
||||
- If you already possess the Telethon `.session` SQLite file, generate a
|
||||
StringSession first (e.g. via Telethon or `python -m telethon.sessions`)
|
||||
and provide it through `TELEGRAM_SESSION_STRING`.
|
||||
- After the first successful run the Go server stores an encrypted Go session
|
||||
at `~/.funstatmcp/session.json` (unless overridden). Subsequent runs only
|
||||
require the API ID/hash.
|
||||
|
||||
## Development
|
||||
|
||||
- Format code with `gofmt -w cmd internal`.
|
||||
- Run `go test ./...` (no automated tests are included yet).
|
||||
- Dependencies are managed via Go modules (`go mod tidy`).
|
||||
|
||||
## License
|
||||
|
||||
Follows the license of the original Funstat MCP project (MIT, if applicable).
|
||||
32
cmd/funstat-mcp/main.go
Normal file
32
cmd/funstat-mcp/main.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"funstatmcp/internal/app"
|
||||
"funstatmcp/internal/transport"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := app.FromEnv()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
application, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("initialize app: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
server := transport.NewServer(application, cfg)
|
||||
|
||||
if err := server.Run(ctx); err != nil {
|
||||
log.Fatalf("server exited with error: %v", err)
|
||||
}
|
||||
}
|
||||
43
go.mod
Normal file
43
go.mod
Normal file
@@ -0,0 +1,43 @@
|
||||
module funstatmcp
|
||||
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/gotd/td v0.132.0
|
||||
golang.org/x/net v0.46.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // 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/klauspost/compress v1.18.0 // 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.15.2 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.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.43.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230725093048-515e97ebf090 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
)
|
||||
98
go.sum
Normal file
98
go.sum
Normal file
@@ -0,0 +1,98 @@
|
||||
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.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/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/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.132.0 h1:Iqm3S2b+8kDgA9237IDXRxj7sryUpvy+4Cr50/0tpx4=
|
||||
github.com/gotd/td v0.132.0/go.mod h1:4CDGYS+rDtOqotRheGaF9MS5g6jaUewvSXqBNJnx8SQ=
|
||||
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/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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ogen-go/ogen v1.15.2 h1:Hy5XNcDgWur758Kf0+DTQFN8cyBOs58EjDD3NMqih54=
|
||||
github.com/ogen-go/ogen v1.15.2/go.mod h1:bS+BP2cV7+IGjOM24znBmh+PrpZvYFXA7o3BNF4Hj2E=
|
||||
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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
|
||||
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
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=
|
||||
153
internal/app/app.go
Normal file
153
internal/app/app.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tgclient "funstatmcp/internal/telegram"
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
client *tgclient.Client
|
||||
}
|
||||
|
||||
func New(cfg Config) (*App, error) {
|
||||
client, err := tgclient.New(cfg.Telegram)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &App{
|
||||
cfg: cfg,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SendCommand(ctx context.Context, command string, useCache bool) (string, error) {
|
||||
return a.client.SendCommand(ctx, command, useCache)
|
||||
}
|
||||
|
||||
func (a *App) CallTool(ctx context.Context, name string, args map[string]any) (string, error) {
|
||||
switch name {
|
||||
case "funstat_search":
|
||||
query, err := requireString(args, "query")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.client.SendCommand(ctx, fmt.Sprintf("/search %s", query), true)
|
||||
|
||||
case "funstat_topchat":
|
||||
category := optionalString(args, "category")
|
||||
if category != "" {
|
||||
return a.client.SendCommand(ctx, fmt.Sprintf("/topchat %s", category), true)
|
||||
}
|
||||
return a.client.SendCommand(ctx, "/topchat", true)
|
||||
|
||||
case "funstat_text":
|
||||
text, err := requireString(args, "text")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.client.SendCommand(ctx, fmt.Sprintf("/text %s", text), true)
|
||||
|
||||
case "funstat_human":
|
||||
nameArg, err := requireString(args, "name")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.client.SendCommand(ctx, fmt.Sprintf("/human %s", nameArg), true)
|
||||
|
||||
case "funstat_user_info":
|
||||
identifier, err := requireString(args, "identifier")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
identifier = strings.TrimSpace(identifier)
|
||||
if identifier == "" {
|
||||
return "", fmt.Errorf("identifier cannot be empty")
|
||||
}
|
||||
return a.client.SendCommand(ctx, fmt.Sprintf("/user_info %s", identifier), true)
|
||||
|
||||
case "funstat_user_messages":
|
||||
identifier, err := requireString(args, "identifier")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var maxPagesPtr *int
|
||||
if value, ok := args["max_pages"]; ok {
|
||||
v, err := toInt(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("max_pages must be an integer: %w", err)
|
||||
}
|
||||
maxPagesPtr = &v
|
||||
}
|
||||
return a.client.FetchUserMessages(ctx, identifier, maxPagesPtr)
|
||||
|
||||
case "funstat_balance":
|
||||
return a.client.SendCommand(ctx, "/balance", true)
|
||||
|
||||
case "funstat_menu":
|
||||
return a.client.SendCommand(ctx, "/menu", true)
|
||||
|
||||
case "funstat_start":
|
||||
return a.client.SendCommand(ctx, "/start", true)
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func requireString(args map[string]any, key string) (string, error) {
|
||||
value, ok := args[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing required argument: %s", key)
|
||||
}
|
||||
str, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("argument %s must be a string", key)
|
||||
}
|
||||
str = strings.TrimSpace(str)
|
||||
if str == "" {
|
||||
return "", fmt.Errorf("argument %s cannot be empty", key)
|
||||
}
|
||||
return str, nil
|
||||
}
|
||||
|
||||
func optionalString(args map[string]any, key string) string {
|
||||
if value, ok := args[key]; ok {
|
||||
if str, ok := value.(string); ok {
|
||||
return strings.TrimSpace(str)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func toInt(value any) (int, error) {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return int(v), nil
|
||||
case float32:
|
||||
return int(v), nil
|
||||
case int:
|
||||
return v, nil
|
||||
case int32:
|
||||
return int(v), nil
|
||||
case int64:
|
||||
return int(v), nil
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return parsed, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported type %T", value)
|
||||
}
|
||||
}
|
||||
158
internal/app/config.go
Normal file
158
internal/app/config.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgconfig "funstatmcp/internal/telegram"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Telegram tgconfig.Config
|
||||
Host string
|
||||
Port int
|
||||
RequireSession bool
|
||||
}
|
||||
|
||||
func FromEnv() (Config, error) {
|
||||
var cfg Config
|
||||
|
||||
telegramCfg, err := loadTelegramConfig()
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
cfg.Telegram = telegramCfg
|
||||
cfg.Host = getEnvDefault("FUNSTAT_HOST", "127.0.0.1")
|
||||
|
||||
portStr := getEnvDefault("FUNSTAT_PORT", "8091")
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("invalid FUNSTAT_PORT: %w", err)
|
||||
}
|
||||
cfg.Port = port
|
||||
|
||||
cfg.RequireSession = parseBool(getEnvDefault("FUNSTAT_REQUIRE_SESSION", "false"))
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadTelegramConfig() (tgconfig.Config, error) {
|
||||
var cfg tgconfig.Config
|
||||
|
||||
apiIDStr := os.Getenv("TELEGRAM_API_ID")
|
||||
if apiIDStr == "" {
|
||||
return cfg, fmt.Errorf("TELEGRAM_API_ID is required")
|
||||
}
|
||||
apiID, err := strconv.Atoi(apiIDStr)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("invalid TELEGRAM_API_ID: %w", err)
|
||||
}
|
||||
cfg.APIID = apiID
|
||||
|
||||
cfg.APIHash = strings.TrimSpace(os.Getenv("TELEGRAM_API_HASH"))
|
||||
if cfg.APIHash == "" {
|
||||
return cfg, fmt.Errorf("TELEGRAM_API_HASH is required")
|
||||
}
|
||||
|
||||
cfg.BotUsername = getEnvDefault("FUNSTAT_BOT_USERNAME", "@openaiw_bot")
|
||||
cfg.SessionString = strings.TrimSpace(os.Getenv("TELEGRAM_SESSION_STRING"))
|
||||
sessionStringFile := strings.TrimSpace(os.Getenv("TELEGRAM_SESSION_STRING_FILE"))
|
||||
if cfg.SessionString == "" && sessionStringFile != "" {
|
||||
data, err := os.ReadFile(expandPath(sessionStringFile))
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("read TELEGRAM_SESSION_STRING_FILE: %w", err)
|
||||
}
|
||||
cfg.SessionString = strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
sessionPath := strings.TrimSpace(os.Getenv("TELEGRAM_SESSION_PATH"))
|
||||
if sessionPath != "" {
|
||||
if !strings.HasSuffix(sessionPath, ".session") {
|
||||
sessionPath = sessionPath + ".session"
|
||||
}
|
||||
cfg.SessionStorage = expandPath(sessionPath)
|
||||
} else {
|
||||
cfg.SessionStorage = defaultSessionPath()
|
||||
}
|
||||
|
||||
if value := strings.TrimSpace(os.Getenv("FUNSTAT_RATE_LIMIT_PER_SECOND")); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("invalid FUNSTAT_RATE_LIMIT_PER_SECOND: %w", err)
|
||||
}
|
||||
cfg.RateLimit = parsed
|
||||
}
|
||||
|
||||
if value := strings.TrimSpace(os.Getenv("FUNSTAT_RATE_LIMIT_WINDOW")); value != "" {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("invalid FUNSTAT_RATE_LIMIT_WINDOW: %w", err)
|
||||
}
|
||||
cfg.RateLimitWindow = duration
|
||||
}
|
||||
|
||||
if value := strings.TrimSpace(os.Getenv("FUNSTAT_CACHE_TTL")); value != "" {
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("invalid FUNSTAT_CACHE_TTL: %w", err)
|
||||
}
|
||||
cfg.CacheTTL = duration
|
||||
}
|
||||
|
||||
proxyHost := strings.TrimSpace(os.Getenv("FUNSTAT_PROXY_HOST"))
|
||||
proxyPort := strings.TrimSpace(os.Getenv("FUNSTAT_PROXY_PORT"))
|
||||
if proxyHost != "" && proxyPort != "" {
|
||||
port, err := strconv.Atoi(proxyPort)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("invalid FUNSTAT_PROXY_PORT: %w", err)
|
||||
}
|
||||
cfg.Proxy = &tgconfig.ProxyConfig{
|
||||
Type: getEnvDefault("FUNSTAT_PROXY_TYPE", "socks5"),
|
||||
Host: proxyHost,
|
||||
Port: port,
|
||||
Username: strings.TrimSpace(os.Getenv("FUNSTAT_PROXY_USERNAME")),
|
||||
Password: strings.TrimSpace(os.Getenv("FUNSTAT_PROXY_PASSWORD")),
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnvDefault(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseBool(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSessionPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return filepath.Join(os.TempDir(), "funstatmcp", "session.json")
|
||||
}
|
||||
return filepath.Join(home, ".funstatmcp", "session.json")
|
||||
}
|
||||
|
||||
func expandPath(path string) string {
|
||||
if strings.HasPrefix(path, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err == nil {
|
||||
return filepath.Join(home, strings.TrimPrefix(path, "~"))
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
124
internal/app/tools.go
Normal file
124
internal/app/tools.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package app
|
||||
|
||||
type ToolDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema map[string]any `json:"inputSchema"`
|
||||
}
|
||||
|
||||
func ToolDefinitions() []ToolDefinition {
|
||||
return []ToolDefinition{
|
||||
{
|
||||
Name: "funstat_search",
|
||||
Description: "搜索 Telegram 群组、频道。支持关键词搜索,返回相关的群组列表",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "搜索关键词,例如: 'python', '区块链', 'AI'",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_topchat",
|
||||
Description: "获取热门群组/频道列表,按成员数或活跃度排序",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"category": map[string]any{
|
||||
"type": "string",
|
||||
"description": "分类筛选(可选),例如: 'tech', 'crypto', 'news'",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_text",
|
||||
Description: "通过消息文本搜索,查找包含特定文本的消息和来源群组",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{
|
||||
"type": "string",
|
||||
"description": "要搜索的文本内容",
|
||||
},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_human",
|
||||
Description: "通过姓名搜索,查找包含特定用户的群组和消息",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"description": "用户姓名",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_user_info",
|
||||
Description: "查询用户详细信息,支持通过用户名、用户ID、联系人等方式查询",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"identifier": map[string]any{
|
||||
"type": "string",
|
||||
"description": "用户标识: 用户名(@username)、用户ID、或手机号",
|
||||
},
|
||||
},
|
||||
"required": []string{"identifier"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_user_messages",
|
||||
Description: "获取指定用户的历史消息列表,并自动翻页汇总",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"identifier": map[string]any{
|
||||
"type": "string",
|
||||
"description": "用户标识: 用户名(@username) 或用户ID",
|
||||
},
|
||||
"max_pages": map[string]any{
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "可选,限制抓取的最大页数",
|
||||
},
|
||||
},
|
||||
"required": []string{"identifier"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_balance",
|
||||
Description: "查询当前账号的积分余额和使用统计",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_menu",
|
||||
Description: "显示 funstat BOT 的主菜单和所有可用功能",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "funstat_start",
|
||||
Description: "获取 funstat BOT 的欢迎信息和使用说明",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
70
internal/cache/cache.go
vendored
Normal file
70
internal/cache/cache.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type entry struct {
|
||||
value string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
ttl time.Duration
|
||||
mu sync.RWMutex
|
||||
values map[string]entry
|
||||
}
|
||||
|
||||
func New(ttl time.Duration) *Cache {
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
return &Cache{
|
||||
ttl: ttl,
|
||||
values: make(map[string]entry),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Get(key string) (string, bool) {
|
||||
c.mu.RLock()
|
||||
e, ok := c.values[key]
|
||||
c.mu.RUnlock()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if time.Now().After(e.expires) {
|
||||
c.mu.Lock()
|
||||
delete(c.values, key)
|
||||
c.mu.Unlock()
|
||||
return "", false
|
||||
}
|
||||
return e.value, true
|
||||
}
|
||||
|
||||
func (c *Cache) Set(key string, value string) {
|
||||
c.mu.Lock()
|
||||
c.values[key] = entry{
|
||||
value: value,
|
||||
expires: time.Now().Add(c.ttl),
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *Cache) ClearExpired() int {
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
removed := 0
|
||||
for k, v := range c.values {
|
||||
if now.After(v.expires) {
|
||||
delete(c.values, k)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return removed
|
||||
}
|
||||
|
||||
func (c *Cache) TTL() time.Duration {
|
||||
return c.ttl
|
||||
}
|
||||
71
internal/ratelimit/limiter.go
Normal file
71
internal/ratelimit/limiter.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Limiter struct {
|
||||
maxRequests int
|
||||
window time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
timestamps []time.Time
|
||||
}
|
||||
|
||||
func New(maxRequests int, window time.Duration) *Limiter {
|
||||
if maxRequests <= 0 {
|
||||
maxRequests = 1
|
||||
}
|
||||
if window <= 0 {
|
||||
window = time.Second
|
||||
}
|
||||
|
||||
return &Limiter{
|
||||
maxRequests: maxRequests,
|
||||
window: window,
|
||||
timestamps: make([]time.Time, 0, maxRequests),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) Wait(ctx context.Context) error {
|
||||
for {
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
|
||||
cutoff := now.Add(-l.window)
|
||||
idx := 0
|
||||
for ; idx < len(l.timestamps); idx++ {
|
||||
if l.timestamps[idx].After(cutoff) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if idx > 0 {
|
||||
l.timestamps = append([]time.Time(nil), l.timestamps[idx:]...)
|
||||
}
|
||||
|
||||
if len(l.timestamps) < l.maxRequests {
|
||||
l.timestamps = append(l.timestamps, now)
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
waitUntil := l.timestamps[0].Add(l.window)
|
||||
waitDuration := time.Until(waitUntil)
|
||||
l.mu.Unlock()
|
||||
|
||||
if waitDuration <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
timer := time.NewTimer(waitDuration)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
71
internal/telegram/buttons.go
Normal file
71
internal/telegram/buttons.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func findCallbackButton(message *tg.Message, keyword string) (*tg.KeyboardButtonCallback, error) {
|
||||
markup, ok := message.ReplyMarkup.(*tg.ReplyInlineMarkup)
|
||||
if !ok || len(markup.Rows) == 0 {
|
||||
return nil, fmt.Errorf("message has no interactive buttons")
|
||||
}
|
||||
|
||||
normalizedKeyword := strings.ToLower(normalizeButtonText(keyword))
|
||||
available := make([]string, 0)
|
||||
|
||||
for _, row := range markup.Rows {
|
||||
for _, button := range row.Buttons {
|
||||
callback, ok := button.(*tg.KeyboardButtonCallback)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
text := callback.Text
|
||||
normalized := strings.ToLower(normalizeButtonText(text))
|
||||
available = append(available, normalizeButtonText(text))
|
||||
|
||||
if strings.Contains(normalized, normalizedKeyword) {
|
||||
return callback, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("button containing '%s' not found (available: %s)", keyword, strings.Join(available, ", "))
|
||||
}
|
||||
|
||||
func extractTotalPages(message *tg.Message) int {
|
||||
markup, ok := message.ReplyMarkup.(*tg.ReplyInlineMarkup)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
for _, row := range markup.Rows {
|
||||
for _, button := range row.Buttons {
|
||||
callback, ok := button.(*tg.KeyboardButtonCallback)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(callback.Text, "⏭") {
|
||||
digits := strings.Builder{}
|
||||
for _, r := range normalizeButtonText(callback.Text) {
|
||||
if unicode.IsDigit(r) {
|
||||
digits.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if digits.Len() > 0 {
|
||||
if value, err := strconv.Atoi(digits.String()); err == nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
497
internal/telegram/client.go
Normal file
497
internal/telegram/client.go
Normal file
@@ -0,0 +1,497 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"github.com/gotd/td/session"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/telegram/message"
|
||||
"github.com/gotd/td/tg"
|
||||
|
||||
"funstatmcp/internal/cache"
|
||||
"funstatmcp/internal/ratelimit"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg Config
|
||||
limiter *ratelimit.Limiter
|
||||
cache *cache.Cache
|
||||
sessionPath string
|
||||
sessionOnce sync.Once
|
||||
}
|
||||
|
||||
func New(cfg Config) (*Client, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionPath := cfg.SessionStorage
|
||||
if sessionPath == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get home dir: %w", err)
|
||||
}
|
||||
sessionPath = filepath.Join(home, ".funstatmcp", "session.json")
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create session directory: %w", err)
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
cfg: cfg,
|
||||
limiter: ratelimit.New(cfg.RateLimitPerSecond(), cfg.RateLimitDuration()),
|
||||
cache: cache.New(cfg.CacheDuration()),
|
||||
sessionPath: sessionPath,
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.SessionString) != "" {
|
||||
if err := client.writeStringSession(strings.TrimSpace(cfg.SessionString)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *Client) writeStringSession(sessionStr string) error {
|
||||
var result error
|
||||
c.sessionOnce.Do(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
data, err := session.TelethonSession(sessionStr)
|
||||
if err != nil {
|
||||
result = fmt.Errorf("decode telethon session: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
loader := session.Loader{Storage: &session.FileStorage{Path: c.sessionPath}}
|
||||
if err := loader.Save(ctx, data); err != nil {
|
||||
result = fmt.Errorf("save session: %w", err)
|
||||
return
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func (c *Client) createOptions() (telegram.Options, error) {
|
||||
opts := telegram.Options{
|
||||
SessionStorage: &session.FileStorage{Path: c.sessionPath},
|
||||
NoUpdates: true,
|
||||
}
|
||||
|
||||
proxyCfg := c.cfg.Proxy
|
||||
if proxyCfg != nil && proxyCfg.Host != "" && proxyCfg.Port > 0 {
|
||||
address := fmt.Sprintf("%s:%d", proxyCfg.Host, proxyCfg.Port)
|
||||
switch strings.ToLower(proxyCfg.Type) {
|
||||
case "", "socks5", "socks":
|
||||
var auth *proxy.Auth
|
||||
if proxyCfg.Username != "" {
|
||||
auth = &proxy.Auth{User: proxyCfg.Username, Password: proxyCfg.Password}
|
||||
}
|
||||
dialer, err := proxy.SOCKS5("tcp", address, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
return opts, fmt.Errorf("create SOCKS5 proxy: %w", err)
|
||||
}
|
||||
contextDialer, ok := dialer.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
contextDialer = &contextDialerAdapter{Dialer: dialer}
|
||||
}
|
||||
opts.Resolver = dcs.Plain(dcs.PlainOptions{Dial: contextDialer.DialContext})
|
||||
default:
|
||||
return opts, fmt.Errorf("unsupported proxy type %q", proxyCfg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
type contextDialerAdapter struct {
|
||||
Dialer proxy.Dialer
|
||||
}
|
||||
|
||||
func (a *contextDialerAdapter) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
type dialResult struct {
|
||||
conn net.Conn
|
||||
err error
|
||||
}
|
||||
|
||||
result := make(chan dialResult, 1)
|
||||
go func() {
|
||||
conn, err := a.Dialer.Dial(network, addr)
|
||||
result <- dialResult{conn: conn, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case res := <-result:
|
||||
return res.conn, res.err
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) withClient(ctx context.Context, fn func(ctx context.Context, api *tg.Client, sender *message.Sender) error) error {
|
||||
opts, err := c.createOptions()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := telegram.NewClient(c.cfg.APIID, c.cfg.APIHash, opts)
|
||||
|
||||
return client.Run(ctx, func(runCtx context.Context) error {
|
||||
if err := c.ensureAuthorized(runCtx, client); err != nil {
|
||||
return err
|
||||
}
|
||||
raw := tg.NewClient(client)
|
||||
sender := message.NewSender(raw)
|
||||
return fn(runCtx, raw, sender)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) withPeer(ctx context.Context, fn func(ctx context.Context, api *tg.Client, sender *message.Sender, peer tg.InputPeerClass, botID int64) error) error {
|
||||
return c.withClient(ctx, func(runCtx context.Context, api *tg.Client, sender *message.Sender) error {
|
||||
peer, botID, err := c.resolvePeer(runCtx, sender)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fn(runCtx, api, sender, peer, botID)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) ensureAuthorized(ctx context.Context, client *telegram.Client) error {
|
||||
status, err := client.Auth().Status(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("check auth status: %w", err)
|
||||
}
|
||||
if !status.Authorized {
|
||||
return errors.New("telegram session is not authorized; provide TELEGRAM_SESSION_STRING")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) resolvePeer(ctx context.Context, sender *message.Sender) (tg.InputPeerClass, int64, error) {
|
||||
builder := sender.Resolve(c.cfg.BotUsername)
|
||||
peer, err := builder.AsInputPeer(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("resolve bot peer: %w", err)
|
||||
}
|
||||
|
||||
inputUser, err := builder.AsInputUser(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("resolve bot user: %w", err)
|
||||
}
|
||||
|
||||
return peer, inputUser.UserID, nil
|
||||
}
|
||||
|
||||
func (c *Client) latestIncomingMessageID(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, botID int64) (int, error) {
|
||||
resp, err := api.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
||||
Peer: peer,
|
||||
Limit: 5,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
messages, err := extractMessages(resp)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
last := 0
|
||||
for _, msg := range messages {
|
||||
if isFromBot(msg, botID) && msg.ID > last {
|
||||
last = msg.ID
|
||||
}
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
|
||||
func (c *Client) waitForMessage(ctx context.Context, api *tg.Client, peer tg.InputPeerClass, botID int64, lastID int, timeout time.Duration) (*tg.Message, error) {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := api.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
|
||||
Peer: peer,
|
||||
Limit: 5,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages, err := extractMessages(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.ID > lastID && isFromBot(msg, botID) {
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("timeout waiting for bot response")
|
||||
}
|
||||
|
||||
if err := sleepWithContext(ctx, 500*time.Millisecond); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendCommand(ctx context.Context, command string, useCache bool) (string, error) {
|
||||
cacheKey := fmt.Sprintf("cmd:%s", command)
|
||||
if useCache {
|
||||
if cached, ok := c.cache.Get(cacheKey); ok {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var response string
|
||||
err := c.withPeer(ctx, func(runCtx context.Context, api *tg.Client, sender *message.Sender, peer tg.InputPeerClass, botID int64) error {
|
||||
lastID, err := c.latestIncomingMessageID(runCtx, api, peer, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := sender.Resolve(c.cfg.BotUsername).Text(runCtx, command); err != nil {
|
||||
return fmt.Errorf("send command: %w", err)
|
||||
}
|
||||
|
||||
msg, err := c.waitForMessage(runCtx, api, peer, botID, lastID, 15*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
response = strings.TrimSpace(msg.Message)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if useCache {
|
||||
c.cache.Set(cacheKey, response)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) SendCommandMessage(ctx context.Context, command string, timeout time.Duration) (*tg.Message, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result *tg.Message
|
||||
err := c.withPeer(ctx, func(runCtx context.Context, api *tg.Client, sender *message.Sender, peer tg.InputPeerClass, botID int64) error {
|
||||
lastID, err := c.latestIncomingMessageID(runCtx, api, peer, botID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := sender.Resolve(c.cfg.BotUsername).Text(runCtx, command); err != nil {
|
||||
return fmt.Errorf("send command: %w", err)
|
||||
}
|
||||
|
||||
msg, err := c.waitForMessage(runCtx, api, peer, botID, lastID, timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result = msg
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) PressButton(ctx context.Context, msg *tg.Message, keyword string) (*tg.Message, error) {
|
||||
if msg == nil {
|
||||
return nil, errors.New("message cannot be nil")
|
||||
}
|
||||
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizedKeyword := strings.ToLower(keyword)
|
||||
|
||||
var updated *tg.Message
|
||||
err := c.withPeer(ctx, func(runCtx context.Context, api *tg.Client, sender *message.Sender, peer tg.InputPeerClass, botID int64) error {
|
||||
button, err := findCallbackButton(msg, normalizedKeyword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req := &tg.MessagesGetBotCallbackAnswerRequest{
|
||||
Peer: peer,
|
||||
MsgID: msg.ID,
|
||||
}
|
||||
if len(button.Data) > 0 {
|
||||
req.SetData(button.Data)
|
||||
}
|
||||
|
||||
invoke := func() error {
|
||||
_, err := api.MessagesGetBotCallbackAnswer(runCtx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := invoke(); err != nil {
|
||||
if wait, ok := telegram.AsFloodWait(err); ok {
|
||||
if err := sleepWithContext(runCtx, wait+time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := invoke(); err != nil {
|
||||
return fmt.Errorf("callback retry failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("press callback button: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := sleepWithContext(runCtx, 1200*time.Millisecond); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := api.MessagesGetMessages(runCtx, []tg.InputMessageClass{
|
||||
&tg.InputMessageID{ID: msg.ID},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch updated message: %w", err)
|
||||
}
|
||||
|
||||
refreshed, err := extractMessageByID(resp, msg.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
updated = refreshed
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchUserMessages(ctx context.Context, identifier string, maxPages *int) (string, error) {
|
||||
id := strings.TrimSpace(identifier)
|
||||
if id == "" {
|
||||
return "", errors.New("identifier cannot be empty")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(id, "/") {
|
||||
if !strings.HasPrefix(id, "@") && !isNumericIdentifier(id) {
|
||||
id = "@" + id
|
||||
}
|
||||
}
|
||||
|
||||
command := id
|
||||
if !strings.HasPrefix(command, "/") {
|
||||
command = fmt.Sprintf("/user_info %s", id)
|
||||
}
|
||||
|
||||
base, err := c.SendCommandMessage(ctx, command, 20*time.Second)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stage, err := c.PressButton(ctx, base, "messages")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
current, err := c.PressButton(ctx, stage, "all")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pages := make([]string, 0)
|
||||
seen := map[string]struct{}{}
|
||||
currentPage := 1
|
||||
totalPages := extractTotalPages(current)
|
||||
|
||||
var limit int
|
||||
if maxPages != nil {
|
||||
if *maxPages <= 0 {
|
||||
return "", errors.New("maxPages must be greater than zero")
|
||||
}
|
||||
limit = *maxPages
|
||||
}
|
||||
|
||||
for {
|
||||
text := strings.TrimSpace(current.Message)
|
||||
if text != "" {
|
||||
if _, ok := seen[text]; !ok {
|
||||
header := fmt.Sprintf("第 %d 页", currentPage)
|
||||
if totalPages > 0 {
|
||||
header = fmt.Sprintf("%s/%d", header, totalPages)
|
||||
}
|
||||
entry := strings.Join([]string{header, "", text}, "\n")
|
||||
pages = append(pages, entry)
|
||||
seen[text] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
if limit > 0 && currentPage >= limit {
|
||||
break
|
||||
}
|
||||
|
||||
updated, err := c.PressButton(ctx, current, "➡")
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
newText := strings.TrimSpace(updated.Message)
|
||||
if _, ok := seen[newText]; ok {
|
||||
break
|
||||
}
|
||||
|
||||
current = updated
|
||||
currentPage++
|
||||
}
|
||||
|
||||
if len(pages) == 0 {
|
||||
return fmt.Sprintf("未找到 %s 的消息记录。", identifier), nil
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("共收集 %d 页消息", len(pages))
|
||||
if totalPages > 0 {
|
||||
summary = fmt.Sprintf("%s(存在 %d 页)", summary, totalPages)
|
||||
}
|
||||
|
||||
result := append([]string{summary, ""}, pages...)
|
||||
return strings.Join(result, "\n\n"), nil
|
||||
}
|
||||
|
||||
func isNumericIdentifier(value string) bool {
|
||||
for _, r := range value {
|
||||
if r != '+' && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value != ""
|
||||
}
|
||||
60
internal/telegram/config.go
Normal file
60
internal/telegram/config.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProxyConfig struct {
|
||||
Type string
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
APIID int
|
||||
APIHash string
|
||||
BotUsername string
|
||||
SessionString string
|
||||
SessionStorage string
|
||||
RateLimit int
|
||||
RateLimitWindow time.Duration
|
||||
CacheTTL time.Duration
|
||||
Proxy *ProxyConfig
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.APIID == 0 {
|
||||
return fmt.Errorf("APIID must be provided")
|
||||
}
|
||||
if c.APIHash == "" {
|
||||
return fmt.Errorf("APIHash must be provided")
|
||||
}
|
||||
if c.BotUsername == "" {
|
||||
return fmt.Errorf("BotUsername must be provided")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) RateLimitPerSecond() int {
|
||||
if c.RateLimit <= 0 {
|
||||
return 18
|
||||
}
|
||||
return c.RateLimit
|
||||
}
|
||||
|
||||
func (c Config) RateLimitDuration() time.Duration {
|
||||
if c.RateLimitWindow <= 0 {
|
||||
return time.Second
|
||||
}
|
||||
return c.RateLimitWindow
|
||||
}
|
||||
|
||||
func (c Config) CacheDuration() time.Duration {
|
||||
if c.CacheTTL <= 0 {
|
||||
return time.Hour
|
||||
}
|
||||
return c.CacheTTL
|
||||
}
|
||||
63
internal/telegram/messages.go
Normal file
63
internal/telegram/messages.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gotd/td/tg"
|
||||
)
|
||||
|
||||
func extractMessages(resp tg.MessagesMessagesClass) ([]*tg.Message, error) {
|
||||
switch v := resp.(type) {
|
||||
case *tg.MessagesMessages:
|
||||
return filterMessages(v.Messages), nil
|
||||
case *tg.MessagesMessagesSlice:
|
||||
return filterMessages(v.Messages), nil
|
||||
case *tg.MessagesChannelMessages:
|
||||
return filterMessages(v.Messages), nil
|
||||
case *tg.MessagesMessagesNotModified:
|
||||
return nil, fmt.Errorf("messages not modified")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported response type %T", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func filterMessages(values []tg.MessageClass) []*tg.Message {
|
||||
result := make([]*tg.Message, 0, len(values))
|
||||
for _, m := range values {
|
||||
if msg, ok := m.(*tg.Message); ok {
|
||||
result = append(result, msg)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func extractMessageByID(resp tg.MessagesMessagesClass, id int) (*tg.Message, error) {
|
||||
messages, err := extractMessages(resp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, msg := range messages {
|
||||
if msg.ID == id {
|
||||
return msg, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("message %d not found", id)
|
||||
}
|
||||
|
||||
func isFromBot(msg *tg.Message, botID int64) bool {
|
||||
if msg == nil || msg.Out {
|
||||
return false
|
||||
}
|
||||
|
||||
if peer, ok := msg.GetPeerID().(*tg.PeerUser); ok && peer.UserID == botID {
|
||||
return true
|
||||
}
|
||||
|
||||
if fromClass, ok := msg.GetFromID(); ok {
|
||||
if from, ok := fromClass.(*tg.PeerUser); ok && from.UserID == botID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
45
internal/telegram/normalize.go
Normal file
45
internal/telegram/normalize.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package telegram
|
||||
|
||||
import "strings"
|
||||
|
||||
var buttonTextTranslations = map[rune]rune{
|
||||
'ƒ': 'f',
|
||||
'Μ': 'M',
|
||||
'τ': 't',
|
||||
'ѕ': 's',
|
||||
'η': 'n',
|
||||
'Ғ': 'F',
|
||||
'α': 'a',
|
||||
'ο': 'o',
|
||||
'ᴜ': 'u',
|
||||
'о': 'o',
|
||||
'е': 'e',
|
||||
'с': 'c',
|
||||
'℮': 'e',
|
||||
'Τ': 'T',
|
||||
'ρ': 'p',
|
||||
'Δ': 'D',
|
||||
'χ': 'x',
|
||||
'β': 'b',
|
||||
'λ': 'l',
|
||||
'γ': 'y',
|
||||
'Ν': 'N',
|
||||
'μ': 'm',
|
||||
'ψ': 'y',
|
||||
'Α': 'A',
|
||||
'Ρ': 'P',
|
||||
'С': 'C',
|
||||
'ё': 'e',
|
||||
'ł': 'l',
|
||||
'Ł': 'L',
|
||||
'ց': 'g',
|
||||
}
|
||||
|
||||
func normalizeButtonText(text string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if mapped, ok := buttonTextTranslations[r]; ok {
|
||||
return mapped
|
||||
}
|
||||
return r
|
||||
}, text)
|
||||
}
|
||||
22
internal/telegram/util.go
Normal file
22
internal/telegram/util.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) error {
|
||||
if d <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
277
internal/transport/server.go
Normal file
277
internal/transport/server.go
Normal file
@@ -0,0 +1,277 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"funstatmcp/internal/app"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
app *app.App
|
||||
config app.Config
|
||||
|
||||
subscribersMu sync.Mutex
|
||||
subscribers map[int]chan []byte
|
||||
nextID int
|
||||
}
|
||||
|
||||
func NewServer(appInstance *app.App, cfg app.Config) *Server {
|
||||
return &Server{
|
||||
app: appInstance,
|
||||
config: cfg,
|
||||
subscribers: make(map[int]chan []byte),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/sse", s.handleSSE)
|
||||
mux.HandleFunc("/messages", s.handleMessages)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", s.config.Host, s.config.Port),
|
||||
Handler: s.corsMiddleware(mux),
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Printf("HTTP server shutdown error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Printf("Funstat MCP Go server listening on http://%s:%d", s.config.Host, s.config.Port)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-MCP-Session-ID")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"status": "ok",
|
||||
"server": "funstat-mcp-go",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
subscriber := make(chan []byte, 16)
|
||||
id := s.addSubscriber(subscriber)
|
||||
defer s.removeSubscriber(id)
|
||||
|
||||
heartbeatTicker := time.NewTicker(15 * time.Second)
|
||||
defer heartbeatTicker.Stop()
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-heartbeatTicker.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
case data := <-subscriber:
|
||||
fmt.Fprintf(w, "event: message\n")
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) addSubscriber(ch chan []byte) int {
|
||||
s.subscribersMu.Lock()
|
||||
defer s.subscribersMu.Unlock()
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subscribers[id] = ch
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Server) removeSubscriber(id int) {
|
||||
s.subscribersMu.Lock()
|
||||
defer s.subscribersMu.Unlock()
|
||||
delete(s.subscribers, id)
|
||||
}
|
||||
|
||||
func (s *Server) broadcast(payload []byte) {
|
||||
s.subscribersMu.Lock()
|
||||
defer s.subscribersMu.Unlock()
|
||||
for _, ch := range s.subscribers {
|
||||
select {
|
||||
case ch <- payload:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type jsonRPCRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID any `json:"id"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error *jsonRPCError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type jsonRPCError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var request jsonRPCRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("invalid JSON: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
response := s.handleRequest(r.Context(), request)
|
||||
|
||||
var buffer bytes.Buffer
|
||||
if err := json.NewEncoder(&buffer).Encode(response); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := bytes.TrimSpace(buffer.Bytes())
|
||||
s.broadcast(payload)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(payload)
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(ctx context.Context, req jsonRPCRequest) jsonRPCResponse {
|
||||
response := jsonRPCResponse{
|
||||
JSONRPC: "2.0",
|
||||
ID: req.ID,
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.JSONRPC) != "2.0" {
|
||||
response.Error = &jsonRPCError{Code: -32600, Message: "invalid jsonrpc version"}
|
||||
return response
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
response.Result = s.initializeResult()
|
||||
case "list_tools":
|
||||
response.Result = map[string]any{
|
||||
"tools": app.ToolDefinitions(),
|
||||
}
|
||||
case "call_tool":
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
if err := json.Unmarshal(req.Params, &payload); err != nil {
|
||||
response.Error = &jsonRPCError{Code: -32602, Message: "invalid params"}
|
||||
return response
|
||||
}
|
||||
|
||||
if payload.Arguments == nil {
|
||||
payload.Arguments = make(map[string]any)
|
||||
}
|
||||
|
||||
result, err := s.app.CallTool(ctx, payload.Name, payload.Arguments)
|
||||
if err != nil {
|
||||
response.Result = map[string]any{
|
||||
"content": []map[string]string{
|
||||
{
|
||||
"type": "text",
|
||||
"text": fmt.Sprintf("❌ 错误: %s", err.Error()),
|
||||
},
|
||||
},
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
response.Result = map[string]any{
|
||||
"content": []map[string]string{
|
||||
{
|
||||
"type": "text",
|
||||
"text": result,
|
||||
},
|
||||
},
|
||||
}
|
||||
default:
|
||||
response.Error = &jsonRPCError{Code: -32601, Message: "method not found"}
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
func (s *Server) initializeResult() map[string]any {
|
||||
return map[string]any{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"capabilities": map[string]any{
|
||||
"tools": map[string]any{},
|
||||
},
|
||||
"serverInfo": map[string]any{
|
||||
"name": "funstat-mcp-go",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
10
scripts/start_server.sh
Executable file
10
scripts/start_server.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "Starting Funstat MCP Go server..."
|
||||
go run ./cmd/funstat-mcp "$@"
|
||||
Reference in New Issue
Block a user