From d3d114443cc4e924719f77b4f48bbc400ae45e38 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 19:29:00 +0800 Subject: [PATCH 01/12] docs: define Telegram live E2E example --- README.md | 6 ++++ docs/docs/index.md | 2 ++ docs/docs/telegram.md | 17 +++++++++ example/telegram-e2e/README.md | 65 ++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 example/telegram-e2e/README.md diff --git a/README.md b/README.md index 59de3a8..d38a240 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ - [ ] 实现 Runner Event 到文本、流式消息和卡片消息的转换 - [ ] 接入企业微信或微信相关通道 - [x] 接入 Telegram long polling 文本通道(Issue #31;单 Binding、Gateway Dispatch、进程内幂等) +- [x] 增加真实 Telegram live E2E 示例与手动 CI workflow(Issue #33;根目录 `example/telegram-e2e`) - [ ] 接入 Telegram webhook、媒体/rich update 或其他 IM 通道 - [ ] 实现 webhook 验签、账号与租户绑定、用户身份映射 - [ ] 使用 `tenant + channel + message_id` 实现幂等去重和缓存回复 @@ -232,6 +233,9 @@ - Issue #31 的 `trpcservice/channels/telegram` 提供单 Binding、`getMe` 身份校验、普通文本 long polling、Gateway Dispatch、进程内幂等和脱敏分段回复;具体边界以 `docs/docs/telegram.md` 为准。 +- Issue #33 的 `example/telegram-e2e` 使用真实 Telegram Bot API 和确定性 Dispatcher 验证 + `getMe -> getUpdates -> sendMessage`;live workflow 只手动触发并使用受保护 Environment, + 不替代完整模型供应商或生产控制面 E2E。 - Issue #26 的 fake candidate resolver/verifier 与 proof-bearing routing 边界有独立测试, 但这不代表 WeCom/Telegram webhook、媒体能力或持久化消息能力已满足 README 原验收要求。 @@ -253,6 +257,8 @@ | |-- start.sh # 启动服务 | `-- stop.sh # 停止服务 |-- data # 服务运行时数据 +|-- example # 可运行的外部集成示例 +| `-- telegram-e2e # Telegram live long-polling E2E |-- docs # 各模块说明与架构设计文档 |-- cmd | `-- trpc-service # 命令行入口,可直接启动服务 diff --git a/docs/docs/index.md b/docs/docs/index.md index 6017a9b..15f927e 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -22,6 +22,8 @@ 限流、幂等和服务生命周期契约。 - [Telegram 长轮询 Adapter](telegram.md):Issue #31 的文档先行契约,固定单 Binding、Bot 身份校验、普通文本映射、Dispatch 聚合回复和生命周期边界。 +- [Telegram live E2E 示例](https://github.com/XnLemon/trpc-agent-service/tree/main/example/telegram-e2e): + Issue #33 的真实 Bot API 传输冒烟测试和手动 CI 运行说明。 ## 快速开始 diff --git a/docs/docs/telegram.md b/docs/docs/telegram.md index 548e4c2..c2bf37f 100644 --- a/docs/docs/telegram.md +++ b/docs/docs/telegram.md @@ -148,3 +148,20 @@ README 和 MkDocs 状态应明确区分已交付与后续能力: 参考:[Telegram Bot API](https://core.telegram.org/bots/api)、 [getUpdates](https://core.telegram.org/bots/api#getting-updates)、 [github.com/go-telegram/bot](https://github.com/go-telegram/bot)。 + +## 7. 真实 Telegram E2E + +Issue #33 提供根目录 `example/telegram-e2e/` 示例和手动触发的 CI 工作流, +用于验证真实的 `getMe -> getUpdates -> sendMessage` 边界。示例内部使用确定性 +`DispatchService`,因此不会把模型供应商凭据和 Telegram 传输冒烟测试混在一起。 + +本地运行只需要在进程环境中提供 `TELEGRAM_BOT_TOKEN`;Token 不得进入仓库、日志、 +trace 或错误。CI 使用受保护的 `telegram-e2e` Environment,至少配置接收 Bot 的 +`TELEGRAM_BOT_TOKEN`,并在需要完全自动化入站消息时配置第二个受控测试 Bot 的 +`TELEGRAM_SENDER_BOT_TOKEN`。一个 Bot Token 不能模拟普通用户向自己发送入站消息, +所以 CI 必须显式配置发送者或采用人工/外部触发方案。 + +示例和 CI 都只验证普通文本;命令、媒体、rich update、Webhook、持久化 outbox 和 +生产模型供应商仍不属于该 E2E 范围。详见 +[Telegram live E2E example](https://github.com/XnLemon/trpc-agent-service/tree/main/example/telegram-e2e) +和 Issue #33。 diff --git a/example/telegram-e2e/README.md b/example/telegram-e2e/README.md new file mode 100644 index 0000000..0a23e1b --- /dev/null +++ b/example/telegram-e2e/README.md @@ -0,0 +1,65 @@ +# Telegram live E2E example + +This example exercises the real Telegram Bot API boundary around the +tenant-scoped long-polling adapter: + +```text +getMe -> getUpdates -> trusted Telegram Adapter -> DispatchService -> sendMessage +``` + +It deliberately uses a deterministic `DispatchService`, so this example tests +Telegram transport, trusted target construction, update normalization, reply +delivery, cancellation, and secret handling without requiring a production +LLM provider. + +## Local run + +Create a dedicated test Bot with `@BotFather`, revoke any token that has been +shared outside a secret store, and set the replacement token in the process +environment. Do not place it in this repository or print it. + +PowerShell: + +```powershell +$env:TELEGRAM_BOT_TOKEN = '' +go run ./example/telegram-e2e +``` + +The command prints a unique ordinary-text marker. Open the receiver Bot in +Telegram, send that marker, and confirm the `telegram-e2e-ok` reply. Commands, +media, and rich updates are intentionally outside this first E2E. Press +`Ctrl+C` to stop the local polling process cleanly. + +If the Bot has a webhook, either remove it before starting long polling or set +`TELEGRAM_DELETE_WEBHOOK=true`. Pending updates are preserved by default; set +`TELEGRAM_DROP_PENDING_UPDATES=true` only when discarding them is intentional. + +Optional local settings: + +| Variable | Meaning | Default | +| --- | --- | --- | +| `TELEGRAM_TEST_MESSAGE` | Exact marker to wait for | generated per run | +| `TELEGRAM_TIMEOUT` | Maximum run duration | `2m` | +| `TELEGRAM_POLL_TIMEOUT` | Telegram long-poll timeout | `5s` | +| `TELEGRAM_DELETE_WEBHOOK` | Delete an existing webhook | `false` | +| `TELEGRAM_DROP_PENDING_UPDATES` | Drop queued updates when deleting webhook | `false` | + +## CI run + +The live workflow is intentionally manual and references a protected GitHub +Environment named `telegram-e2e`: + +- `TELEGRAM_BOT_TOKEN`: secret for the receiving test Bot. +- `TELEGRAM_SENDER_BOT_TOKEN`: optional secret for a second controlled test Bot + that sends the unique marker and receives the expected reply. + +For a fully automatic message round trip, enable Telegram Bot-to-Bot +Communication Mode for both dedicated test Bots. A single Bot API token cannot +act as a normal user sending an inbound message to itself. Without the sender +secret, the example remains suitable for a local human-driven run but the CI +job will eventually time out waiting for the marker. + +The workflow uses one concurrency group so two runs cannot poll the same test +Bot at the same time. It must not be changed to run automatically on arbitrary +pull requests: live Bot credentials and external Telegram side effects are +intentionally outside the offline PR checks. From cce3e6671ad56aa4d7f9b3471d1c3a795ea26b11 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 19:29:13 +0800 Subject: [PATCH 02/12] test: add Telegram live E2E example and workflow --- .github/workflows/telegram-e2e.yml | 57 ++++ example/telegram-e2e/main.go | 445 +++++++++++++++++++++++++++++ example/telegram-e2e/main_test.go | 162 +++++++++++ 3 files changed, 664 insertions(+) create mode 100644 .github/workflows/telegram-e2e.yml create mode 100644 example/telegram-e2e/main.go create mode 100644 example/telegram-e2e/main_test.go diff --git a/.github/workflows/telegram-e2e.yml b/.github/workflows/telegram-e2e.yml new file mode 100644 index 0000000..30a172b --- /dev/null +++ b/.github/workflows/telegram-e2e.yml @@ -0,0 +1,57 @@ +name: Telegram Live E2E + +on: + workflow_dispatch: + inputs: + delete_webhook: + description: Delete an existing webhook before polling + required: true + type: boolean + default: false + drop_pending_updates: + description: Drop queued updates when deleting the webhook + required: true + type: boolean + default: false + +concurrency: + group: telegram-live-e2e + cancel-in-progress: false + +permissions: + contents: read + +jobs: + telegram-e2e: + name: Telegram live long-polling E2E + runs-on: ubuntu-latest + environment: telegram-e2e + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate live E2E secrets + shell: bash + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_SENDER_BOT_TOKEN: ${{ secrets.TELEGRAM_SENDER_BOT_TOKEN }} + run: | + test -n "$TELEGRAM_BOT_TOKEN" || { echo "::error::telegram-e2e Environment is missing TELEGRAM_BOT_TOKEN"; exit 1; } + test -n "$TELEGRAM_SENDER_BOT_TOKEN" || { echo "::error::telegram-e2e Environment is missing TELEGRAM_SENDER_BOT_TOKEN"; exit 1; } + + - name: Run live Telegram E2E + run: go run ./example/telegram-e2e + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_SENDER_BOT_TOKEN: ${{ secrets.TELEGRAM_SENDER_BOT_TOKEN }} + TELEGRAM_DELETE_WEBHOOK: ${{ inputs.delete_webhook }} + TELEGRAM_DROP_PENDING_UPDATES: ${{ inputs.drop_pending_updates }} + TELEGRAM_TEST_MESSAGE: telegram-e2e-${{ github.run_id }}-${{ github.run_attempt }} + TELEGRAM_TIMEOUT: 90s + TELEGRAM_POLL_TIMEOUT: 5s diff --git a/example/telegram-e2e/main.go b/example/telegram-e2e/main.go new file mode 100644 index 0000000..9efe9bd --- /dev/null +++ b/example/telegram-e2e/main.go @@ -0,0 +1,445 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/signal" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/XnLemon/trpc-agent-service/trpcservice/agent" + "github.com/XnLemon/trpc-agent-service/trpcservice/channels" + channelsinmemory "github.com/XnLemon/trpc-agent-service/trpcservice/channels/inmemory" + "github.com/XnLemon/trpc-agent-service/trpcservice/channels/telegram" + "github.com/XnLemon/trpc-agent-service/trpcservice/gateway" + "github.com/XnLemon/trpc-agent-service/trpcservice/tenant" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +const ( + defaultRunTimeout = 2 * time.Minute + defaultPollTimeout = 5 * time.Second + shutdownTimeout = 5 * time.Second + e2eReply = "telegram-e2e-ok" +) + +var ( + errConfiguration = errors.New("invalid Telegram E2E configuration") + errPreflight = errors.New("Telegram E2E preflight failed") + errWebhookConfigured = errors.New("Telegram webhook is configured; remove it or enable TELEGRAM_DELETE_WEBHOOK") + errAdapterRun = errors.New("Telegram E2E adapter stopped unexpectedly") + errAdapterClose = errors.New("Telegram E2E adapter close failed") + errRunTimeout = errors.New("Telegram E2E timed out waiting for the test message") + errSender = errors.New("Telegram E2E sender failed") + errSenderStopped = errors.New("Telegram E2E sender stopped unexpectedly") +) + +type runConfig struct { + botToken string + senderBotToken string + testMessage string + runTimeout time.Duration + pollTimeout time.Duration + deleteWebhook bool + dropPendingUpdate bool +} + +type webhookClient interface { + GetWebhookInfo(context.Context) (*models.WebhookInfo, error) + DeleteWebhook(context.Context, *bot.DeleteWebhookParams) (bool, error) +} + +type deterministicDispatcher struct { + marker string + seen chan gateway.InboundMessage + once sync.Once +} + +var _ gateway.DispatchService = (*deterministicDispatcher)(nil) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, os.Getenv, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writer) error { + if ctx == nil || lookup == nil || stdout == nil || stderr == nil { + return errConfiguration + } + configuration, err := loadConfig(lookup) + if err != nil { + return err + } + + receiver, err := prepareBot(ctx, configuration.botToken, configuration.deleteWebhook, configuration.dropPendingUpdate) + if err != nil { + return err + } + target, err := newTrustedTarget(strconv.FormatInt(receiver.ID, 10)) + if err != nil { + return errConfiguration + } + dispatcher := newDeterministicDispatcher(configuration.testMessage) + adapter, err := telegramAdapter(ctx, configuration, target, dispatcher, stderr) + if err != nil { + return err + } + + runContext, cancel := context.WithTimeout(ctx, configuration.runTimeout) + runDone := make(chan error, 1) + go func() { + runDone <- adapter.Run(runContext) + }() + + _, _ = fmt.Fprintf(stdout, "Telegram E2E receiver @%s (%d) is listening.\n", receiver.Username, receiver.ID) + _, _ = fmt.Fprintf(stdout, "Send this ordinary text: %s\n", configuration.testMessage) + + var result error + if configuration.senderBotToken != "" { + result = runAutomatedSender(runContext, configuration.senderBotToken, receiver, configuration.testMessage, configuration.deleteWebhook, configuration.dropPendingUpdate) + cancel() + if stopErr := waitForAdapter(runDone); stopErr != nil && result == nil { + result = stopErr + } + } else { + select { + case err := <-runDone: + if err != nil { + result = errAdapterRun + } else { + result = errAdapterRun + } + case <-runContext.Done(): + if stopErr := waitForAdapter(runDone); stopErr != nil { + result = stopErr + } else if ctx.Err() == nil { + result = errRunTimeout + } + } + } + cancel() + if err := adapter.Close(); err != nil && result == nil { + result = errAdapterClose + } + return result +} + +func loadConfig(lookup func(string) string) (runConfig, error) { + if lookup == nil { + return runConfig{}, errConfiguration + } + botToken := strings.TrimSpace(lookup("TELEGRAM_BOT_TOKEN")) + if botToken == "" || hasControl(botToken) { + return runConfig{}, errConfiguration + } + senderToken := strings.TrimSpace(lookup("TELEGRAM_SENDER_BOT_TOKEN")) + if hasControl(senderToken) || (senderToken != "" && senderToken == botToken) { + return runConfig{}, errConfiguration + } + message := strings.TrimSpace(lookup("TELEGRAM_TEST_MESSAGE")) + if message == "" { + message = fmt.Sprintf("telegram-e2e-%d", time.Now().UTC().UnixNano()) + } + if hasControl(message) || len([]rune(message)) > 4096 || strings.Contains(message, botToken) || (senderToken != "" && strings.Contains(message, senderToken)) { + return runConfig{}, errConfiguration + } + runTimeout, err := readDuration(lookup, "TELEGRAM_TIMEOUT", defaultRunTimeout) + if err != nil || runTimeout <= 0 { + return runConfig{}, errConfiguration + } + pollTimeout, err := readDuration(lookup, "TELEGRAM_POLL_TIMEOUT", defaultPollTimeout) + if err != nil || pollTimeout < 2*time.Second || pollTimeout > 10*time.Minute { + return runConfig{}, errConfiguration + } + deleteWebhook, err := readBool(lookup, "TELEGRAM_DELETE_WEBHOOK", false) + if err != nil { + return runConfig{}, errConfiguration + } + dropPending, err := readBool(lookup, "TELEGRAM_DROP_PENDING_UPDATES", false) + if err != nil { + return runConfig{}, errConfiguration + } + return runConfig{ + botToken: botToken, senderBotToken: senderToken, testMessage: message, + runTimeout: runTimeout, pollTimeout: pollTimeout, + deleteWebhook: deleteWebhook, dropPendingUpdate: dropPending, + }, nil +} + +func readDuration(lookup func(string) string, name string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(lookup(name)) + if value == "" { + return fallback, nil + } + return time.ParseDuration(value) +} + +func readBool(lookup func(string) string, name string, fallback bool) (bool, error) { + value := strings.TrimSpace(lookup(name)) + if value == "" { + return fallback, nil + } + return strconv.ParseBool(value) +} + +func prepareBot(ctx context.Context, token string, deleteWebhook, dropPending bool) (*models.User, error) { + client, err := bot.New(token, bot.WithSkipGetMe()) + if err != nil { + return nil, errPreflight + } + me, err := client.GetMe(ctx) + if err != nil || me == nil || !me.IsBot || me.ID <= 0 { + return nil, errPreflight + } + if err := prepareLongPolling(ctx, client, deleteWebhook, dropPending); err != nil { + return nil, err + } + return me, nil +} + +func prepareLongPolling(ctx context.Context, client webhookClient, deleteWebhook, dropPending bool) error { + if client == nil { + return errPreflight + } + info, err := client.GetWebhookInfo(ctx) + if err != nil { + return errPreflight + } + if info == nil || info.URL == "" { + return nil + } + if !deleteWebhook { + return errWebhookConfigured + } + if _, err := client.DeleteWebhook(ctx, &bot.DeleteWebhookParams{DropPendingUpdates: dropPending}); err != nil { + return errPreflight + } + return nil +} + +func newTrustedTarget(providerAccountID string) (channels.RoutingTarget, error) { + accountID, err := strconv.ParseInt(providerAccountID, 10, 64) + if err != nil || accountID <= 0 || strconv.FormatInt(accountID, 10) != providerAccountID { + return channels.RoutingTarget{}, errConfiguration + } + root, err := tenant.NewTenant(tenant.CreateInput{ + TenantKey: "telegram-e2e", DisplayName: "Telegram E2E Tenant", + AuditRetentionDays: 30, LogMaskingLevel: tenant.MaskingStrict, TraceSamplingRate: 1, + }) + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + snapshot, err := tenant.NewConfigurationSnapshot(root) + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + app, err := agent.NewApp(agent.CreateInput{ + TenantID: root.TenantID, AppKey: "telegram-e2e", DisplayName: "Telegram E2E", Description: "Deterministic Telegram transport test", + }) + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + revision := int64(1) + app.Status = agent.StatusActive + app.CurrentRevision = &revision + app.Version++ + app.UpdatedAt = app.CreatedAt.Add(time.Second) + if err := app.Validate(); err != nil { + return channels.RoutingTarget{}, errConfiguration + } + routeDigest, err := channels.DigestPublicRouteKey(channels.ChannelTelegram, "telegram-e2e") + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + repository := channelsinmemory.NewRepository() + secret := "telegram-e2e-verifier-secret" + binding, _, err := repository.Create(context.Background(), channels.CreateInput{ + TenantID: root.TenantID, BindingKey: "telegram-e2e", Channel: channels.ChannelTelegram, + ProviderAccountID: providerAccountID, PublicRouteKeyDigest: routeDigest, AppID: app.AppID, + SecretRef: "example/telegram-e2e", Status: channels.StatusActive, + Protocol: channels.ProtocolConfiguration{Telegram: &channels.TelegramProtocolConfiguration{}}, + Metadata: exampleMetadata(), + }) + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + resolver := channelsinmemory.NewFakeCandidateResolver(repository, map[channels.SecretScope]string{{TenantID: binding.TenantID, SecretRef: binding.SecretRef}: secret}) + candidates, err := repository.LookupCandidates(context.Background(), channels.ChannelTelegram, routeDigest) + if err != nil || len(candidates) != 1 { + return channels.RoutingTarget{}, errConfiguration + } + handle, err := resolver.ResolveCandidate(context.Background(), channels.CandidateSecretRequest{ + Candidate: candidates[0], Purpose: channels.PurposeWebhookVerification, + }) + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + digest := sha256.Sum256([]byte("telegram-e2e-trusted-target")) + verification := channels.VerificationRequest{ + Purpose: channels.PurposeWebhookVerification, Timestamp: time.Now().UTC(), + Nonce: "telegram-e2e-target", MessageDigest: hex.EncodeToString(digest[:]), + } + verification.Signature = channelsinmemory.SignFakeRequest(secret, verification) + verified, err := resolver.Verify(context.Background(), handle, verification) + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + target, err := channels.NewRoutingTarget(snapshot, binding, app, verified) + if err != nil { + return channels.RoutingTarget{}, errConfiguration + } + return target, nil +} + +func exampleMetadata() channels.ChangeMetadata { + return channels.ChangeMetadata{ + ActorType: "example", ActorID: "telegram-e2e", Reason: "live Telegram transport test", + CorrelationID: "telegram-e2e", + } +} + +func telegramAdapter(ctx context.Context, configuration runConfig, target channels.RoutingTarget, dispatcher gateway.DispatchService, stderr io.Writer) (*telegram.Adapter, error) { + adapter, err := telegram.New(ctx, telegram.Config{ + BotToken: configuration.botToken, Target: target, Dispatcher: dispatcher, + PollTimeout: configuration.pollTimeout, + ErrorHook: func(event telegram.ErrorEvent) { + _, _ = fmt.Fprintf(stderr, "telegram %s failed: %v\n", event.Operation, event.Err) + }, + }) + if err != nil { + return nil, errPreflight + } + return adapter, nil +} + +func newDeterministicDispatcher(marker string) *deterministicDispatcher { + return &deterministicDispatcher{marker: marker, seen: make(chan gateway.InboundMessage, 1)} +} + +func (dispatcher *deterministicDispatcher) Dispatch(ctx context.Context, request gateway.DispatchRequest) (<-chan gateway.DispatchEvent, error) { + if dispatcher == nil || ctx == nil { + return nil, errConfiguration + } + if err := ctx.Err(); err != nil { + return nil, err + } + if request.Message.Content == dispatcher.marker { + dispatcher.once.Do(func() { + select { + case dispatcher.seen <- request.Message: + default: + } + }) + } + events := make(chan gateway.DispatchEvent, 2) + events <- gateway.DispatchEvent{Type: gateway.DispatchEventMessage, RequestID: request.RequestID, Text: e2eReply} + events <- gateway.DispatchEvent{Type: gateway.DispatchEventDone, RequestID: request.RequestID, Status: "complete", Done: true} + close(events) + return events, nil +} + +func runAutomatedSender(ctx context.Context, token string, receiver *models.User, marker string, deleteWebhook, dropPending bool) error { + if receiver == nil || receiver.ID <= 0 || receiver.Username == "" { + return errSender + } + replyReceived := make(chan struct{}, 1) + pollingFailed := make(chan struct{}, 1) + sender, err := bot.New(token, + bot.WithSkipGetMe(), + bot.WithDefaultHandler(func(_ context.Context, _ *bot.Bot, update *models.Update) { + if update == nil || update.Message == nil || update.Message.From == nil { + return + } + if update.Message.From.ID == receiver.ID && update.Message.Text == e2eReply { + select { + case replyReceived <- struct{}{}: + default: + } + } + }), + bot.WithNotAsyncHandlers(), + bot.WithErrorsHandler(func(error) { + select { + case pollingFailed <- struct{}{}: + default: + } + }), + ) + if err != nil { + return errSender + } + senderUser, err := sender.GetMe(ctx) + if err != nil || senderUser == nil || !senderUser.IsBot || senderUser.ID <= 0 || senderUser.ID == receiver.ID { + return errSender + } + if err := prepareLongPolling(ctx, sender, deleteWebhook, dropPending); err != nil { + return errSender + } + + senderContext, cancel := context.WithCancel(ctx) + senderDone := make(chan struct{}) + go func() { + sender.Start(senderContext) + close(senderDone) + }() + if _, err := sender.SendMessage(ctx, &bot.SendMessageParams{ChatID: "@" + receiver.Username, Text: marker}); err != nil { + cancel() + waitForSender(senderDone) + return errSender + } + select { + case <-replyReceived: + cancel() + if !waitForSender(senderDone) { + return errSenderStopped + } + return nil + case <-pollingFailed: + cancel() + waitForSender(senderDone) + return errSender + case <-senderDone: + cancel() + return errSenderStopped + case <-ctx.Done(): + cancel() + waitForSender(senderDone) + return errSender + } +} + +func waitForSender(done <-chan struct{}) bool { + select { + case <-done: + return true + case <-time.After(shutdownTimeout): + return false + } +} + +func waitForAdapter(done <-chan error) error { + if err := <-done; err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil + } + return errAdapterRun + } + return nil +} + +func hasControl(value string) bool { + return strings.ContainsAny(value, "\r\n\x00") +} diff --git a/example/telegram-e2e/main_test.go b/example/telegram-e2e/main_test.go new file mode 100644 index 0000000..4504d05 --- /dev/null +++ b/example/telegram-e2e/main_test.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/XnLemon/trpc-agent-service/trpcservice/channels" + "github.com/XnLemon/trpc-agent-service/trpcservice/gateway" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func TestLoadConfigUsesSafeDefaults(t *testing.T) { + values := map[string]string{"TELEGRAM_BOT_TOKEN": "receiver-token"} + configuration, err := loadConfig(func(name string) string { return values[name] }) + if err != nil { + t.Fatal(err) + } + if configuration.botToken != values["TELEGRAM_BOT_TOKEN"] || configuration.senderBotToken != "" { + t.Fatalf("unexpected token configuration: %+v", configuration) + } + if configuration.testMessage == "" || !strings.HasPrefix(configuration.testMessage, "telegram-e2e-") { + t.Fatalf("generated marker = %q", configuration.testMessage) + } + if configuration.runTimeout != defaultRunTimeout || configuration.pollTimeout != defaultPollTimeout { + t.Fatalf("unexpected defaults: %+v", configuration) + } + if configuration.deleteWebhook || configuration.dropPendingUpdate { + t.Fatalf("destructive webhook defaults must be false: %+v", configuration) + } +} + +func TestLoadConfigRejectsSecretBearingOrUnsafeValuesWithoutEchoingThem(t *testing.T) { + tests := []map[string]string{ + {"TELEGRAM_BOT_TOKEN": "receiver-token", "TELEGRAM_SENDER_BOT_TOKEN": "receiver-token"}, + {"TELEGRAM_BOT_TOKEN": "receiver-token", "TELEGRAM_TEST_MESSAGE": "contains-receiver-token"}, + {"TELEGRAM_BOT_TOKEN": "receiver-token", "TELEGRAM_TIMEOUT": "not-a-duration"}, + {"TELEGRAM_BOT_TOKEN": "receiver-token", "TELEGRAM_POLL_TIMEOUT": "1s"}, + {"TELEGRAM_BOT_TOKEN": "receiver-token", "TELEGRAM_DELETE_WEBHOOK": "not-a-bool"}, + } + for _, values := range tests { + _, err := loadConfig(func(name string) string { return values[name] }) + if !errors.Is(err, errConfiguration) { + t.Fatalf("values=%v error=%v, want errConfiguration", values, err) + } + for _, value := range values { + if value != "" && strings.Contains(err.Error(), value) { + t.Fatalf("error %q echoed configured value %q", err, value) + } + } + } +} + +func TestLoadConfigParsesExplicitSettings(t *testing.T) { + values := map[string]string{ + "TELEGRAM_BOT_TOKEN": "receiver-token", + "TELEGRAM_SENDER_BOT_TOKEN": "sender-token", + "TELEGRAM_TEST_MESSAGE": "telegram-e2e-marker", + "TELEGRAM_TIMEOUT": "45s", + "TELEGRAM_POLL_TIMEOUT": "3s", + "TELEGRAM_DELETE_WEBHOOK": "true", + "TELEGRAM_DROP_PENDING_UPDATES": "true", + } + configuration, err := loadConfig(func(name string) string { return values[name] }) + if err != nil { + t.Fatal(err) + } + if configuration.testMessage != values["TELEGRAM_TEST_MESSAGE"] || configuration.runTimeout != 45*time.Second || configuration.pollTimeout != 3*time.Second || !configuration.deleteWebhook || !configuration.dropPendingUpdate { + t.Fatalf("explicit settings were not parsed: %+v", configuration) + } +} + +func TestPrepareLongPollingHandlesWebhookSafely(t *testing.T) { + noWebhook := &fakeWebhookClient{} + if err := prepareLongPolling(context.Background(), noWebhook, false, false); err != nil { + t.Fatal(err) + } + if noWebhook.deleted { + t.Fatal("did not expect DeleteWebhook without a webhook") + } + + configured := &fakeWebhookClient{info: &models.WebhookInfo{URL: "https://example.test/telegram"}} + if err := prepareLongPolling(context.Background(), configured, false, false); !errors.Is(err, errWebhookConfigured) { + t.Fatalf("configured webhook error = %v", err) + } + if configured.deleted { + t.Fatal("must not delete webhook without explicit permission") + } + + configured = &fakeWebhookClient{info: &models.WebhookInfo{URL: "https://example.test/telegram"}} + if err := prepareLongPolling(context.Background(), configured, true, true); err != nil { + t.Fatal(err) + } + if !configured.deleted || !configured.dropPending { + t.Fatalf("DeleteWebhook options were not preserved: %+v", configured) + } +} + +func TestNewTrustedTargetUsesTheTrustedBoundary(t *testing.T) { + target, err := newTrustedTarget("8954722550") + if err != nil { + t.Fatal(err) + } + if err := target.Validate(); err != nil { + t.Fatal(err) + } + if target.Channel != channels.ChannelTelegram || target.ProviderAccountID != "8954722550" { + t.Fatalf("unexpected target: %+v", target) + } +} + +func TestNewTrustedTargetRejectsNonCanonicalAccountID(t *testing.T) { + for _, value := range []string{"", "0", "+8954722550", "08954722550", "bot"} { + if _, err := newTrustedTarget(value); !errors.Is(err, errConfiguration) { + t.Fatalf("provider account %q error = %v", value, err) + } + } +} + +func TestDeterministicDispatcherEmitsCompleteReplyAndMarksInput(t *testing.T) { + dispatcher := newDeterministicDispatcher("marker") + stream, err := dispatcher.Dispatch(context.Background(), gateway.DispatchRequest{ + Message: gateway.InboundMessage{Content: "marker"}, RequestID: "request-id", + }) + if err != nil { + t.Fatal(err) + } + var events []gateway.DispatchEvent + for event := range stream { + events = append(events, event) + } + if len(events) != 2 || events[0].Type != gateway.DispatchEventMessage || events[0].Text != e2eReply || !events[1].Done { + t.Fatalf("unexpected dispatch events: %+v", events) + } + select { + case message := <-dispatcher.seen: + if message.Content != "marker" { + t.Fatalf("seen message = %+v", message) + } + default: + t.Fatal("dispatcher did not mark the expected message") + } +} + +type fakeWebhookClient struct { + info *models.WebhookInfo + deleted bool + dropPending bool +} + +func (client *fakeWebhookClient) GetWebhookInfo(context.Context) (*models.WebhookInfo, error) { + return client.info, nil +} + +func (client *fakeWebhookClient) DeleteWebhook(_ context.Context, params *bot.DeleteWebhookParams) (bool, error) { + client.deleted = true + client.dropPending = params != nil && params.DropPendingUpdates + return true, nil +} From 509b25ccfbfe9c7a6dbccab4307c01e9bd21c51a Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 19:29:56 +0800 Subject: [PATCH 03/12] test: keep live bot identity out of fixtures --- example/telegram-e2e/main_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/example/telegram-e2e/main_test.go b/example/telegram-e2e/main_test.go index 4504d05..1fac265 100644 --- a/example/telegram-e2e/main_test.go +++ b/example/telegram-e2e/main_test.go @@ -100,20 +100,20 @@ func TestPrepareLongPollingHandlesWebhookSafely(t *testing.T) { } func TestNewTrustedTargetUsesTheTrustedBoundary(t *testing.T) { - target, err := newTrustedTarget("8954722550") + target, err := newTrustedTarget("123456789") if err != nil { t.Fatal(err) } if err := target.Validate(); err != nil { t.Fatal(err) } - if target.Channel != channels.ChannelTelegram || target.ProviderAccountID != "8954722550" { + if target.Channel != channels.ChannelTelegram || target.ProviderAccountID != "123456789" { t.Fatalf("unexpected target: %+v", target) } } func TestNewTrustedTargetRejectsNonCanonicalAccountID(t *testing.T) { - for _, value := range []string{"", "0", "+8954722550", "08954722550", "bot"} { + for _, value := range []string{"", "0", "+123456789", "0123456789", "bot"} { if _, err := newTrustedTarget(value); !errors.Is(err, errConfiguration) { t.Fatalf("provider account %q error = %v", value, err) } From d4e171b59d53785ce87e7fe4128e3285408f462f Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 19:49:30 +0800 Subject: [PATCH 04/12] docs: clarify Telegram E2E CI sender requirement --- docs/docs/telegram.md | 2 +- example/telegram-e2e/README.md | 7 ++++--- example/telegram-e2e/main.go | 14 +++++++------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/docs/telegram.md b/docs/docs/telegram.md index c2bf37f..fd56bc6 100644 --- a/docs/docs/telegram.md +++ b/docs/docs/telegram.md @@ -159,7 +159,7 @@ Issue #33 提供根目录 `example/telegram-e2e/` 示例和手动触发的 CI trace 或错误。CI 使用受保护的 `telegram-e2e` Environment,至少配置接收 Bot 的 `TELEGRAM_BOT_TOKEN`,并在需要完全自动化入站消息时配置第二个受控测试 Bot 的 `TELEGRAM_SENDER_BOT_TOKEN`。一个 Bot Token 不能模拟普通用户向自己发送入站消息, -所以 CI 必须显式配置发送者或采用人工/外部触发方案。 +所以当前 workflow 必须显式配置第二个受控测试 Bot;本地人工运行可以不配置发送者。 示例和 CI 都只验证普通文本;命令、媒体、rich update、Webhook、持久化 outbox 和 生产模型供应商仍不属于该 E2E 范围。详见 diff --git a/example/telegram-e2e/README.md b/example/telegram-e2e/README.md index 0a23e1b..a33aac9 100644 --- a/example/telegram-e2e/README.md +++ b/example/telegram-e2e/README.md @@ -49,9 +49,10 @@ Optional local settings: The live workflow is intentionally manual and references a protected GitHub Environment named `telegram-e2e`: -- `TELEGRAM_BOT_TOKEN`: secret for the receiving test Bot. -- `TELEGRAM_SENDER_BOT_TOKEN`: optional secret for a second controlled test Bot - that sends the unique marker and receives the expected reply. +- `TELEGRAM_BOT_TOKEN`: required secret for the receiving test Bot. +- `TELEGRAM_SENDER_BOT_TOKEN`: required secret for a second controlled test Bot + in CI; it sends the unique marker and receives the expected reply. This + sender secret is optional only for local human-driven runs. For a fully automatic message round trip, enable Telegram Bot-to-Bot Communication Mode for both dedicated test Bots. A single Bot API token cannot diff --git a/example/telegram-e2e/main.go b/example/telegram-e2e/main.go index 9efe9bd..b3ee9e4 100644 --- a/example/telegram-e2e/main.go +++ b/example/telegram-e2e/main.go @@ -34,13 +34,13 @@ const ( var ( errConfiguration = errors.New("invalid Telegram E2E configuration") - errPreflight = errors.New("Telegram E2E preflight failed") - errWebhookConfigured = errors.New("Telegram webhook is configured; remove it or enable TELEGRAM_DELETE_WEBHOOK") - errAdapterRun = errors.New("Telegram E2E adapter stopped unexpectedly") - errAdapterClose = errors.New("Telegram E2E adapter close failed") - errRunTimeout = errors.New("Telegram E2E timed out waiting for the test message") - errSender = errors.New("Telegram E2E sender failed") - errSenderStopped = errors.New("Telegram E2E sender stopped unexpectedly") + errPreflight = errors.New("telegram E2E preflight failed") + errWebhookConfigured = errors.New("telegram webhook is configured; remove it or enable TELEGRAM_DELETE_WEBHOOK") + errAdapterRun = errors.New("telegram E2E adapter stopped unexpectedly") + errAdapterClose = errors.New("telegram E2E adapter close failed") + errRunTimeout = errors.New("telegram E2E timed out waiting for the test message") + errSender = errors.New("telegram E2E sender failed") + errSenderStopped = errors.New("telegram E2E sender stopped unexpectedly") ) type runConfig struct { From 0079a2eec10030d2309e85604c64c26ac9cd5bdf Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 19:58:25 +0800 Subject: [PATCH 05/12] ci: exclude live Telegram harness from patch coverage --- codecov.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codecov.yml b/codecov.yml index ed7826e..3e04fb5 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,6 +1,11 @@ # Codecov status checks (shown on PRs). # The hard CI gate lives in scripts/coverage.sh --min (see .github/workflows/ci.yml); # this file makes the same 85% target visible in Codecov reports and PR comments. +# The live example entrypoint is exercised only by the opt-in Telegram workflow; +# its credential-free unit tests cover the pure helpers and boundary setup. +ignore: + - "example/telegram-e2e/main.go" + coverage: status: project: From e596168b1d6f4f5a9f064d04c9565f1cb305aa6b Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 20:10:13 +0800 Subject: [PATCH 06/12] refactor: move Telegram E2E example under examples --- .github/workflows/telegram-e2e.yml | 2 +- README.md | 4 ++-- codecov.yml | 2 +- docs/docs/index.md | 2 +- docs/docs/telegram.md | 4 ++-- {example => examples}/telegram-e2e/README.md | 2 +- {example => examples}/telegram-e2e/main.go | 2 +- {example => examples}/telegram-e2e/main_test.go | 0 8 files changed, 9 insertions(+), 9 deletions(-) rename {example => examples}/telegram-e2e/README.md (98%) rename {example => examples}/telegram-e2e/main.go (99%) rename {example => examples}/telegram-e2e/main_test.go (100%) diff --git a/.github/workflows/telegram-e2e.yml b/.github/workflows/telegram-e2e.yml index 30a172b..d366f69 100644 --- a/.github/workflows/telegram-e2e.yml +++ b/.github/workflows/telegram-e2e.yml @@ -46,7 +46,7 @@ jobs: test -n "$TELEGRAM_SENDER_BOT_TOKEN" || { echo "::error::telegram-e2e Environment is missing TELEGRAM_SENDER_BOT_TOKEN"; exit 1; } - name: Run live Telegram E2E - run: go run ./example/telegram-e2e + run: go run ./examples/telegram-e2e env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_SENDER_BOT_TOKEN: ${{ secrets.TELEGRAM_SENDER_BOT_TOKEN }} diff --git a/README.md b/README.md index d38a240..d947f20 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ - [ ] 实现 Runner Event 到文本、流式消息和卡片消息的转换 - [ ] 接入企业微信或微信相关通道 - [x] 接入 Telegram long polling 文本通道(Issue #31;单 Binding、Gateway Dispatch、进程内幂等) -- [x] 增加真实 Telegram live E2E 示例与手动 CI workflow(Issue #33;根目录 `example/telegram-e2e`) +- [x] 增加真实 Telegram live E2E 示例与手动 CI workflow(Issue #33;根目录 `examples/telegram-e2e`) - [ ] 接入 Telegram webhook、媒体/rich update 或其他 IM 通道 - [ ] 实现 webhook 验签、账号与租户绑定、用户身份映射 - [ ] 使用 `tenant + channel + message_id` 实现幂等去重和缓存回复 @@ -233,7 +233,7 @@ - Issue #31 的 `trpcservice/channels/telegram` 提供单 Binding、`getMe` 身份校验、普通文本 long polling、Gateway Dispatch、进程内幂等和脱敏分段回复;具体边界以 `docs/docs/telegram.md` 为准。 -- Issue #33 的 `example/telegram-e2e` 使用真实 Telegram Bot API 和确定性 Dispatcher 验证 +- Issue #33 的 `examples/telegram-e2e` 使用真实 Telegram Bot API 和确定性 Dispatcher 验证 `getMe -> getUpdates -> sendMessage`;live workflow 只手动触发并使用受保护 Environment, 不替代完整模型供应商或生产控制面 E2E。 - Issue #26 的 fake candidate resolver/verifier 与 proof-bearing routing 边界有独立测试, diff --git a/codecov.yml b/codecov.yml index 3e04fb5..036772c 100644 --- a/codecov.yml +++ b/codecov.yml @@ -4,7 +4,7 @@ # The live example entrypoint is exercised only by the opt-in Telegram workflow; # its credential-free unit tests cover the pure helpers and boundary setup. ignore: - - "example/telegram-e2e/main.go" + - "examples/telegram-e2e/main.go" coverage: status: diff --git a/docs/docs/index.md b/docs/docs/index.md index 15f927e..93ff0aa 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -22,7 +22,7 @@ 限流、幂等和服务生命周期契约。 - [Telegram 长轮询 Adapter](telegram.md):Issue #31 的文档先行契约,固定单 Binding、Bot 身份校验、普通文本映射、Dispatch 聚合回复和生命周期边界。 -- [Telegram live E2E 示例](https://github.com/XnLemon/trpc-agent-service/tree/main/example/telegram-e2e): +- [Telegram live E2E 示例](https://github.com/XnLemon/trpc-agent-service/tree/main/examples/telegram-e2e): Issue #33 的真实 Bot API 传输冒烟测试和手动 CI 运行说明。 ## 快速开始 diff --git a/docs/docs/telegram.md b/docs/docs/telegram.md index fd56bc6..e8c05b2 100644 --- a/docs/docs/telegram.md +++ b/docs/docs/telegram.md @@ -151,7 +151,7 @@ README 和 MkDocs 状态应明确区分已交付与后续能力: ## 7. 真实 Telegram E2E -Issue #33 提供根目录 `example/telegram-e2e/` 示例和手动触发的 CI 工作流, +Issue #33 提供根目录 `examples/telegram-e2e/` 示例和手动触发的 CI 工作流, 用于验证真实的 `getMe -> getUpdates -> sendMessage` 边界。示例内部使用确定性 `DispatchService`,因此不会把模型供应商凭据和 Telegram 传输冒烟测试混在一起。 @@ -163,5 +163,5 @@ trace 或错误。CI 使用受保护的 `telegram-e2e` Environment,至少配 示例和 CI 都只验证普通文本;命令、媒体、rich update、Webhook、持久化 outbox 和 生产模型供应商仍不属于该 E2E 范围。详见 -[Telegram live E2E example](https://github.com/XnLemon/trpc-agent-service/tree/main/example/telegram-e2e) +[Telegram live E2E example](https://github.com/XnLemon/trpc-agent-service/tree/main/examples/telegram-e2e) 和 Issue #33。 diff --git a/example/telegram-e2e/README.md b/examples/telegram-e2e/README.md similarity index 98% rename from example/telegram-e2e/README.md rename to examples/telegram-e2e/README.md index a33aac9..f07553c 100644 --- a/example/telegram-e2e/README.md +++ b/examples/telegram-e2e/README.md @@ -22,7 +22,7 @@ PowerShell: ```powershell $env:TELEGRAM_BOT_TOKEN = '' -go run ./example/telegram-e2e +go run ./examples/telegram-e2e ``` The command prints a unique ordinary-text marker. Open the receiver Bot in diff --git a/example/telegram-e2e/main.go b/examples/telegram-e2e/main.go similarity index 99% rename from example/telegram-e2e/main.go rename to examples/telegram-e2e/main.go index b3ee9e4..356e7da 100644 --- a/example/telegram-e2e/main.go +++ b/examples/telegram-e2e/main.go @@ -269,7 +269,7 @@ func newTrustedTarget(providerAccountID string) (channels.RoutingTarget, error) binding, _, err := repository.Create(context.Background(), channels.CreateInput{ TenantID: root.TenantID, BindingKey: "telegram-e2e", Channel: channels.ChannelTelegram, ProviderAccountID: providerAccountID, PublicRouteKeyDigest: routeDigest, AppID: app.AppID, - SecretRef: "example/telegram-e2e", Status: channels.StatusActive, + SecretRef: "examples/telegram-e2e", Status: channels.StatusActive, Protocol: channels.ProtocolConfiguration{Telegram: &channels.TelegramProtocolConfiguration{}}, Metadata: exampleMetadata(), }) diff --git a/example/telegram-e2e/main_test.go b/examples/telegram-e2e/main_test.go similarity index 100% rename from example/telegram-e2e/main_test.go rename to examples/telegram-e2e/main_test.go From c4201747355e61038f5551948637fb67ddd912c3 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 20:32:45 +0800 Subject: [PATCH 07/12] fix: expose safe Telegram E2E preflight stages --- examples/telegram-e2e/main.go | 43 +++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/examples/telegram-e2e/main.go b/examples/telegram-e2e/main.go index 356e7da..a575705 100644 --- a/examples/telegram-e2e/main.go +++ b/examples/telegram-e2e/main.go @@ -33,14 +33,20 @@ const ( ) var ( - errConfiguration = errors.New("invalid Telegram E2E configuration") - errPreflight = errors.New("telegram E2E preflight failed") - errWebhookConfigured = errors.New("telegram webhook is configured; remove it or enable TELEGRAM_DELETE_WEBHOOK") - errAdapterRun = errors.New("telegram E2E adapter stopped unexpectedly") - errAdapterClose = errors.New("telegram E2E adapter close failed") - errRunTimeout = errors.New("telegram E2E timed out waiting for the test message") - errSender = errors.New("telegram E2E sender failed") - errSenderStopped = errors.New("telegram E2E sender stopped unexpectedly") + errConfiguration = errors.New("invalid Telegram E2E configuration") + errPreflight = errors.New("telegram E2E preflight failed") + errPreflightClient = errors.New("telegram E2E bot client preflight failed") + errPreflightGetMe = errors.New("telegram E2E getMe preflight failed") + errPreflightWebhook = errors.New("telegram E2E webhook preflight failed") + errWebhookConfigured = errors.New("telegram webhook is configured; remove it or enable TELEGRAM_DELETE_WEBHOOK") + errAdapterConfiguration = errors.New("telegram E2E adapter configuration failed") + errAdapterInitialization = errors.New("telegram E2E adapter initialization failed") + errAdapterIdentity = errors.New("telegram E2E adapter identity check failed") + errAdapterRun = errors.New("telegram E2E adapter stopped unexpectedly") + errAdapterClose = errors.New("telegram E2E adapter close failed") + errRunTimeout = errors.New("telegram E2E timed out waiting for the test message") + errSender = errors.New("telegram E2E sender failed") + errSenderStopped = errors.New("telegram E2E sender stopped unexpectedly") ) type runConfig struct { @@ -198,11 +204,11 @@ func readBool(lookup func(string) string, name string, fallback bool) (bool, err func prepareBot(ctx context.Context, token string, deleteWebhook, dropPending bool) (*models.User, error) { client, err := bot.New(token, bot.WithSkipGetMe()) if err != nil { - return nil, errPreflight + return nil, errPreflightClient } me, err := client.GetMe(ctx) if err != nil || me == nil || !me.IsBot || me.ID <= 0 { - return nil, errPreflight + return nil, errPreflightGetMe } if err := prepareLongPolling(ctx, client, deleteWebhook, dropPending); err != nil { return nil, err @@ -212,11 +218,11 @@ func prepareBot(ctx context.Context, token string, deleteWebhook, dropPending bo func prepareLongPolling(ctx context.Context, client webhookClient, deleteWebhook, dropPending bool) error { if client == nil { - return errPreflight + return errPreflightWebhook } info, err := client.GetWebhookInfo(ctx) if err != nil { - return errPreflight + return errPreflightWebhook } if info == nil || info.URL == "" { return nil @@ -225,7 +231,7 @@ func prepareLongPolling(ctx context.Context, client webhookClient, deleteWebhook return errWebhookConfigured } if _, err := client.DeleteWebhook(ctx, &bot.DeleteWebhookParams{DropPendingUpdates: dropPending}); err != nil { - return errPreflight + return errPreflightWebhook } return nil } @@ -320,7 +326,16 @@ func telegramAdapter(ctx context.Context, configuration runConfig, target channe }, }) if err != nil { - return nil, errPreflight + switch { + case errors.Is(err, telegram.ErrInvalid): + return nil, errAdapterConfiguration + case errors.Is(err, telegram.ErrBotIdentityMismatch): + return nil, errAdapterIdentity + case errors.Is(err, telegram.ErrInitialization): + return nil, errAdapterInitialization + default: + return nil, errPreflight + } } return adapter, nil } From 026fe0c788f256b829bd00f4d9c0ba01c403baae Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 20:59:52 +0800 Subject: [PATCH 08/12] fix: classify Telegram E2E getMe failures --- examples/telegram-e2e/main.go | 77 +++++++++++++++++++++++------- examples/telegram-e2e/main_test.go | 55 +++++++++++++++++++++ 2 files changed, 114 insertions(+), 18 deletions(-) diff --git a/examples/telegram-e2e/main.go b/examples/telegram-e2e/main.go index a575705..82eae61 100644 --- a/examples/telegram-e2e/main.go +++ b/examples/telegram-e2e/main.go @@ -7,6 +7,8 @@ import ( "errors" "fmt" "io" + "net/http" + "net/url" "os" "os/signal" "strconv" @@ -36,7 +38,10 @@ var ( errConfiguration = errors.New("invalid Telegram E2E configuration") errPreflight = errors.New("telegram E2E preflight failed") errPreflightClient = errors.New("telegram E2E bot client preflight failed") - errPreflightGetMe = errors.New("telegram E2E getMe preflight failed") + errPreflightGetMeNetwork = errors.New("telegram E2E getMe network failure") + errPreflightGetMeTimeout = errors.New("telegram E2E getMe timeout") + errPreflightGetMeAPI = errors.New("telegram E2E getMe Telegram API rejected the request") + errPreflightGetMeReply = errors.New("telegram E2E getMe response was invalid") errPreflightWebhook = errors.New("telegram E2E webhook preflight failed") errWebhookConfigured = errors.New("telegram webhook is configured; remove it or enable TELEGRAM_DELETE_WEBHOOK") errAdapterConfiguration = errors.New("telegram E2E adapter configuration failed") @@ -90,7 +95,7 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ return err } - receiver, err := prepareBot(ctx, configuration.botToken, configuration.deleteWebhook, configuration.dropPendingUpdate) + receiver, err := prepareBot(ctx, configuration.botToken, configuration.pollTimeout, configuration.deleteWebhook, configuration.dropPendingUpdate) if err != nil { return err } @@ -115,7 +120,7 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ var result error if configuration.senderBotToken != "" { - result = runAutomatedSender(runContext, configuration.senderBotToken, receiver, configuration.testMessage, configuration.deleteWebhook, configuration.dropPendingUpdate) + result = runAutomatedSender(runContext, configuration.senderBotToken, configuration.pollTimeout, receiver, configuration.testMessage, configuration.deleteWebhook, configuration.dropPendingUpdate) cancel() if stopErr := waitForAdapter(runDone); stopErr != nil && result == nil { result = stopErr @@ -201,14 +206,17 @@ func readBool(lookup func(string) string, name string, fallback bool) (bool, err return strconv.ParseBool(value) } -func prepareBot(ctx context.Context, token string, deleteWebhook, dropPending bool) (*models.User, error) { - client, err := bot.New(token, bot.WithSkipGetMe()) +func prepareBot(ctx context.Context, token string, pollTimeout time.Duration, deleteWebhook, dropPending bool) (*models.User, error) { + client, err := bot.New(token, bot.WithSkipGetMe(), bot.WithHTTPClient(pollTimeout, preflightHTTPClient(pollTimeout))) if err != nil { return nil, errPreflightClient } me, err := client.GetMe(ctx) - if err != nil || me == nil || !me.IsBot || me.ID <= 0 { - return nil, errPreflightGetMe + if err != nil { + return nil, classifyGetMeError(err) + } + if me == nil || !me.IsBot || me.ID <= 0 { + return nil, errPreflightGetMeReply } if err := prepareLongPolling(ctx, client, deleteWebhook, dropPending); err != nil { return nil, err @@ -216,6 +224,47 @@ func prepareBot(ctx context.Context, token string, deleteWebhook, dropPending bo return me, nil } +func classifyGetMeError(err error) error { + if err == nil { + return errPreflightGetMeReply + } + if errors.Is(err, context.DeadlineExceeded) { + return errPreflightGetMeTimeout + } + if errors.Is(err, bot.ErrorForbidden) || errors.Is(err, bot.ErrorBadRequest) || errors.Is(err, bot.ErrorUnauthorized) || errors.Is(err, bot.ErrorNotFound) || errors.Is(err, bot.ErrorConflict) || errors.Is(err, bot.ErrorTooManyRequests) { + return errPreflightGetMeAPI + } + var tooManyRequestsError *bot.TooManyRequestsError + if errors.As(err, &tooManyRequestsError) { + return errPreflightGetMeAPI + } + var requestError *url.Error + if errors.As(err, &requestError) { + if requestError.Timeout() { + return errPreflightGetMeTimeout + } + return errPreflightGetMeNetwork + } + return errPreflightGetMeReply +} + +func classifyAdapterError(err error) error { + switch { + case errors.Is(err, telegram.ErrInvalid): + return errAdapterConfiguration + case errors.Is(err, telegram.ErrBotIdentityMismatch): + return errAdapterIdentity + case errors.Is(err, telegram.ErrInitialization): + return errAdapterInitialization + default: + return errPreflight + } +} + +func preflightHTTPClient(pollTimeout time.Duration) *http.Client { + return &http.Client{Timeout: pollTimeout + 5*time.Second} +} + func prepareLongPolling(ctx context.Context, client webhookClient, deleteWebhook, dropPending bool) error { if client == nil { return errPreflightWebhook @@ -326,16 +375,7 @@ func telegramAdapter(ctx context.Context, configuration runConfig, target channe }, }) if err != nil { - switch { - case errors.Is(err, telegram.ErrInvalid): - return nil, errAdapterConfiguration - case errors.Is(err, telegram.ErrBotIdentityMismatch): - return nil, errAdapterIdentity - case errors.Is(err, telegram.ErrInitialization): - return nil, errAdapterInitialization - default: - return nil, errPreflight - } + return nil, classifyAdapterError(err) } return adapter, nil } @@ -366,7 +406,7 @@ func (dispatcher *deterministicDispatcher) Dispatch(ctx context.Context, request return events, nil } -func runAutomatedSender(ctx context.Context, token string, receiver *models.User, marker string, deleteWebhook, dropPending bool) error { +func runAutomatedSender(ctx context.Context, token string, pollTimeout time.Duration, receiver *models.User, marker string, deleteWebhook, dropPending bool) error { if receiver == nil || receiver.ID <= 0 || receiver.Username == "" { return errSender } @@ -392,6 +432,7 @@ func runAutomatedSender(ctx context.Context, token string, receiver *models.User default: } }), + bot.WithHTTPClient(pollTimeout, preflightHTTPClient(pollTimeout)), ) if err != nil { return errSender diff --git a/examples/telegram-e2e/main_test.go b/examples/telegram-e2e/main_test.go index 1fac265..6366ac3 100644 --- a/examples/telegram-e2e/main_test.go +++ b/examples/telegram-e2e/main_test.go @@ -3,11 +3,14 @@ package main import ( "context" "errors" + "fmt" + "net/url" "strings" "testing" "time" "github.com/XnLemon/trpc-agent-service/trpcservice/channels" + "github.com/XnLemon/trpc-agent-service/trpcservice/channels/telegram" "github.com/XnLemon/trpc-agent-service/trpcservice/gateway" "github.com/go-telegram/bot" "github.com/go-telegram/bot/models" @@ -99,6 +102,58 @@ func TestPrepareLongPollingHandlesWebhookSafely(t *testing.T) { } } +func TestClassifyGetMeErrorRedactsProviderDetails(t *testing.T) { + secret := "bot-secret" + tests := []struct { + name string + err error + want error + }{ + {name: "nil error", want: errPreflightGetMeReply}, + {name: "context timeout", err: context.DeadlineExceeded, want: errPreflightGetMeTimeout}, + {name: "api rejection", err: fmt.Errorf("provider response: %w", bot.ErrorUnauthorized), want: errPreflightGetMeAPI}, + {name: "rate limit", err: &bot.TooManyRequestsError{Message: "provider response", RetryAfter: 1}, want: errPreflightGetMeAPI}, + {name: "network error", err: &url.Error{Op: "POST", URL: "https://api.telegram.org/bot" + secret + "/getMe", Err: errors.New("dial failed")}, want: errPreflightGetMeNetwork}, + {name: "invalid response", err: errors.New("provider response could not be decoded"), want: errPreflightGetMeReply}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := classifyGetMeError(test.err) + if !errors.Is(got, test.want) { + t.Fatalf("classifyGetMeError(%v) = %v, want %v", test.err, got, test.want) + } + if strings.Contains(got.Error(), secret) { + t.Fatalf("classification error %q leaked provider secret", got) + } + }) + } +} + +func TestClassifyAdapterErrorRedactsProviderDetails(t *testing.T) { + secret := "bot-secret" + tests := []struct { + name string + err error + want error + }{ + {name: "invalid configuration", err: fmt.Errorf("provider token %s: %w", secret, telegram.ErrInvalid), want: errAdapterConfiguration}, + {name: "identity mismatch", err: telegram.ErrBotIdentityMismatch, want: errAdapterIdentity}, + {name: "initialization", err: fmt.Errorf("provider detail: %w", telegram.ErrInitialization), want: errAdapterInitialization}, + {name: "fallback", err: errors.New("unexpected provider error"), want: errPreflight}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := classifyAdapterError(test.err) + if !errors.Is(got, test.want) { + t.Fatalf("classifyAdapterError(%v) = %v, want %v", test.err, got, test.want) + } + if strings.Contains(got.Error(), secret) { + t.Fatalf("classification error %q leaked provider secret", got) + } + }) + } +} + func TestNewTrustedTargetUsesTheTrustedBoundary(t *testing.T) { target, err := newTrustedTarget("123456789") if err != nil { From 9fb983833615e93fc8fd3031db8ac8efd1909fe7 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 23:31:29 +0800 Subject: [PATCH 09/12] fix: harden Telegram E2E automation --- README.md | 2 +- examples/telegram-e2e/README.md | 7 +++++ examples/telegram-e2e/main.go | 45 +++++++++++++++++++++++------- examples/telegram-e2e/main_test.go | 35 +++++++++++++++++++++-- 4 files changed, 76 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d947f20..2b7509a 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ | |-- start.sh # 启动服务 | `-- stop.sh # 停止服务 |-- data # 服务运行时数据 -|-- example # 可运行的外部集成示例 +|-- examples # 可运行的外部集成示例 | `-- telegram-e2e # Telegram live long-polling E2E |-- docs # 各模块说明与架构设计文档 |-- cmd diff --git a/examples/telegram-e2e/README.md b/examples/telegram-e2e/README.md index f07553c..66c38ee 100644 --- a/examples/telegram-e2e/README.md +++ b/examples/telegram-e2e/README.md @@ -30,6 +30,13 @@ Telegram, send that marker, and confirm the `telegram-e2e-ok` reply. Commands, media, and rich updates are intentionally outside this first E2E. Press `Ctrl+C` to stop the local polling process cleanly. +If PowerShell can reach `api.telegram.org` but this command reports +`telegram E2E getMe network failure`, Go is using a different HTTPS path. Go +uses the standard `HTTPS_PROXY`/`HTTP_PROXY` environment variables; it does not +automatically import every Windows system-proxy setting. Configure the proxy +for the same PowerShell process, without printing credentials, and rerun the +command. + If the Bot has a webhook, either remove it before starting long polling or set `TELEGRAM_DELETE_WEBHOOK=true`. Pending updates are preserved by default; set `TELEGRAM_DROP_PENDING_UPDATES=true` only when discarding them is intentional. diff --git a/examples/telegram-e2e/main.go b/examples/telegram-e2e/main.go index 82eae61..2a62124 100644 --- a/examples/telegram-e2e/main.go +++ b/examples/telegram-e2e/main.go @@ -2,6 +2,7 @@ package main import ( "context" + cryptorand "crypto/rand" "crypto/sha256" "encoding/hex" "errors" @@ -71,6 +72,7 @@ type webhookClient interface { type deterministicDispatcher struct { marker string + reply string seen chan gateway.InboundMessage once sync.Once } @@ -94,8 +96,15 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ if err != nil { return err } + runContext, cancel := context.WithTimeout(ctx, configuration.runTimeout) + defer cancel() + correlationID, err := newCorrelationID() + if err != nil { + return errConfiguration + } + reply := e2eReplyFor(correlationID) - receiver, err := prepareBot(ctx, configuration.botToken, configuration.pollTimeout, configuration.deleteWebhook, configuration.dropPendingUpdate) + receiver, err := prepareBot(runContext, configuration.botToken, configuration.pollTimeout, configuration.deleteWebhook, configuration.dropPendingUpdate) if err != nil { return err } @@ -103,13 +112,12 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ if err != nil { return errConfiguration } - dispatcher := newDeterministicDispatcher(configuration.testMessage) - adapter, err := telegramAdapter(ctx, configuration, target, dispatcher, stderr) + dispatcher := newDeterministicDispatcher(configuration.testMessage, reply) + adapter, err := telegramAdapter(runContext, configuration, target, dispatcher, stderr) if err != nil { return err } - runContext, cancel := context.WithTimeout(ctx, configuration.runTimeout) runDone := make(chan error, 1) go func() { runDone <- adapter.Run(runContext) @@ -120,7 +128,7 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ var result error if configuration.senderBotToken != "" { - result = runAutomatedSender(runContext, configuration.senderBotToken, configuration.pollTimeout, receiver, configuration.testMessage, configuration.deleteWebhook, configuration.dropPendingUpdate) + result = runAutomatedSender(runContext, configuration.senderBotToken, configuration.pollTimeout, receiver, configuration.testMessage, reply, configuration.deleteWebhook, configuration.dropPendingUpdate) cancel() if stopErr := waitForAdapter(runDone); stopErr != nil && result == nil { result = stopErr @@ -265,6 +273,18 @@ func preflightHTTPClient(pollTimeout time.Duration) *http.Client { return &http.Client{Timeout: pollTimeout + 5*time.Second} } +func newCorrelationID() (string, error) { + var nonce [8]byte + if _, err := cryptorand.Read(nonce[:]); err != nil { + return "", err + } + return hex.EncodeToString(nonce[:]), nil +} + +func e2eReplyFor(correlationID string) string { + return e2eReply + ":" + correlationID +} + func prepareLongPolling(ctx context.Context, client webhookClient, deleteWebhook, dropPending bool) error { if client == nil { return errPreflightWebhook @@ -380,8 +400,8 @@ func telegramAdapter(ctx context.Context, configuration runConfig, target channe return adapter, nil } -func newDeterministicDispatcher(marker string) *deterministicDispatcher { - return &deterministicDispatcher{marker: marker, seen: make(chan gateway.InboundMessage, 1)} +func newDeterministicDispatcher(marker, reply string) *deterministicDispatcher { + return &deterministicDispatcher{marker: marker, reply: reply, seen: make(chan gateway.InboundMessage, 1)} } func (dispatcher *deterministicDispatcher) Dispatch(ctx context.Context, request gateway.DispatchRequest) (<-chan gateway.DispatchEvent, error) { @@ -400,13 +420,13 @@ func (dispatcher *deterministicDispatcher) Dispatch(ctx context.Context, request }) } events := make(chan gateway.DispatchEvent, 2) - events <- gateway.DispatchEvent{Type: gateway.DispatchEventMessage, RequestID: request.RequestID, Text: e2eReply} + events <- gateway.DispatchEvent{Type: gateway.DispatchEventMessage, RequestID: request.RequestID, Text: dispatcher.reply} events <- gateway.DispatchEvent{Type: gateway.DispatchEventDone, RequestID: request.RequestID, Status: "complete", Done: true} close(events) return events, nil } -func runAutomatedSender(ctx context.Context, token string, pollTimeout time.Duration, receiver *models.User, marker string, deleteWebhook, dropPending bool) error { +func runAutomatedSender(ctx context.Context, token string, pollTimeout time.Duration, receiver *models.User, marker, reply string, deleteWebhook, dropPending bool) error { if receiver == nil || receiver.ID <= 0 || receiver.Username == "" { return errSender } @@ -418,7 +438,7 @@ func runAutomatedSender(ctx context.Context, token string, pollTimeout time.Dura if update == nil || update.Message == nil || update.Message.From == nil { return } - if update.Message.From.ID == receiver.ID && update.Message.Text == e2eReply { + if isExpectedAutomatedReply(update, receiver.ID, reply) { select { case replyReceived <- struct{}{}: default: @@ -477,6 +497,11 @@ func runAutomatedSender(ctx context.Context, token string, pollTimeout time.Dura } } +func isExpectedAutomatedReply(update *models.Update, receiverID int64, reply string) bool { + return update != nil && update.Message != nil && update.Message.From != nil && + update.Message.From.ID == receiverID && update.Message.Chat.ID == receiverID && update.Message.Text == reply +} + func waitForSender(done <-chan struct{}) bool { select { case <-done: diff --git a/examples/telegram-e2e/main_test.go b/examples/telegram-e2e/main_test.go index 6366ac3..2937751 100644 --- a/examples/telegram-e2e/main_test.go +++ b/examples/telegram-e2e/main_test.go @@ -176,7 +176,8 @@ func TestNewTrustedTargetRejectsNonCanonicalAccountID(t *testing.T) { } func TestDeterministicDispatcherEmitsCompleteReplyAndMarksInput(t *testing.T) { - dispatcher := newDeterministicDispatcher("marker") + reply := e2eReplyFor("correlation") + dispatcher := newDeterministicDispatcher("marker", reply) stream, err := dispatcher.Dispatch(context.Background(), gateway.DispatchRequest{ Message: gateway.InboundMessage{Content: "marker"}, RequestID: "request-id", }) @@ -187,7 +188,7 @@ func TestDeterministicDispatcherEmitsCompleteReplyAndMarksInput(t *testing.T) { for event := range stream { events = append(events, event) } - if len(events) != 2 || events[0].Type != gateway.DispatchEventMessage || events[0].Text != e2eReply || !events[1].Done { + if len(events) != 2 || events[0].Type != gateway.DispatchEventMessage || events[0].Text != reply || !events[1].Done { t.Fatalf("unexpected dispatch events: %+v", events) } select { @@ -200,6 +201,36 @@ func TestDeterministicDispatcherEmitsCompleteReplyAndMarksInput(t *testing.T) { } } +func TestExpectedAutomatedReplyRequiresCorrelationAndPrivatePeer(t *testing.T) { + receiverID := int64(42) + reply := e2eReplyFor("correlation") + tests := []struct { + name string + update *models.Update + want bool + }{ + {name: "valid reply", update: &models.Update{Message: &models.Message{From: &models.User{ID: receiverID}, Chat: models.Chat{ID: receiverID}, Text: reply}}, want: true}, + {name: "stale correlation", update: &models.Update{Message: &models.Message{From: &models.User{ID: receiverID}, Chat: models.Chat{ID: receiverID}, Text: e2eReplyFor("stale")}}}, + {name: "unexpected chat", update: &models.Update{Message: &models.Message{From: &models.User{ID: receiverID}, Chat: models.Chat{ID: 99}, Text: reply}}}, + {name: "unexpected sender", update: &models.Update{Message: &models.Message{From: &models.User{ID: 99}, Chat: models.Chat{ID: receiverID}, Text: reply}}}, + {name: "missing message", update: &models.Update{}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := isExpectedAutomatedReply(test.update, receiverID, reply); got != test.want { + t.Fatalf("isExpectedAutomatedReply() = %v, want %v", got, test.want) + } + }) + } +} + +func TestPreflightHTTPClientUsesPollTimeoutBudget(t *testing.T) { + client := preflightHTTPClient(3 * time.Second) + if client.Timeout != 8*time.Second { + t.Fatalf("preflight HTTP timeout = %s, want 8s", client.Timeout) + } +} + type fakeWebhookClient struct { info *models.WebhookInfo deleted bool From a27a160631ce80d1cbc5ba3eb02c97481e850c58 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 23:41:15 +0800 Subject: [PATCH 10/12] fix: handle Telegram E2E cancellation cleanly --- examples/telegram-e2e/main.go | 34 ++++++++++++++++------ examples/telegram-e2e/main_test.go | 45 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/examples/telegram-e2e/main.go b/examples/telegram-e2e/main.go index 2a62124..8520a14 100644 --- a/examples/telegram-e2e/main.go +++ b/examples/telegram-e2e/main.go @@ -70,6 +70,8 @@ type webhookClient interface { DeleteWebhook(context.Context, *bot.DeleteWebhookParams) (bool, error) } +type prepareBotFunc func(context.Context, string, time.Duration, bool, bool) (*models.User, error) + type deterministicDispatcher struct { marker string reply string @@ -89,9 +91,16 @@ func main() { } func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writer) error { + return runWithPreflight(ctx, lookup, stdout, stderr, prepareBot) +} + +func runWithPreflight(ctx context.Context, lookup func(string) string, stdout, stderr io.Writer, prepare prepareBotFunc) error { if ctx == nil || lookup == nil || stdout == nil || stderr == nil { return errConfiguration } + if prepare == nil { + return errConfiguration + } configuration, err := loadConfig(lookup) if err != nil { return err @@ -104,7 +113,7 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ } reply := e2eReplyFor(correlationID) - receiver, err := prepareBot(runContext, configuration.botToken, configuration.pollTimeout, configuration.deleteWebhook, configuration.dropPendingUpdate) + receiver, err := prepare(runContext, configuration.botToken, configuration.pollTimeout, configuration.deleteWebhook, configuration.dropPendingUpdate) if err != nil { return err } @@ -136,16 +145,12 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ } else { select { case err := <-runDone: - if err != nil { - result = errAdapterRun - } else { - result = errAdapterRun - } + result = classifyManualRunResult(ctx.Err(), runContext.Err(), err) case <-runContext.Done(): if stopErr := waitForAdapter(runDone); stopErr != nil { result = stopErr - } else if ctx.Err() == nil { - result = errRunTimeout + } else { + result = classifyManualRunResult(ctx.Err(), runContext.Err(), nil) } } } @@ -156,6 +161,19 @@ func run(ctx context.Context, lookup func(string) string, stdout, stderr io.Writ return result } +func classifyManualRunResult(parentErr, runContextErr, adapterErr error) error { + if parentErr != nil { + return nil + } + if errors.Is(runContextErr, context.DeadlineExceeded) { + return errRunTimeout + } + if errors.Is(runContextErr, context.Canceled) || errors.Is(adapterErr, context.Canceled) || errors.Is(adapterErr, context.DeadlineExceeded) { + return nil + } + return errAdapterRun +} + func loadConfig(lookup func(string) string) (runConfig, error) { if lookup == nil { return runConfig{}, errConfiguration diff --git a/examples/telegram-e2e/main_test.go b/examples/telegram-e2e/main_test.go index 2937751..d154a7d 100644 --- a/examples/telegram-e2e/main_test.go +++ b/examples/telegram-e2e/main_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "net/url" "strings" "testing" @@ -76,6 +77,50 @@ func TestLoadConfigParsesExplicitSettings(t *testing.T) { } } +func TestRunTimeoutCoversBlockingPreflight(t *testing.T) { + values := map[string]string{ + "TELEGRAM_BOT_TOKEN": "receiver-token", + "TELEGRAM_TIMEOUT": "20ms", + "TELEGRAM_POLL_TIMEOUT": "2s", + } + var observedContextErr error + prepare := func(ctx context.Context, _ string, _ time.Duration, _, _ bool) (*models.User, error) { + <-ctx.Done() + observedContextErr = ctx.Err() + return nil, errPreflightGetMeTimeout + } + + err := runWithPreflight(context.Background(), func(name string) string { return values[name] }, io.Discard, io.Discard, prepare) + if !errors.Is(err, errPreflightGetMeTimeout) { + t.Fatalf("runWithPreflight() error = %v, want preflight timeout", err) + } + if !errors.Is(observedContextErr, context.DeadlineExceeded) { + t.Fatalf("preflight context error = %v, want deadline exceeded", observedContextErr) + } +} + +func TestClassifyManualRunResultTreatsCancellationAsClean(t *testing.T) { + tests := []struct { + name string + parentErr error + runContextErr error + adapterErr error + want error + }{ + {name: "parent cancellation", parentErr: context.Canceled, runContextErr: context.Canceled, want: nil}, + {name: "run timeout", runContextErr: context.DeadlineExceeded, want: errRunTimeout}, + {name: "adapter cancellation", adapterErr: context.Canceled, want: nil}, + {name: "unexpected stop", want: errAdapterRun}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := classifyManualRunResult(test.parentErr, test.runContextErr, test.adapterErr); !errors.Is(got, test.want) { + t.Fatalf("classifyManualRunResult() = %v, want %v", got, test.want) + } + }) + } +} + func TestPrepareLongPollingHandlesWebhookSafely(t *testing.T) { noWebhook := &fakeWebhookClient{} if err := prepareLongPolling(context.Background(), noWebhook, false, false); err != nil { From 1e3507110a653366753049e2b0ac78274a789bb3 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 23:50:07 +0800 Subject: [PATCH 11/12] fix: isolate Telegram E2E marker replies --- examples/telegram-e2e/README.md | 2 +- examples/telegram-e2e/main.go | 18 ++++++++++--- examples/telegram-e2e/main_test.go | 42 ++++++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/examples/telegram-e2e/README.md b/examples/telegram-e2e/README.md index 66c38ee..c78e29c 100644 --- a/examples/telegram-e2e/README.md +++ b/examples/telegram-e2e/README.md @@ -26,7 +26,7 @@ go run ./examples/telegram-e2e ``` The command prints a unique ordinary-text marker. Open the receiver Bot in -Telegram, send that marker, and confirm the `telegram-e2e-ok` reply. Commands, +Telegram, send that marker, and confirm the `telegram-e2e-ok:` reply. Commands, media, and rich updates are intentionally outside this first E2E. Press `Ctrl+C` to stop the local polling process cleanly. diff --git a/examples/telegram-e2e/main.go b/examples/telegram-e2e/main.go index 8520a14..159c702 100644 --- a/examples/telegram-e2e/main.go +++ b/examples/telegram-e2e/main.go @@ -115,7 +115,7 @@ func runWithPreflight(ctx context.Context, lookup func(string) string, stdout, s receiver, err := prepare(runContext, configuration.botToken, configuration.pollTimeout, configuration.deleteWebhook, configuration.dropPendingUpdate) if err != nil { - return err + return classifyPreflightResult(ctx.Err(), runContext.Err(), err) } target, err := newTrustedTarget(strconv.FormatInt(receiver.ID, 10)) if err != nil { @@ -124,7 +124,7 @@ func runWithPreflight(ctx context.Context, lookup func(string) string, stdout, s dispatcher := newDeterministicDispatcher(configuration.testMessage, reply) adapter, err := telegramAdapter(runContext, configuration, target, dispatcher, stderr) if err != nil { - return err + return classifyPreflightResult(ctx.Err(), runContext.Err(), err) } runDone := make(chan error, 1) @@ -161,6 +161,16 @@ func runWithPreflight(ctx context.Context, lookup func(string) string, stdout, s return result } +func classifyPreflightResult(parentErr, runContextErr, preflightErr error) error { + if parentErr != nil { + return nil + } + if errors.Is(runContextErr, context.DeadlineExceeded) { + return errRunTimeout + } + return preflightErr +} + func classifyManualRunResult(parentErr, runContextErr, adapterErr error) error { if parentErr != nil { return nil @@ -438,7 +448,9 @@ func (dispatcher *deterministicDispatcher) Dispatch(ctx context.Context, request }) } events := make(chan gateway.DispatchEvent, 2) - events <- gateway.DispatchEvent{Type: gateway.DispatchEventMessage, RequestID: request.RequestID, Text: dispatcher.reply} + if request.Message.Content == dispatcher.marker { + events <- gateway.DispatchEvent{Type: gateway.DispatchEventMessage, RequestID: request.RequestID, Text: dispatcher.reply} + } events <- gateway.DispatchEvent{Type: gateway.DispatchEventDone, RequestID: request.RequestID, Status: "complete", Done: true} close(events) return events, nil diff --git a/examples/telegram-e2e/main_test.go b/examples/telegram-e2e/main_test.go index d154a7d..2cdf33e 100644 --- a/examples/telegram-e2e/main_test.go +++ b/examples/telegram-e2e/main_test.go @@ -91,14 +91,30 @@ func TestRunTimeoutCoversBlockingPreflight(t *testing.T) { } err := runWithPreflight(context.Background(), func(name string) string { return values[name] }, io.Discard, io.Discard, prepare) - if !errors.Is(err, errPreflightGetMeTimeout) { - t.Fatalf("runWithPreflight() error = %v, want preflight timeout", err) + if !errors.Is(err, errRunTimeout) { + t.Fatalf("runWithPreflight() error = %v, want run timeout", err) } if !errors.Is(observedContextErr, context.DeadlineExceeded) { t.Fatalf("preflight context error = %v, want deadline exceeded", observedContextErr) } } +func TestRunWithPreflightTreatsParentCancellationAsClean(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + prepare := func(ctx context.Context, _ string, _ time.Duration, _, _ bool) (*models.User, error) { + return nil, ctx.Err() + } + if err := runWithPreflight(ctx, func(name string) string { + if name == "TELEGRAM_BOT_TOKEN" { + return "receiver-token" + } + return "" + }, io.Discard, io.Discard, prepare); err != nil { + t.Fatalf("runWithPreflight() error = %v, want clean cancellation", err) + } +} + func TestClassifyManualRunResultTreatsCancellationAsClean(t *testing.T) { tests := []struct { name string @@ -246,6 +262,28 @@ func TestDeterministicDispatcherEmitsCompleteReplyAndMarksInput(t *testing.T) { } } +func TestDeterministicDispatcherDoesNotReplyToNonMarker(t *testing.T) { + dispatcher := newDeterministicDispatcher("marker", e2eReplyFor("correlation")) + stream, err := dispatcher.Dispatch(context.Background(), gateway.DispatchRequest{ + Message: gateway.InboundMessage{Content: "old-message"}, RequestID: "request-id", + }) + if err != nil { + t.Fatal(err) + } + var events []gateway.DispatchEvent + for event := range stream { + events = append(events, event) + } + if len(events) != 1 || !events[0].Done || events[0].Type != gateway.DispatchEventDone { + t.Fatalf("unexpected non-marker events: %+v", events) + } + select { + case message := <-dispatcher.seen: + t.Fatalf("non-marker was recorded as seen: %+v", message) + default: + } +} + func TestExpectedAutomatedReplyRequiresCorrelationAndPrivatePeer(t *testing.T) { receiverID := int64(42) reply := e2eReplyFor("correlation") From 82af289e49a69743a318ca3e6485fab83f832df8 Mon Sep 17 00:00:00 2001 From: Nene7ko_ <1604009816@qq.com> Date: Sun, 23 Aug 2026 23:58:55 +0800 Subject: [PATCH 12/12] fix: classify automated Telegram E2E cancellation --- examples/telegram-e2e/main.go | 16 +++++++++++++++- examples/telegram-e2e/main_test.go | 22 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/examples/telegram-e2e/main.go b/examples/telegram-e2e/main.go index 159c702..606557a 100644 --- a/examples/telegram-e2e/main.go +++ b/examples/telegram-e2e/main.go @@ -137,7 +137,8 @@ func runWithPreflight(ctx context.Context, lookup func(string) string, stdout, s var result error if configuration.senderBotToken != "" { - result = runAutomatedSender(runContext, configuration.senderBotToken, configuration.pollTimeout, receiver, configuration.testMessage, reply, configuration.deleteWebhook, configuration.dropPendingUpdate) + senderResult := runAutomatedSender(runContext, configuration.senderBotToken, configuration.pollTimeout, receiver, configuration.testMessage, reply, configuration.deleteWebhook, configuration.dropPendingUpdate) + result = classifyAutomatedSenderResult(ctx.Err(), runContext.Err(), senderResult) cancel() if stopErr := waitForAdapter(runDone); stopErr != nil && result == nil { result = stopErr @@ -184,6 +185,19 @@ func classifyManualRunResult(parentErr, runContextErr, adapterErr error) error { return errAdapterRun } +func classifyAutomatedSenderResult(parentErr, runContextErr, senderErr error) error { + if parentErr != nil { + return nil + } + if errors.Is(runContextErr, context.DeadlineExceeded) { + return errRunTimeout + } + if errors.Is(runContextErr, context.Canceled) { + return nil + } + return senderErr +} + func loadConfig(lookup func(string) string) (runConfig, error) { if lookup == nil { return runConfig{}, errConfiguration diff --git a/examples/telegram-e2e/main_test.go b/examples/telegram-e2e/main_test.go index 2cdf33e..1d32917 100644 --- a/examples/telegram-e2e/main_test.go +++ b/examples/telegram-e2e/main_test.go @@ -137,6 +137,28 @@ func TestClassifyManualRunResultTreatsCancellationAsClean(t *testing.T) { } } +func TestClassifyAutomatedSenderResultTreatsCancellationAndTimeoutAsClean(t *testing.T) { + tests := []struct { + name string + parentErr error + runContextErr error + senderErr error + want error + }{ + {name: "parent cancellation", parentErr: context.Canceled, runContextErr: context.Canceled, senderErr: errSender, want: nil}, + {name: "run timeout", runContextErr: context.DeadlineExceeded, senderErr: errSender, want: errRunTimeout}, + {name: "run cancellation", runContextErr: context.Canceled, senderErr: errSender, want: nil}, + {name: "sender failure", senderErr: errSender, want: errSender}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := classifyAutomatedSenderResult(test.parentErr, test.runContextErr, test.senderErr); !errors.Is(got, test.want) { + t.Fatalf("classifyAutomatedSenderResult() = %v, want %v", got, test.want) + } + }) + } +} + func TestPrepareLongPollingHandlesWebhookSafely(t *testing.T) { noWebhook := &fakeWebhookClient{} if err := prepareLongPolling(context.Background(), noWebhook, false, false); err != nil {