From 6909ea476c9f48c3356b5d013b60d335cfc45545 Mon Sep 17 00:00:00 2001 From: Alexander Trakhimenok Date: Sat, 25 Jul 2026 12:16:07 +0100 Subject: [PATCH] feat: add BotHostWithHTTPClient and TelegramRedirectTransport Production BotHost() hardcodes http.DefaultClient, so nothing can redirect a bot's outbound Bot API calls. The only redirect implementation lived test-only in a Sneat consumer repo. Promote that pattern to exported, reusable code here: BotHostWithHTTPClient(client) is an additive constructor alongside BotHost() (which keeps returning http.DefaultClient exactly as before), and TelegramRedirectTransport is an http.RoundTripper that rewrites api.telegram.org requests to a caller-supplied base URL and rejects every other destination, closing the network boundary rather than opening a general-purpose proxy. This lets a caller (e.g. a dev/test host wired to a Chatwright Telegram Platform Emulator) redirect outbound Telegram traffic without touching bots-api-telegram's hardcoded APIEndpoint constant, which all production Sneat bots depend on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SkkrXdtf8mU2GRo2hHsHT1 Signed-off-by: Alexander Trakhimenok --- host.go | 64 +++++++++-- telegram_redirect_transport.go | 65 +++++++++++ telegram_redirect_transport_test.go | 161 ++++++++++++++++++++++++++++ 3 files changed, 281 insertions(+), 9 deletions(-) create mode 100644 telegram_redirect_transport.go create mode 100644 telegram_redirect_transport_test.go diff --git a/host.go b/host.go index 5fc9a29..9ab65b1 100644 --- a/host.go +++ b/host.go @@ -6,14 +6,11 @@ import ( "net/http" ) -// botHost represent information on current hosting platform -type botHost struct { -} - -//var _ botsfw.BotHost = (*botHost)(nil) - -// BotHost returns hosting platform settings & information -func BotHost() interface { +// botHostInterface is the shape returned by BotHost and BotHostWithHTTPClient. +// It intentionally mirrors github.com/bots-go-framework/bots-fw's botsfw.BotHost +// interface without importing that module, so this package keeps its single +// real dependency (google.golang.org/appengine/v2). +type botHostInterface interface { // Context returns a context.Context for a request. // We need this as some platforms (as Google App Engine Standard) @@ -23,7 +20,19 @@ func BotHost() interface { // GetHTTPClient returns HTTP client for current host // We need this as some platforms (as Google App Engine Standard) require setting http client in a specific way. GetHTTPClient(c context.Context) *http.Client -} { +} + +// botHost represent information on current hosting platform +type botHost struct { +} + +//var _ botsfw.BotHost = (*botHost)(nil) + +// BotHost returns hosting platform settings & information. Its GetHTTPClient +// always returns http.DefaultClient; use BotHostWithHTTPClient when a caller +// needs to control outbound HTTP (e.g. to redirect Bot API calls to a +// Chatwright Telegram Platform Emulator in a non-production environment). +func BotHost() botHostInterface { return botHost{} } @@ -46,6 +55,43 @@ func (h botHost) GetHTTPClient(c context.Context) *http.Client { //} } +// botHostWithClient is a botHostInterface whose GetHTTPClient returns a +// caller-supplied *http.Client instead of http.DefaultClient. Context() +// behaves identically to botHost's. +type botHostWithClient struct { + client *http.Client +} + +// BotHostWithHTTPClient returns hosting platform settings & information whose +// GetHTTPClient returns client instead of http.DefaultClient. This is the +// seam a caller uses to redirect a bot's outbound HTTP calls — for example, +// installing a TelegramRedirectTransport to point Bot API traffic at a +// Chatwright emulator instead of https://api.telegram.org — without changing +// anything else about how the host behaves. It does not alter BotHost(), +// which keeps returning http.DefaultClient exactly as before. +// +// client must not be nil; callers that do not need a custom client should +// call BotHost() instead. +func BotHostWithHTTPClient(client *http.Client) botHostInterface { + if client == nil { + panic("bots-host-gae: client == nil") + } + return botHostWithClient{client: client} +} + +// Context creates context for http.Request, identically to botHost.Context. +func (h botHostWithClient) Context(r *http.Request) context.Context { + return appengine.NewContext(r) +} + +// GetHTTPClient returns the *http.Client supplied to BotHostWithHTTPClient. +func (h botHostWithClient) GetHTTPClient(c context.Context) *http.Client { + if c == nil { + panic("c == nil") + } + return h.client +} + //var DbProvider = func(c context.Context) (db dal.DB, err error) { // panic("gae.DbProvider is not set") // //return dalgo2datastore.NewDatabase(c, "") diff --git a/telegram_redirect_transport.go b/telegram_redirect_transport.go new file mode 100644 index 0000000..e33786c --- /dev/null +++ b/telegram_redirect_transport.go @@ -0,0 +1,65 @@ +package gae + +import ( + "errors" + "fmt" + "net/http" + "net/url" +) + +// TelegramAPIHost is the hostname of the real Telegram Bot API +// (github.com/bots-go-framework/bots-api-telegram's tgbotapi.APIEndpoint is +// hardcoded to this host — it has no configurable base URL). It is exported +// so callers can recognize which requests TelegramRedirectTransport rewrites. +const TelegramAPIHost = "api.telegram.org" + +// TelegramRedirectTransport is an http.RoundTripper that rewrites requests +// bound for TelegramAPIHost to a caller-supplied base URL and rejects every +// other destination. It exists to let a non-production host (e.g. one wired +// to a Chatwright Telegram Platform Emulator) redirect a bot's outbound Bot +// API calls without turning the process's HTTP client into a general-purpose +// proxy: refusing any request whose host is not TelegramAPIHost is a closed +// network boundary, not an oversight. +// +// Construct one with NewTelegramRedirectTransport and install it on an +// *http.Client passed to BotHostWithHTTPClient. +type TelegramRedirectTransport struct { + baseURL *url.URL + base http.RoundTripper +} + +// NewTelegramRedirectTransport returns a TelegramRedirectTransport that +// rewrites the scheme and host of any request whose destination host is +// TelegramAPIHost to baseURL, preserving the path, query and body. Requests +// to any other host are rejected with an error rather than forwarded. +// +// baseURL must be non-nil and specify a scheme and host (e.g. +// "http://127.0.0.1:4000"). base is the underlying RoundTripper used to +// perform the rewritten request; if nil, http.DefaultTransport is used. +func NewTelegramRedirectTransport(baseURL *url.URL, base http.RoundTripper) (*TelegramRedirectTransport, error) { + if baseURL == nil { + return nil, errors.New("bots-host-gae: baseURL is nil") + } + if baseURL.Scheme == "" || baseURL.Host == "" { + return nil, fmt.Errorf("bots-host-gae: baseURL %q must have a scheme and a host", baseURL.String()) + } + if base == nil { + base = http.DefaultTransport + } + return &TelegramRedirectTransport{baseURL: baseURL, base: base}, nil +} + +// RoundTrip implements http.RoundTripper. It rejects any request whose host +// is not TelegramAPIHost and otherwise forwards the request, rewritten to +// target the configured baseURL, to the underlying RoundTripper. +func (t *TelegramRedirectTransport) RoundTrip(request *http.Request) (*http.Response, error) { + if request.URL.Host != TelegramAPIHost { + return nil, fmt.Errorf("bots-host-gae: TelegramRedirectTransport rejected unexpected HTTP destination: %s", request.URL.Host) + } + clonedRequest := request.Clone(request.Context()) + clonedURL := *request.URL + clonedURL.Scheme = t.baseURL.Scheme + clonedURL.Host = t.baseURL.Host + clonedRequest.URL = &clonedURL + return t.base.RoundTrip(clonedRequest) +} diff --git a/telegram_redirect_transport_test.go b/telegram_redirect_transport_test.go new file mode 100644 index 0000000..f9791ff --- /dev/null +++ b/telegram_redirect_transport_test.go @@ -0,0 +1,161 @@ +package gae + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestNewTelegramRedirectTransport_nilBaseURL(t *testing.T) { + if _, err := NewTelegramRedirectTransport(nil, nil); err == nil { + t.Error("expected an error for a nil baseURL") + } +} + +func TestNewTelegramRedirectTransport_incompleteBaseURL(t *testing.T) { + incomplete, err := url.Parse("/no-host-or-scheme") + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + if _, err = NewTelegramRedirectTransport(incomplete, nil); err == nil { + t.Error("expected an error for a baseURL without a scheme and host") + } +} + +func TestNewTelegramRedirectTransport_defaultsBaseTransport(t *testing.T) { + baseURL, err := url.Parse("http://127.0.0.1:1") + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + transport, err := NewTelegramRedirectTransport(baseURL, nil) + if err != nil { + t.Fatalf("NewTelegramRedirectTransport: %v", err) + } + if transport.base == nil { + t.Error("expected a default base RoundTripper when base is nil") + } +} + +func TestTelegramRedirectTransport_rejectsNonTelegramHost(t *testing.T) { + baseURL, err := url.Parse("http://127.0.0.1:1") + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + transport, err := NewTelegramRedirectTransport(baseURL, http.DefaultTransport) + if err != nil { + t.Fatalf("NewTelegramRedirectTransport: %v", err) + } + request, err := http.NewRequest(http.MethodGet, "https://evil.example.com/steal", nil) + if err != nil { + t.Fatalf("http.NewRequest: %v", err) + } + if _, err = transport.RoundTrip(request); err == nil { + t.Fatal("expected RoundTrip to reject a non-Telegram destination") + } +} + +func TestTelegramRedirectTransport_rewritesTelegramHost(t *testing.T) { + var gotPath, gotBody string + var served bool + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + served = true + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer fake.Close() + + baseURL, err := url.Parse(fake.URL) + if err != nil { + t.Fatalf("url.Parse: %v", err) + } + transport, err := NewTelegramRedirectTransport(baseURL, nil) + if err != nil { + t.Fatalf("NewTelegramRedirectTransport: %v", err) + } + + request, err := http.NewRequest( + http.MethodPost, + "https://"+TelegramAPIHost+"/botTOKEN/sendMessage", + strings.NewReader(`{"chat_id":1}`), + ) + if err != nil { + t.Fatalf("http.NewRequest: %v", err) + } + response, err := transport.RoundTrip(request) + if err != nil { + t.Fatalf("RoundTrip: %v", err) + } + defer func() { _ = response.Body.Close() }() + + if response.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", response.StatusCode, http.StatusOK) + } + if gotPath != "/botTOKEN/sendMessage" { + t.Errorf("path = %q, want /botTOKEN/sendMessage", gotPath) + } + if gotBody != `{"chat_id":1}` { + t.Errorf("body = %q, want the original request body", gotBody) + } + if !served { + t.Fatal("request never reached the fake server — it was not redirected") + } + + // The original request must be left untouched (RoundTrip clones it). + if request.URL.Host != TelegramAPIHost { + t.Errorf("original request was mutated: Host = %q, want %q", request.URL.Host, TelegramAPIHost) + } +} + +func TestBotHostWithHTTPClient_nilClientPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected BotHostWithHTTPClient(nil) to panic") + } + }() + BotHostWithHTTPClient(nil) +} + +func TestBotHostWithHTTPClient_returnsSuppliedClient(t *testing.T) { + client := &http.Client{} + host := BotHostWithHTTPClient(client) + if got := host.GetHTTPClient(context.Background()); got != client { + t.Errorf("GetHTTPClient() = %p, want the supplied client %p", got, client) + } +} + +func TestBotHostWithHTTPClient_GetHTTPClient_nilContextPanics(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("expected GetHTTPClient(nil) to panic") + } + }() + host := BotHostWithHTTPClient(&http.Client{}) + var ctx context.Context + host.GetHTTPClient(ctx) +} + +func TestBotHostWithHTTPClient_Context(t *testing.T) { + host := BotHostWithHTTPClient(&http.Client{}) + r := &http.Request{} + if ctx := host.Context(r); ctx == nil { + t.Error("Context() returns nil") + } +} + +// TestBotHost_unaffectedByBotHostWithHTTPClient guards the non-breaking +// requirement: BotHost() must keep returning http.DefaultClient regardless of +// BotHostWithHTTPClient's existence. +func TestBotHost_unaffectedByBotHostWithHTTPClient(t *testing.T) { + _ = BotHostWithHTTPClient(&http.Client{}) + host := BotHost() + if got := host.GetHTTPClient(context.Background()); got != http.DefaultClient { + t.Errorf("BotHost().GetHTTPClient() = %p, want http.DefaultClient %p", got, http.DefaultClient) + } +}