REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare commits

...
Author SHA1 Message Date
ScuroNeko f74496a3e8 Release v1.0.0-rc.12
Finalize scenes, typed arg binding, and request-scoped context plumbing

Refresh docs, backlog state, and regression coverage for the rc.12 release
2026-03-30 00:12:24 +03:00
ScuroNeko a4d70e1510 Stabilize scene session skeleton
Hide internal scene runtime methods
Refresh docs, tests, and changelog for scenes
2026-03-28 12:58:05 +00:00
ScuroNeko 3ad9e48d71 fix: guard nil logger in prefix check 2026-03-27 16:06:24 +03:00
ScuroNeko 4f8d583b03 wip: scene sessions 2026-03-27 16:05:20 +03:00
ScuroNeko 68e7529f16 Add framework backlog and signed-commit policy
Document scene modal-chat flow and other missing core concepts in TODO.md
Require agent-created commits to be GPG-signed and fail fast if signing cannot complete
2026-03-26 23:27:54 +03:00
ScuroNeko 0ee0917af5 Update repo agent documentation rules
Require paired English and Russian docs when expanding project documentation
Clarify changelog handling for main repo changes and keep rc.12 notes aligned
Ignore local editor and Codex config directories in git
2026-03-26 23:06:05 +03:00
ScuroNeko 8618397bc1 wip: expand docs and wiki structure
add wiki links to README and README_RU
fill Start here pages for setup, commands/plugins, and MsgContext
add wiki page-priority tracker and local wiki AGENTS rules
2026-03-26 18:35:35 +03:00
ScuroNeko 945b8240e6 wip: add long message helpers and payload type controls
- add centralized message validation errors
- switch command handlers to return error
- add explicit long plain-text reply helpers
- support strict payload type policy with debug logging
- document versioning and changelog workflow
2026-03-26 18:15:06 +03:00
ScuroNeko 5d3199dc21 fix(tgapi): accept string chat boost IDs 2026-03-25 18:20:20 +03:00
ScuroNeko 158625c220 fix bot lifecycle and docs 2026-03-25 18:07:41 +03:00
ScuroNeko 7901fb659e fix bot safety and tgapi edge cases 2026-03-25 13:41:30 +03:00
ScuroNeko eda635e72c fix: enforce required command arg positions 2026-03-25 13:17:44 +03:00
ScuroNeko f0da64c7af logger now initialize before middleware 2026-03-25 12:57:11 +03:00
ScuroNeko 3861746a3e logger now initialize before middleware 2026-03-25 12:56:26 +03:00
ScuroNeko 401173714e refactor(logging): use context logger in MsgContext 2026-03-24 14:02:51 +03:00
ScuroNeko 7776acaf12 refactor logging setup and split local/remote close APIs 2026-03-24 13:45:19 +03:00
ScuroNeko db31246eeb retract 1.0.0 rc 5 2026-03-23 13:19:39 +03:00
ScuroNeko d04c91342b small close fix; logo in readme 2026-03-23 13:13:47 +03:00
ScuroNeko 2e14d8b5df small close fix; logo in readme 2026-03-23 12:59:18 +03:00
ScuroNeko 6b9075c722 feat(tgapi): add context-aware API/uploader methods and align params/docs with Telegram Bot API 2026-03-19 15:10:49 +03:00
ScuroNeko 0b1a58a514 readme change 2026-03-19 14:23:23 +03:00
ScuroNeko c59dd1fe8e Handle canceled update polling without shutdown delay 2026-03-19 14:16:31 +03:00
82 changed files with 6416 additions and 771 deletions
+4 -1
View File
@@ -1,2 +1,5 @@
.idea/ .idea/
test/ .wiki/
.vscode/
test/
.codex/
+14
View File
@@ -0,0 +1,14 @@
version: "2"
run:
timeout: 5m
linters:
disable-all: true
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
issues:
max-issues-per-linter: 0
max-same-issues: 0
+41
View File
@@ -0,0 +1,41 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-yaml
- id: check-json
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: local
hooks:
- id: gofmt
name: gofmt
entry: gofmt -w
language: system
types: [go]
- id: go-vet
name: go vet
entry: go vet ./...
language: system
pass_filenames: false
types: [go]
- id: golangci-lint
name: golangci-lint
entry: golangci-lint run
language: system
pass_filenames: false
types: [go]
- id: go-test
name: go test
entry: go test ./...
language: system
pass_filenames: false
stages: [pre-push]
types: [go]
+159
View File
@@ -0,0 +1,159 @@
# AGENTS.md
## Purpose
This repository uses Codex for full-project Go code review, not diff-only review.
When asked to review code, inspect the entire repository and use repository-wide context. Do not limit analysis to the latest commit, pull request diff, or recently changed files.
## Review priorities
Review the codebase with focus on:
- correctness and reliability;
- maintainability and architecture;
- idiomatic Go;
- testability;
- performance where justified by code evidence;
- security;
- godoc quality.
## Scope rules
- Always review the whole repository unless the prompt explicitly narrows scope.
- Check cross-package interactions, public APIs, package boundaries, and shared patterns.
- Prefer concrete fixes over generic advice.
- When feasible, make small, high-confidence improvements directly.
- When uncertain, state confidence level and evidence.
## Documentation languages
- When creating or expanding project documentation, generate and maintain both English and Russian versions in the same turn whenever reasonably possible.
- For wiki pages, prefer paired pages such as `Page.md` and `Page-RU.md`.
- Keep English and Russian pages aligned in structure, major examples, and user-facing guidance.
- If only one language can be updated safely in the current turn, explicitly say which language is lagging and why.
## Wiki and backlog workflow
- Treat the wiki as the primary place for large design ideas, architectural drafts, and framework backlog notes.
- If the agent identifies a substantial new concept or design direction, such as scenes, callback agents, a webhook model, or another framework-level abstraction, the agent must ask the user whether it should also formalize that idea as a draft wiki page.
- When the user agrees, prefer paired wiki pages such as `Page.md` and `Page-RU.md`, and clearly mark draft design pages with `DRAFT` when the API is not implemented or not yet stable.
- Keep `TODO.md`, the wiki backlog pages, and `CHANGELOG.md` aligned when framework-level items move between planned and completed states.
## Go review expectations
Check for:
- bugs, fragile logic, invalid assumptions, nil handling issues, resource leaks;
- poor error handling;
- misuse of context, cancellation, timeouts, retries, and cleanup;
- race risks, deadlocks, blocking hazards, unsafe shared state;
- non-idiomatic naming, APIs, interfaces, package structure, and error patterns;
- unnecessary complexity, duplication, or weak abstractions;
- obvious performance problems supported by the code;
- security risks such as unsafe input handling, secret leakage, insecure logging, injection risks, and risky file or network operations.
## Godoc rules
Review comments for all declarations.
### Exported declarations
Exported types, funcs, methods, vars, and consts must have godoc comments.
Each exported godoc comment must:
- start with the identifier name;
- explain the purpose or behavior;
- be as short as possible without losing important meaning;
- avoid repeating the signature mechanically;
- stay high-signal and informative.
### Unexported declarations
Unexported types, funcs, methods, vars, and consts should generally not have godoc-style comments unless there is a strong reason.
### Always report
- missing godoc on exported declarations;
- unnecessary godoc on unexported declarations;
- comments that are too long, vague, redundant, or low-value;
- comments that should be shortened or rewritten.
When feasible, rewrite bad godoc into better versions.
## Testing expectations
Treat tests as a required part of review.
- Assess existing test quality, not only test presence.
- Add or propose as many useful tests as reasonably possible.
- Prioritize public APIs, critical flows, edge cases, negative paths, boundary conditions, and concurrency-sensitive logic.
- Prefer table-driven tests where appropriate.
- Add regression tests for bugs you find.
- If a case is hard to test directly, explain the gap and the best test strategy.
## Commands
Before finalizing changes, run the relevant project checks when available:
- build
- tests
- lint
- static analysis
Prefer the repositorys documented commands. If multiple choices exist, use the most standard and least destructive ones first.
## Versioning and changelog
- After every code or documentation change in the main repository, update `CHANGELOG.md`.
- Changes made only inside the `.wiki/` repository do not require a `CHANGELOG.md` update.
- Add changes only to the section for the next version after the latest published git tag.
- The agent must check the latest published tag, `CHANGELOG.md`, and `utils/version.go` before editing the changelog.
- The agent must verify that the target changelog version matches the version declared in `utils/version.go`.
- If the latest published tag is, for example, `v1.0.0`, and `CHANGELOG.md` does not yet contain the next version section, the agent must stop and ask the user which version the change belongs to:
1. `v1.0.1`
2. `v1.1.0`
3. `v2.0.0`
- The agent must not guess the next version when that section is missing.
- If the user-selected version does not match `utils/version.go`, the agent must warn about the mismatch and require the version file to be updated before proceeding.
- Changelog entries must describe all user-visible behavior changes made in the turn, including API additions, fixes, behavior changes, and breaking changes.
- When a framework backlog item recorded in `TODO.md` is completed, the agent must also update the backlog status using the existing format:
1. move the completed item into the top of the `Done` section;
2. replace the numbered backlog label with a version tag, for example `1. Scene Model` becomes `[v2.0.0] Scene Model`;
3. keep the item title and descriptive notes aligned with the corresponding `CHANGELOG.md` entry.
- The agent must treat `TODO.md` and `CHANGELOG.md` as linked records: a completed backlog item should not be left in one file as done and in the other as still pending or undocumented.
## Breaking changes policy
- The agent must detect potential breaking changes before editing public APIs.
- Breaking changes are forbidden unless the selected target version is a new major version.
- If the requested change is breaking and the user did not bump the major version, the agent must stop and warn that the change is not allowed under the current version.
- In that case, the agent must offer only these options:
1. do not make the breaking change;
2. introduce a backward-compatible alternative such as a new method, function, type, or struct, but only if that keeps the codebase reasonably small and clear;
3. bump the major version and then apply the breaking change.
- Prefer additive compatibility over signature changes when the additive option is small and maintainable.
- Example: if a method like `ctx.answer(...)` needs an extra parameter, the agent must either require a major-version bump or add a new method that keeps the old method working.
## Commit message format
- When the user asks for a commit message, the agent must produce it in this format:
1. a short summary line;
2. up to three additional lines with only the most important changes;
3. each additional line must start on its own new line.
- The agent must output the commit message as a plain multiline block that the user can copy directly.
- Do not collapse the lines into a paragraph, bullet list, or wrapped prose explanation.
- Keep commit text concise and high-signal.
- Do not turn commit messages into changelogs.
## Commit signing
- All commits created by the agent must be GPG-signed.
- If commit signing or pushing requires leaving the sandbox, the agent must request escalation explicitly before running the command.
- If a signed commit cannot be created successfully, the agent must report the failure clearly and stop instead of creating an unsigned fallback commit.
## Output format
For repo-wide review tasks, structure the result as:
1. Overall summary
2. Critical findings
3. Major findings
4. Minor findings
5. Godoc issues
6. Test gaps and added/proposed tests
7. Good decisions worth keeping
8. Summary of concrete changes made
For each finding include:
- location;
- issue;
- why it matters;
- recommended fix.
## Working style
- Be direct, specific, and action-oriented.
- Do not stop at style-only feedback.
- Use full repository context before drawing conclusions.
- Prefer minimal, high-confidence patches.
- Preserve behavior unless intentionally fixing a bug.
+191
View File
@@ -0,0 +1,191 @@
# Changelog
## v1.0.0-rc.12
### Added
- `AnswerLong(...)`, `AnswerLongf(...)`, `KeyboardLong(...)`, and `SplitMessageText(...)` for explicit plain-text splitting of long replies without changing the semantics of existing single-message helpers.
- Centralized library-level validation errors in `errors.go`, including `ErrEmptyMessage`, `ErrMessageTooLong`, `ErrCaptionTooLong`, and context/target validation sentinels.
- `Bot.GetPayloadType()`, `InlineKeyboard.GetPayloadType()`, and optional strict payload decoding via `BotOpts.StrictPayloadType` / `Bot.SetStrictPayloadType(...)`.
- `MsgContext.BindArgs(...)` for binding positional command arguments into exported struct fields.
- Binding sentinels `ErrBindArgsTargetNotPointer`, `ErrBindArgsTargetNotStruct`, `ErrBindArgsUnsupportedFieldType`, and `ErrBindArgsConversion`.
- Work-in-progress scene/session support, including plugin scene registration, scoped scene sessions, scene entry/exit APIs on `MsgContext`, default in-memory session storage, scene-local routing before normal command handling, and state helpers on `SceneContext`.
### Changed
- `CommandExecutor` now returns `error`, and command, payload, and non-command update handlers now use centralized bot error handling for returned errors.
- README and README_RU examples now use the new handler signature and document the long-message helpers.
- README and README_RU now link to the project wiki, and the wiki now includes a page-priority tracker while content is being filled in.
- README and README_RU now document scenes, session scopes, scene state helpers, and `SceneActionPass` semantics.
- `TODO.md` and the framework backlog pages now group the remaining framework work into explicit priority 1, 2, and 3 buckets.
- Payload-type comments and docs now distinguish between the bot's default payload type and keyboard-local overrides.
- Scene runtime sentinel errors now have explicit godoc comments.
- Public scene structs now document their exported fields more explicitly.
- `MsgContext.Context()` now safely falls back to `context.Background()` when no request-scoped context is attached.
- `MsgContext` reply, edit, callback, delete, action, and draft-limiter paths now use the context accessor instead of reaching into raw internal state.
- Version constants were bumped to `v1.0.0-rc.12`.
### Fixed
- Message and caption validation now runs before Telegram API calls, rejecting empty messages, oversized message text, and oversized captions with stable sentinel errors.
- Draft flushing and draft updates now reject oversized messages before sending invalid requests.
- Callback payload decoding now optionally enforces strict type matching, while the default tolerant mode logs Base64-to-JSON decoding in debug mode and still accepts keyboard-local payload overrides.
- Positional argument binding now leaves missing trailing struct fields at zero values, joins the remaining arguments into the final string field, and returns clearer binding errors.
- Request-scoped contexts are now created per update handler execution and safely reused through `MsgContext.Context()` even for manually constructed test contexts.
- Command and payload handlers now have regression coverage for end-to-end typed argument binding through the normal routing path.
### Breaking Changes
- `CommandExecutor[T]` changed from `func(ctx *MsgContext, db T)` to `func(ctx *MsgContext, db T) error`.
- `Plugin.NewCommand(...)`, `Plugin.NewPayload(...)`, and `Plugin.AddUpdateHandler(...)` now require handlers with the new error-returning signature.
### Tests
- Added regression tests for `MsgContext.BindArgs(...)`, including scalar conversion, tail-string binding, zero-value trailing fields, invalid targets, unsupported field types, and end-to-end command/payload binding.
- Added scene regression tests for runtime guards, scene-local command handling, and `SceneActionPass` preserving session state.
- Added scene regression tests for message fallback handling, user-scoped session lookup without `Msg`, and custom `SessionStore` error propagation.
## v1.0.0-rc.11
### Fixed
- `chat_boost` update decoding now accepts string `boost_id` values, matching the current Telegram Bot API schema and preventing polling failures on boosted-chat updates.
## v1.0.0-rc.10
### Added
- `Plugin.AddUpdateHandler` for routing non-command Telegram updates by `tgapi.UpdateType`.
- Derived `tgapi.Update.Type` assignment during JSON decoding, plus `tgapi.UpdateTypeUnknown` for unmatched payloads.
- `tgapi.API.OpenFileByLink(...)` and `OpenFileByLinkWithContext(...)` for streaming downloads from Telegram's file server.
- Regression tests for update dispatch, keyboard builders, localization fallback, runners, rate limiting, parse mode encoding, streaming downloads, and context isolation.
- Regression tests for bot single-run enforcement, nil plugin registration, `L10n` concurrent access, `API.Close()` idle-connection cleanup, and `tgapi` worker-pool edge cases.
- `SEMVER.md` documenting versioning expectations for the project.
### Changed
- `NewBot` now returns `(*Bot[T], error)` instead of terminating the host process on configuration or startup failures.
- `Run` and `RunWithContext` now return errors; `RunWithContext` returns `ErrNoPrefixes` and `ErrNoPlugins` for invalid bot configuration.
- Polling retries now use exponential backoff instead of busy-looping on repeated `getUpdates` failures.
- `Bot` is now explicitly single-use; repeated `Run()` or `RunWithContext(...)` calls return `ErrBotAlreadyRun`.
- Database context wiring now uses `T` consistently instead of forcing `*T`; shared dependencies should typically use pointer types such as `*sql.DB`.
- `DatabaseContext`, `GetDBContext`, and `DbLogger` were updated to the new `T`-based dependency model.
- `DatabaseContext(...)` now warns once when `T` is a value type, to highlight likely unintended copying of shared dependencies.
- `AddDatabaseLoggerWriter(...)` now skips unset and nil database contexts instead of calling the writer with invalid values.
- `L10n` is now safe for concurrent use and copies added dictionary entries to avoid external mutation after registration.
- Plugin registration now snapshots commands, payloads, middlewares, and update handlers so later mutations of the original `*Plugin` do not leak into the bot.
- `AddPlugins(...)` now skips nil plugin pointers instead of panicking.
- `GetUpdateTypes()` now returns a copy instead of exposing internal slice state.
- Update handling now normalizes `MsgContext` for more Telegram update kinds and routes plugin-level update handlers with isolated context copies.
- `message`, `channel_post`, and `callback_query` remain on the command/payload flow; non-command updates can be handled through plugin update handlers.
- Command auto-generation now validates Telegram command names with the correct character set and `1..32` length limit, and emits commands in deterministic sorted order.
- Builder-style APIs were normalized to value returns for `NewCommandArg`, `NewMiddleware`, `NewRunner`, and `NewCallbackData`.
- `MenuButton` replaced `BaseMenuButton`, and `GetChatMenuButton(...)` now returns the renamed type.
- Several Telegram DTOs were tightened for optionality and serialization correctness, including `InputPaidMedia`, `MenuButton`, optional gift fields, and message entity slices.
- `tgapi.NewRequest(...)`, `NewRequestWithChatID(...)`, `NewUploaderRequest(...)`, and `NewUploaderRequestWithChatID(...)` are now documented as low-level unsafe escape hatches rather than internal helpers.
- `tgapi.API.Close()` now closes idle HTTP connections before releasing logger resources.
- Multipart form encoding now writes scalar field bytes directly instead of converting through temporary strings.
- README, README_RU, package docs, and exported godoc were updated to match the current APIs and concurrency/lifecycle model.
- Version constants were bumped to `v1.0.0-rc.10`.
### Fixed
- Required command arguments are now enforced by declared argument index, not only by total required count.
- `ParseNone` now omits `parse_mode` from JSON requests instead of serializing `"None"`.
- Upload file type detection is now case-insensitive for file extensions.
- Draft creation no longer panics when no limiter is configured, and draft flushing now rejects zero chat IDs before sending invalid requests.
- Channel posts with `SenderChat` no longer panic in the command path and now preserve the expected `MsgContext` fields.
- File logger initialization now falls back to stdout loggers instead of terminating the process on logger setup failures.
- `GetChatMenuButton` and `SetChatMenuButton` now serialize `chat_id` correctly when omitted.
- Update decoding tests now match the canonical `deleted_business_messages` model and no longer rely on the removed singular alias.
### Breaking Changes
- `NewBot[T](opts)` now returns `(*Bot[T], error)`.
- `Run()` now returns `error`.
- `RunWithContext(ctx)` now returns `error`.
- `Run()` and `RunWithContext(ctx)` are now single-use per bot instance; create a new `Bot` after they return.
- Database context handlers now receive `T` instead of `*T`. For shared dependencies, instantiate the bot with a pointer type, for example `Bot[*sql.DB]`.
- `DatabaseContext(...)` now takes `T` instead of `*T`.
- `GetDBContext()` now returns `T` instead of `*T`.
- `DbLogger[T]` now receives `T` instead of `*T`.
- `NewCommandArg(...)`, `NewMiddleware(...)`, `NewRunner(...)`, and `NewCallbackData(...)` now return values instead of pointers.
- `BaseMenuButton` was renamed to `MenuButton`, and `GetChatMenuButton(...)` now returns `MenuButton`.
- `tgapi.Update` no longer exposes the deprecated `DeletedBusinessMessage` alias; use `DeletedBusinessMessages`.
### Tests
- Added coverage for polling backoff helpers, command sorting, database logger safety checks, update handler routing, update-context isolation, channel posts with `SenderChat`, parse mode encoding, streaming downloads, and rate limiter behavior.
## v1.0.0-rc.7
### Added
- Package-level logger helpers: `utils.CreateLogger(prefix, level)` and `utils.CreateFileLogger(prefix, level, filePath)`.
- `MsgContext.Logger`, populated from the matched plugin and falling back to the bot logger.
- Plugin lifecycle/configuration APIs: `SetLogger`, `RemoveLogger`, `SetOnClose`, and `Close`.
- `Bot.CloseRemote(ctx)` as the explicit wrapper for Telegram Bot API close.
### Changed
- Logger initialization is now unified across `Bot`, `tgapi.API`, and `tgapi.Uploader`.
- `Bot.Close()` now performs local resource teardown only and invokes `Plugin.Close()` for registered plugins.
- Local `tgapi.API` shutdown was renamed to `Close()`.
- Telegram Bot API close wrappers in `tgapi.API` were renamed to `CloseRemote()` and `CloseRemoteWithContext()`.
- `Bot.Debug()` now updates log levels for the bot logger, request logger, and already registered plugin loggers.
- `Bot.AddPlugins()` now creates a default plugin logger automatically when one is not provided.
- `Bot.AddDatabaseLoggerWriter()` now also attaches the writer to already registered plugin loggers.
- GoDoc was expanded for the new shutdown and logging APIs, and plugin registration is now documented as a configuration commit point.
### Breaking Changes
- `(*Bot).Close(ctx context.Context)` was replaced with `(*Bot).Close()`.
- `(*tgapi.API).CloseApi()` was renamed to `(*tgapi.API).Close()`.
- `(*tgapi.API).Close()` was renamed to `(*tgapi.API).CloseRemote()`.
- `(*tgapi.API).CloseWithContext()` was renamed to `(*tgapi.API).CloseRemoteWithContext(ctx)`.
### Migration
- Replace `bot.Close(ctx)` with `bot.Close()`.
- If you need Telegram Bot API close, use `bot.CloseRemote(ctx)`.
- Replace `api.CloseApi()` with `api.Close()`.
- Replace `api.Close()` with `api.CloseRemote()`.
- Replace `api.CloseWithContext(ctx)` with `api.CloseRemoteWithContext(ctx)`.
- Configure plugin loggers and `OnClose` hooks before calling `bot.AddPlugins(...)`.
### Tests
- Updated tests for the new shutdown and logging behavior.
### Notes
- Registering a plugin via `AddPlugins(...)` is a configuration commit point; the plugin should not be mutated through the original `*Plugin` afterward.
- If plugin loggers must receive a database writer, call `AddDatabaseLoggerWriter(...)` after registering plugins.
## v1.0.0-rc.4
### Added
- `WithContext` variants across `tgapi` API and uploader methods so callers can pass cancellation and deadline contexts consistently.
- `UploaderCertificateType`, `UploadSetWebhookP`, `Uploader.SetWebhook(...)`, and `Uploader.SetWebhookWithContext(...)` for multipart webhook certificate uploads.
- Missing media thumbnail fields where applicable.
### Changed
- GoDoc for context-aware methods was improved, and `See` references now point to method-specific Telegram Bot API anchors.
- `EditMessageTextP` now includes `entities` and `link_preview_options`.
- `EditMessageCaptionP` now includes `caption_entities` and `show_caption_above_media`.
- `StopPollP` now uses `reply_markup` and no longer carries `inline_message_id`.
- `SendStickerP` now includes reply and suggested-post related fields.
- `SendDocumentP` now includes `disable_content_type_detection`.
- `SendInvoiceP` no longer includes unsupported `business_connection_id`.
- `SetWebhookP` no longer carries `certificate`; GoDoc now points to uploader-based certificate upload.
- Existing non-context methods remain available, and the `Do(...)` call style is preserved.
### Breaking Changes
- Users sending webhook certificates through JSON `SetWebhookP.Certificate` must migrate to `Uploader.SetWebhook(...)`.
## v1.0.0-rc.3
### Fixed
- The update polling loop no longer logs or retries after `context.Canceled` during shutdown.
- Extra retry delay was removed from canceled polling requests so `RunWithContext` can exit immediately while stopping.
### Changed
- Shutdown behavior remains explicit: callers are still responsible for invoking `Close()` after `RunWithContext` returns.
## v1.0.0-rc.2
### Fixed
- Fixed a shutdown crash caused by `DatabaseWriter` calling `Close()` through an uninitialized embedded logger writer.
- Fixed bot shutdown hanging during Telegram long polling by making update polling use a cancelable context.
- Reduced the chance of container termination with exit code `137` during shutdown by allowing `getUpdates` to stop promptly on cancellation.
### Changed
- Switched the project to use the local `laniakea` replacement for the shutdown fix.
- Documentation now clarifies that `RunWithContext` does not close resources automatically and callers must invoke `Close()` explicitly.
- `Updates` documentation now describes context-driven cancellation behavior.
### Tests
- Added regression tests for database logger writer shutdown behavior.
-15
View File
@@ -1,15 +0,0 @@
# Проверка наличия golangci-lint
GO_LINT := $(shell command -v golangci-lint 2>/dev/null)
# Цель: запуск всех проверок кода
check:
@echo "🔍 Running code checks..."
@go mod tidy -v
@go vet ./...
@if [ -n "$(GO_LINT)" ]; then \
echo "✅ golangci-lint found, running..." && \
golangci-lint run --timeout=5m --verbose; \
else \
echo "⚠️ golangci-lint not installed. Install with: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.57.2"; \
fi
@go test -race -v ./... 2>/dev/null || echo "⚠️ Tests skipped or failed (run manually with 'go test -race ./...')"
+96 -21
View File
@@ -1,13 +1,17 @@
# Laniakea # Laniakea
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) ![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting. A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting.
[На русском](README_RU.md) [На русском](README_RU.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
--- ---
## ✨ Features ## ✨ Features
@@ -25,7 +29,13 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
## 📦 Installation ## 📦 Installation
```bash ```bash
go get git.nix13.pw/scuroneko/laniakea go get git.scuroneko.dev/scuroneko/laniakea
```
or
```bash
go get github.com/scuroneko/laniakea
``` ```
## 🚀 Quick Start (with step-by-step explanation) ## 🚀 Quick Start (with step-by-step explanation)
@@ -37,17 +47,18 @@ package main
import ( import (
"log" "log"
"git.nix13.pw/scuroneko/laniakea" // Import the Laniakea library "git.scuroneko.dev/scuroneko/laniakea" // Import the Laniakea library
) )
// echo is a command handler function. // echo is a command handler function.
// It receives two parameters: // It receives two parameters:
// - ctx: the message context (contains info about the message, sender, chat, etc.) // - ctx: the message context (contains info about the message, sender, chat, etc.)
// - db: your custom database context (here we use NoDB, a placeholder for no database) // - db: your custom database context (here we use NoDB, a placeholder for no database)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
// Answer the user with the text they sent, without any command prefix. // Answer the user with the text they sent, without any command prefix.
// ctx.Text contains the user's message with the command part stripped off. // ctx.Text contains the user's message with the command part stripped off.
ctx.Answer(ctx.Text) // User input WITHOUT command ctx.Answer(ctx.Text) // User input WITHOUT command
return nil
} }
func main() { func main() {
@@ -56,7 +67,10 @@ func main() {
// 2. Initialize a new bot instance. // 2. Initialize a new bot instance.
// We use laniakea.NoDB as the database context type (no database needed for this example). // We use laniakea.NoDB as the database context type (no database needed for this example).
bot := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoDB](opts)
if err != nil {
log.Fatal(err)
}
// Ensure bot resources are cleaned up on exit. // Ensure bot resources are cleaned up on exit.
defer bot.Close() defer bot.Close()
@@ -70,8 +84,9 @@ func main() {
// 5. Add another command using an anonymous function (closure). // 5. Add another command using an anonymous function (closure).
// This command simply replies "Pong" when the user sends "/ping". // This command simply replies "Pong" when the user sends "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil
}, "ping")) }, "ping"))
// 6. Configure the bot with a custom error template and add the plugin. // 6. Configure the bot with a custom error template and add the plugin.
@@ -86,7 +101,9 @@ func main() {
} }
// 8. Start the bot, listening for updates (long polling). // 8. Start the bot, listening for updates (long polling).
bot.Run() if err := bot.Run(); err != nil {
log.Fatal(err)
}
} }
``` ```
@@ -94,18 +111,19 @@ func main() {
1. `BotOpts`: Holds configuration like the API token. 1. `BotOpts`: Holds configuration like the API token.
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it.
3. `NewPlugin`: Creates a logical group for commands and middlewares. 3. `NewPlugin`: Creates a logical group for commands and middlewares.
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash). 4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash).
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T. 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T, and return an error for centralized error handling.
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
7. `AutoGenerateCommands`: Adds built-in commands (/start, /help) and a command that lists all available commands. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
8. `Run()`: Starts the bot's update polling loop. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
9. A `Bot` instance is single-use. After `Run()` or `RunWithContext()` returns, create a new bot instance for the next session.
## 📖 Core Concepts ## 📖 Core Concepts
### Plugins ### Plugins
Plugins are the main way to organize code. A plugin can have multiple commands and middlewares. Plugins are the main way to organize code. A plugin can have multiple commands and middlewares.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
``` ```
@@ -114,9 +132,10 @@ bot.AddPlugins(plugin)
A command is a function that handles a specific bot command (e.g., /start). A command is a function that handles a specific bot command (e.g., /start).
```go ```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) { func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Access command arguments via ctx.Args ([]string) // Access command arguments via ctx.Args ([]string)
// Reply to the user: ctx.Answer("some text") // Reply to the user: ctx.Answer("some text")
return nil
} }
``` ```
@@ -125,8 +144,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Provides access to the incoming message and useful reply methods: Provides access to the incoming message and useful reply methods:
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none. - `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
- `AnswerLong(text string) []*AnswerMessage`: Splits long plain text into multiple messages.
- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping). - `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping).
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
@@ -145,16 +166,59 @@ Provides access to the incoming message and useful reply methods:
This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`. This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`.
For advanced cases, `tgapi.NewRequest(...)` and `tgapi.NewUploaderRequest(...)` remain public as low-level escape hatches. They are intentionally less safe than method-specific helpers: callers must supply the correct Telegram method name and compatible request/response types themselves.
### Database Context ### Database Context
The `T` in `NewBot[T]` is a powerful feature. You can pass any type (like a database connection pool), and it will be available in every command and middleware handler. The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools should usually use a pointer type.
```go ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
db := &MyDB{...} db := &MyDB{...}
bot := laniakea.NewBot[*MyDB](opts, db) // Pass db instance bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
log.Fatal(err)
}
bot.DatabaseContext(db)
``` ```
### Scenes and Sessions
Scenes model multi-step conversations inside a plugin. Each active scene is stored in a session keyed by scope, so you can isolate flows per user, per chat, or per user-chat pair.
```go
plugin := laniakea.NewPlugin[MyDB]("signup")
plugin.NewScene("signup").
SetScope(laniakea.SceneScopeUserChat).
SetEntry("ask_name").
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
if ctx.Text == "" {
ctx.Answer("What is your name?")
return ctx.Stay(), nil
}
if err := ctx.SaveData(struct {
Name string `json:"name"`
}{Name: ctx.Text}); err != nil {
return laniakea.SceneResult{}, err
}
ctx.Answer("Nice to meet you.")
return ctx.Next("done"), nil
}).
OnStep("done", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
return ctx.Exit(), nil
})
```
- Use `ctx.EnterScene("signup")` to enter the configured entry step.
- Use `ctx.EnterSceneStep("signup", "done")` when you need an explicit starting step.
- Return `ctx.Stay()`, `ctx.Next(step)`, `ctx.Exit()`, or `ctx.Pass()` from scene handlers to control flow.
- `SceneActionPass` keeps the current session unchanged and continues normal bot routing.
- Use `SceneContext.SaveData(...)` and `SceneContext.BindData(...)` for JSON session state.
- Use `SceneScopeUser`, `SceneScopeChat`, or `SceneScopeUserChat` depending on how widely a conversation should be shared.
## 🧩 Middleware ## 🧩 Middleware
Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context. Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context.
@@ -169,11 +233,12 @@ func(ctx *MsgContext, db T) bool
- If it returns false, the execution chain stops immediately (the command will not run). - If it returns false, the execution chain stops immediately (the command will not run).
### Adding Middleware ### Adding Middleware
Use the Use method of a plugin to add one or more middleware functions. They are executed in the order they are added. Use `AddMiddleware` on a plugin to add one or more shared middleware functions. They are executed in the order they are added.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware) plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
``` ```
@@ -202,16 +267,26 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
## ⚙️ Advanced Configuration ## ⚙️ Advanced Configuration
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. - **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. - **Localization**: `L10n` is safe for concurrent use once attached to the bot.
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
- **Lifecycle**: `RunWithContext(...)` does not call `Close()` for you. Shut the bot down explicitly, and create a fresh `Bot` for the next run.
## Telegram Update Handling
- Commands and payloads are handled through plugins.
- Non-command updates can be routed with `plugin.AddUpdateHandler(updateType, handler)`.
- `message`, `channel_post`, and `callback_query` stay on the command/payload flow.
- `tgapi.Update` exposes a derived `Type` field after JSON unmarshalling so handlers can inspect the effective update kind directly.
## 📝 License ## 📝 License
This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details. This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details.
## 📚 Learn More ## 📚 Learn More
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) [GoDoc](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
[Telegram Bot API](https://core.telegram.org/bots/api) [Telegram Bot API](https://core.telegram.org/bots/api)
+109 -27
View File
@@ -1,13 +1,17 @@
# Laniakea # Laniakea
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) ![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке中间件, автоматической генерации команд и встроенному ограничителю скорости запросов. Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру.
[English](README.md) [English](README.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
--- ---
## ✨ Возможности ## ✨ Возможности
@@ -26,7 +30,13 @@
## 📦 Установка ## 📦 Установка
```bash ```bash
go get git.nix13.pw/scuroneko/laniakea go get git.scuroneko.dev/scuroneko/laniakea
```
или
```bash
go get github.com/scuroneko/laniakea
``` ```
## 🚀 Быстрый старт (с пошаговыми комментариями) ## 🚀 Быстрый старт (с пошаговыми комментариями)
@@ -38,17 +48,18 @@ package main
import ( import (
"log" "log"
"git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea "git.scuroneko.dev/scuroneko/laniakea" // Импортируем библиотеку Laniakea
) )
// echo — это функция-обработчик команды. // echo — это функция-обработчик команды.
// Она получает два параметра: // Она получает два параметра:
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) // - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
// Отвечаем пользователю текстом, который он прислал, без префикса команды. // Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
return nil
} }
func main() { func main() {
@@ -57,7 +68,10 @@ func main() {
// 2. Инициализируем новый экземпляр бота. // 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). // Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера).
bot := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoDB](opts)
if err != nil {
log.Fatal(err)
}
// Гарантируем освобождение ресурсов бота при выходе. // Гарантируем освобождение ресурсов бота при выходе.
defer bot.Close() defer bot.Close()
@@ -71,8 +85,9 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil
}, "ping")) }, "ping"))
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
@@ -87,7 +102,9 @@ func main() {
} }
// 8. Запускаем бота, начиная прослушивание обновлений (long polling). // 8. Запускаем бота, начиная прослушивание обновлений (long polling).
bot.Run() if err := bot.Run(); err != nil {
log.Fatal(err)
}
} }
``` ```
@@ -95,18 +112,19 @@ func main() {
1. `BotOpts`: Содержит конфигурацию, например, токен API. 1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша). 4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T. 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот.
## 📖 Основные концепции ## 📖 Основные концепции
### Плагины (Plugins) ### Плагины (Plugins)
Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware. Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
``` ```
@@ -115,9 +133,10 @@ bot.AddPlugins(plugin)
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start). Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
```go ```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) { func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Доступ к аргументам команды через ctx.Args ([]string) // Доступ к аргументам команды через ctx.Args ([]string)
// Ответ пользователю: ctx.Answer("какой-то текст") // Ответ пользователю: ctx.Answer("какой-то текст")
return nil
} }
``` ```
@@ -125,26 +144,78 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Предоставляет доступ к входящему сообщению и полезные методы для ответа: Предоставляет доступ к входящему сообщению и полезные методы для ответа:
- `Answer(text string)`: Отправляет сообщение с parse_mode none. - `Answer(text string)`: Отправляет сообщение с parse_mode none.
- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений.
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне). - `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой. - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки. - `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки. - `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки.
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д. - `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие. - Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
- И много других методов и полей! - И много других методов и полей!
### Контекст базы данных (Database Context) ### Контекст базы данных (Database Context)
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件. Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type.
```go ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
db := &MyDB{...} db := &MyDB{...}
bot := laniakea.NewBot[*MyDB](opts, db) // Передаём экземпляр db bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
log.Fatal(err)
}
bot.DatabaseContext(db)
``` ```
### Сцены и сессии (Scenes and Sessions)
Сцены описывают многошаговые диалоги внутри плагина. Активная сцена хранится в session state, ключ которого зависит от scope, поэтому поток можно изолировать на пользователя, на чат или на пару пользователь-чат.
```go
plugin := laniakea.NewPlugin[MyDB]("signup")
plugin.NewScene("signup").
SetScope(laniakea.SceneScopeUserChat).
SetEntry("ask_name").
OnStep("ask_name", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
if ctx.Text == "" {
ctx.Answer("Как тебя зовут?")
return ctx.Stay(), nil
}
if err := ctx.SaveData(struct {
Name string `json:"name"`
}{Name: ctx.Text}); err != nil {
return laniakea.SceneResult{}, err
}
ctx.Answer("Приятно познакомиться.")
return ctx.Next("done"), nil
}).
OnStep("done", func(ctx *laniakea.SceneContext, db MyDB) (laniakea.SceneResult, error) {
return ctx.Exit(), nil
})
```
- Используйте `ctx.EnterScene("signup")`, чтобы войти в entry step, настроенный у сцены.
- Используйте `ctx.EnterSceneStep("signup", "done")`, если нужен явный стартовый step.
- Из scene handler возвращайте `ctx.Stay()`, `ctx.Next(step)`, `ctx.Exit()` или `ctx.Pass()` для управления потоком.
- `SceneActionPass` не меняет текущую session state и продолжает обычный routing бота.
- Для JSON-состояния сцены используйте `SceneContext.SaveData(...)` и `SceneContext.BindData(...)`.
- Выбирайте `SceneScopeUser`, `SceneScopeChat` или `SceneScopeUserChat` в зависимости от того, насколько широко должен разделяться диалог.
### tgapi: API и Uploader
В `tgapi` есть два клиента:
- `API` для JSON-запросов (`SendMessage`, `EditMessageText`, методы с `file_id`/URL).
- `Uploader` для multipart-загрузок (`SendPhoto`, `SendDocument`, `SendVideo` с бинарными файлами).
Для продвинутых сценариев `tgapi.NewRequest(...)` и `tgapi.NewUploaderRequest(...)` остаются публичными low-level escape hatch API. Они менее безопасны, чем типизированные helper-методы: вызывающая сторона сама отвечает за корректное имя Telegram-метода и совместимые типы параметров/ответа.
## 🧩 Промежуточные слои (Middleware) ## 🧩 Промежуточные слои (Middleware)
Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста. Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста.
@@ -159,11 +230,12 @@ func(ctx *MsgContext, db T) bool
- Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается). - Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается).
### Добавление middleware ### Добавление middleware
Используйте метод Use плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления. Используйте метод `AddMiddleware` плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware) plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
``` ```
@@ -192,15 +264,25 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
## ⚙️ Расширенная настройка ## ⚙️ Расширенная настройка
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard(). - **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram. - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
- **Жизненный цикл**: `RunWithContext(...)` не вызывает `Close()` автоматически. Завершайте бот явно и создавайте новый `Bot` для следующего запуска.
## Обработка Telegram Updates
- Команды и payload-ы обрабатываются через плагины.
- Для некомандных update-ов можно зарегистрировать обработчик через `plugin.AddUpdateHandler(updateType, handler)`.
- `message`, `channel_post` и `callback_query` остаются в command/payload flow.
- После JSON-декодирования `tgapi.Update` заполняет поле `Type`, чтобы обработчики могли явно видеть итоговый вид update.
## 📝 Лицензия ## 📝 Лицензия
Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE). Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE).
## 📚 Дополнительная информация ## 📚 Дополнительная информация
[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) [GoDoc Laniakea](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
[Telegram Bot API](https://core.telegram.org/bots/api) [Telegram Bot API](https://core.telegram.org/bots/api)
+45
View File
@@ -0,0 +1,45 @@
# Semantic Versioning Policy
This project follows Semantic Versioning with the rules below.
## Public API Surface
The public API consists of:
- exported identifiers in package `laniakea`
- exported identifiers in package `tgapi`
- documented behavior in `README.md`, `README_RU.md`, and package godoc
Anything unexported is internal and may change without notice.
## Breaking Changes
A release requires a major version bump when it changes any of the following:
- exported function, method, type, field, constant, or variable names
- function or method signatures
- JSON field names or request/response wire compatibility in `tgapi`
- documented behavioral guarantees relied on by callers
Examples:
- removing an exported alias
- changing callback payload encoding defaults
- changing handler dispatch semantics in a way that breaks existing bots
## Minor Changes
A release uses a minor version bump for backward-compatible additions:
- new exported types, methods, helpers, or update handlers
- support for new Telegram Bot API fields or methods
- optional configuration knobs that do not change existing defaults
## Patch Changes
A release uses a patch version bump for backward-compatible fixes:
- bug fixes
- test-only changes
- godoc and README clarifications
- internal refactors with no public behavior change
## Pre-Releases
`-rc.N` builds may still adjust API details before `v1.0.0`.
Once `v1.0.0` is released, breaking changes require a new major version.
+23
View File
@@ -0,0 +1,23 @@
# TODO
The framework backlog has moved to the wiki.
Primary page:
- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog
Russian page:
- https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Framework-Backlog-RU
Current priority split:
- `Priority 1`: update schema contract, user-facing vs internal error model, configuration freeze model.
- `Priority 2`: webhook runtime model, authorization and policy model, observability model.
- `Priority 3`: service layer and dependency graph model, plugin composition contract.
Completed former high-priority items:
- `1. Conversation / Scene Model`: completed in `v1.0.0-rc.12`.
- `2. Typed Handler Input Model`: completed in `v1.0.0-rc.12`.
- `3. Request Context / Cancellation Model`: completed in `v1.0.0-rc.12`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

+386 -82
View File
@@ -4,25 +4,33 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"maps"
"reflect"
"slices"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
"github.com/alitto/pond/v2" "github.com/alitto/pond/v2"
) )
// DbContext is an interface representing the application's database context. // DbContext is the generic dependency type injected into bots, plugins, and handlers.
// It is injected into plugins and middleware via Bot.DatabaseContext(). // Use it for shared application state such as database handles or service containers.
// //
// Example: // Example:
// //
// type MyDB struct { ... } // type MyDB struct { ... }
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB) // myDB := &MyDB{}
// bot, err := NewBot[*MyDB](opts)
// if err != nil {
// return err
// }
// bot.DatabaseContext(myDB)
// //
// Use NoDB if no database is needed. // Use NoDB if no database is needed.
type DbContext any type DbContext any
@@ -33,7 +41,7 @@ type NoDB struct{ DbContext }
// DbLogger is a function type that returns a slog.LoggerWriter for database logging. // DbLogger is a function type that returns a slog.LoggerWriter for database logging.
// Used to inject database-specific log output (e.g., SQL queries, ORM events). // Used to inject database-specific log output (e.g., SQL queries, ORM events).
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter type DbLogger[T DbContext] func(db T) slog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads. // BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string type BotPayloadType string
@@ -45,6 +53,20 @@ var (
BotPayloadJson BotPayloadType = "json" BotPayloadJson BotPayloadType = "json"
) )
var (
// ErrNoPrefixes reports that the bot was started without any command prefixes.
ErrNoPrefixes = errors.New("no prefixes defined")
// ErrNoPlugins reports that the bot was started without any registered plugins.
ErrNoPlugins = errors.New("no plugins defined")
// ErrBotAlreadyRun reports that Run or RunWithContext was called more than once.
ErrBotAlreadyRun = errors.New("bot can only be run once")
// ErrTokenRequired reports that BotOpts.Token was empty.
ErrTokenRequired = errors.New("token required")
// ErrOptsIsNil reports that NewBot was called with a nil BotOpts pointer.
ErrOptsIsNil = errors.New("opts is nil")
)
// Bot is the core Telegram bot instance. // Bot is the core Telegram bot instance.
// //
// Manages: // Manages:
@@ -54,14 +76,16 @@ var (
// - Logging and rate limiting // - Logging and rate limiting
// - Localization and draft message support // - Localization and draft message support
// //
// All methods are safe for concurrent use. Direct field access is not recommended. // Runtime accessors are safe for concurrent use. Configure the bot before Run.
// A Bot is single-use: after Run or RunWithContext returns, create a new Bot for the next session.
type Bot[T DbContext] struct { type Bot[T DbContext] struct {
token string token string
debug bool debug bool
errorTemplate string errorTemplate string
username string username string
payloadType BotPayloadType payloadType BotPayloadType
maxWorkers int strictPayloadType bool
maxWorkers int
logger *slog.Logger // Main bot logger (JSON stdout + optional file) logger *slog.Logger // Main bot logger (JSON stdout + optional file)
RequestLogger *slog.Logger // Optional request-level API logging RequestLogger *slog.Logger // Optional request-level API logging
@@ -74,9 +98,14 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader uploader *tgapi.Uploader // File uploader
dbContext *T // Injected database context dbContext T // Injected database context
l10n *L10n // Localization manager hasDBContext bool
draftProvider *DraftProvider // Draft message builder warnedValueDB bool
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder
sessionStore SessionStore // Session store for scene management
sceneScopePriority []SceneScope
updateOffsetMu sync.Mutex updateOffsetMu sync.Mutex
updateOffset int // Last processed update ID updateOffset int // Last processed update ID
@@ -84,6 +113,9 @@ type Bot[T DbContext] struct {
updateQueue chan *tgapi.Update // Internal queue for processing updates updateQueue chan *tgapi.Update // Internal queue for processing updates
runnerOnceWG sync.WaitGroup // Tracks one-time async runners runnerOnceWG sync.WaitGroup // Tracks one-time async runners
runnerBgWG sync.WaitGroup // Tracks background async runners runnerBgWG sync.WaitGroup // Tracks background async runners
runStateMu sync.Mutex
running bool
ran bool
} }
// NewBot creates and initializes a new Bot instance using the provided BotOpts. // NewBot creates and initializes a new Bot instance using the provided BotOpts.
@@ -94,13 +126,12 @@ type Bot[T DbContext] struct {
// - Fetches bot username via GetMe() // - Fetches bot username via GetMe()
// - Sets up DraftProvider with random IDs // - Sets up DraftProvider with random IDs
// - Adds API and Uploader loggers to extraLoggers // - Adds API and Uploader loggers to extraLoggers
// func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
// Panics if: if opts == nil {
// - Token is empty return nil, ErrOptsIsNil
// - GetMe() fails (invalid token or network error) }
func NewBot[T any](opts *BotOpts) *Bot[T] {
if opts.Token == "" { if opts.Token == "" {
panic("laniakea: BotOpts.Token is required") return nil, ErrTokenRequired
} }
updateQueue := make(chan *tgapi.Update, 512) updateQueue := make(chan *tgapi.Update, 512)
@@ -131,22 +162,26 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
} }
bot := &Bot[T]{ bot := &Bot[T]{
updateOffset: 0, updateOffset: 0,
errorTemplate: "%s", errorTemplate: "%s",
payloadType: BotPayloadBase64, payloadType: BotPayloadBase64,
maxWorkers: workers, strictPayloadType: opts.StrictPayloadType,
updateQueue: updateQueue, maxWorkers: workers,
api: api, updateQueue: updateQueue,
uploader: uploader, api: api,
debug: opts.Debug, uploader: uploader,
prefixes: prefixes, debug: opts.Debug,
token: opts.Token, prefixes: prefixes,
plugins: make([]Plugin[T], 0), token: opts.Token,
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), plugins: make([]Plugin[T], 0),
runners: make([]Runner[T], 0), updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
extraLoggers: make([]*slog.Logger, 0), runners: make([]Runner[T], 0),
l10n: &L10n{}, extraLoggers: make([]*slog.Logger, 0),
draftProvider: NewRandomDraftProvider(api), l10n: &L10n{},
draftProvider: NewRandomDraftProvider(api),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
} }
// Add API and Uploader loggers to extraLoggers for unified output // Add API and Uploader loggers to extraLoggers for unified output
@@ -164,7 +199,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
u, err := api.GetMe() u, err := api.GetMe()
if err != nil { if err != nil {
_ = bot.Close() _ = bot.Close()
bot.logger.Fatal(err) return nil, err
} }
bot.username = Val(u.Username, "") bot.username = Val(u.Username, "")
if bot.username == "" { if bot.username == "" {
@@ -172,29 +207,35 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
} }
bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown"))) bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown")))
return bot return bot, nil
} }
// Close gracefully shuts down bot-owned resources. // Close gracefully shuts down bot-owned resources.
// //
// Closes: // Close shuts down, in order:
// - Registered plugins via Plugin.Close
// - Uploader (waits for pending uploads) // - Uploader (waits for pending uploads)
// - API client // - API client internals
// - RequestLogger (if enabled) // - RequestLogger (if enabled)
// - Main logger // - Main logger
// //
// RunWithContext does not call Close automatically. The caller is responsible // RunWithContext does not call Close automatically. The caller is responsible
// for invoking Close after RunWithContext returns to release these resources. // for invoking Close after RunWithContext returns to release these resources.
// //
// Returns a joined error containing all shutdown failures, if any. // Close returns a joined error containing all shutdown failures, if any.
func (bot *Bot[T]) Close() error { func (bot *Bot[T]) Close() error {
var e []error var e []error
for _, p := range bot.plugins {
if err := p.Close(); err != nil {
e = append(e, err)
}
}
if err := bot.uploader.Close(); err != nil { if err := bot.uploader.Close(); err != nil {
bot.logger.Errorln(err) bot.logger.Errorln(err)
e = append(e, err) e = append(e, err)
} }
if err := bot.api.CloseApi(); err != nil { if err := bot.api.Close(); err != nil {
bot.logger.Errorln(err) bot.logger.Errorln(err)
e = append(e, err) e = append(e, err)
} }
@@ -210,38 +251,45 @@ func (bot *Bot[T]) Close() error {
return errors.Join(e...) return errors.Join(e...)
} }
// initLoggers configures the main and optional request loggers. // CloseRemote sends Telegram Bot API "close" request for the current bot
// instance using ctx for cancellation and deadlines.
// //
// Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise). // This is separate from Bot.Close(), which only releases local resources.
// Writes to stdout in JSON format by default. func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath. if _, err := bot.api.CloseRemoteWithContext(ctx); err != nil {
return err
}
return nil
}
// Internal logger setup for the bot and optional request logger.
func (bot *Bot[T]) initLoggers(opts *BotOpts) { func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level := slog.FATAL level := slog.FATAL
if opts.Debug { if opts.Debug {
level = slog.DEBUG level = slog.DEBUG
} }
bot.logger = slog.CreateLogger().Level(level).Prefix("BOT") bot.logger = utils.CreateLogger("BOT", level)
bot.logger.AddWriter(bot.logger.CreateJsonStdoutWriter())
if opts.WriteToFile { if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/")) path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
fileWriter, err := bot.logger.CreateTextFileWriter(path) logger, err := utils.CreateFileLogger("BOT", level, path)
if err != nil { if err != nil {
bot.logger.Fatal(err) bot.logger.Errorln(err)
} else {
bot.logger = logger
} }
bot.logger.AddWriter(fileWriter)
} }
if opts.UseRequestLogger { if opts.UseRequestLogger {
bot.RequestLogger = slog.CreateLogger().Level(level).Prefix("REQUESTS") bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
bot.RequestLogger.AddWriter(bot.RequestLogger.CreateJsonStdoutWriter())
if opts.WriteToFile { if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/")) path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
fileWriter, err := bot.RequestLogger.CreateTextFileWriter(path) logger, err := utils.CreateFileLogger("REQUESTS", level, path)
if err != nil { if err != nil {
bot.logger.Fatal(err) bot.logger.Errorln(err)
} else {
bot.RequestLogger = logger
} }
bot.RequestLogger.AddWriter(fileWriter)
} }
} }
} }
@@ -261,14 +309,26 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) {
} }
// GetUpdateTypes returns the list of update types the bot is configured to receive. // GetUpdateTypes returns the list of update types the bot is configured to receive.
func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return bot.updateTypes } func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType {
return append([]tgapi.UpdateType(nil), bot.updateTypes...)
}
// GetLogger returns the main bot logger. // GetLogger returns the main bot logger.
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
// GetDBContext returns the injected database context. // GetDBContext returns the injected database context.
// Returns nil if not set via DatabaseContext(). // If DatabaseContext was not called, it returns the zero value of T.
func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } func (bot *Bot[T]) GetDBContext() T { return bot.dbContext }
// GetLoggerLevel returns the effective log level derived from the bot's debug
// flag.
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
level := slog.FATAL
if bot.debug {
level = slog.DEBUG
}
return level
}
// L10n translates a key in the given language. // L10n translates a key in the given language.
// Returns empty string if translation not found. // Returns empty string if translation not found.
@@ -283,10 +343,60 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
return bot return bot
} }
// GetDraftProvider returns the draft provider currently used by the bot.
func (bot *Bot[T]) GetDraftProvider() *DraftProvider {
return bot.draftProvider
}
// SetSessionStore replaces the session store used for scene management.
func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] {
if store == nil {
bot.logger.Warn("SetSessionStore called with nil store; using default MemorySessionStore")
return bot
}
bot.sessionStore = store
return bot
}
// GetSessionStore returns the session store used for scene management.
func (bot *Bot[T]) GetSessionStore() SessionStore {
return bot.sessionStore
}
// SetSceneScopePriority sets the lookup order for resolving active scene sessions.
func (bot *Bot[T]) SetSceneScopePriority(priority []SceneScope) *Bot[T] {
newPriority := make([]SceneScope, 0, 3)
for _, scope := range priority {
if scope != SceneScopeUser && scope != SceneScopeChat && scope != SceneScopeUserChat {
bot.logger.Warnln(fmt.Sprintf("invalid scene scope %v in priority list; ignoring", scope))
continue
}
if slices.Index(newPriority, scope) >= 0 {
bot.logger.Warnln(fmt.Sprintf("duplicate scope %v in scene scope priority; ignoring duplicates", scope))
continue
}
newPriority = append(newPriority, scope)
}
if len(newPriority) == 0 || len(newPriority) > 3 {
bot.logger.Warnln("scene scope priority must have 1 to 3 scopes; ignoring invalid input")
return bot
}
bot.sceneScopePriority = append([]SceneScope(nil), newPriority...)
return bot
}
// DatabaseContext injects a database context into the bot. // DatabaseContext injects a database context into the bot.
// This context is accessible to plugins and middleware via GetDBContext(). // This context is accessible to plugins and middleware via GetDBContext().
func (bot *Bot[T]) DatabaseContext(ctx *T) *Bot[T] { // For shared dependencies such as *sql.DB, prefer using a pointer type as T.
// Value-typed contexts are supported, but the bot warns once because handlers
// receive T by value.
func (bot *Bot[T]) DatabaseContext(ctx T) *Bot[T] {
if !bot.warnedValueDB && shouldWarnOnValueDBContext[T]() && bot.logger != nil {
bot.logger.Warnln("database context uses a value type; shared dependencies should usually use a pointer type as T")
bot.warnedValueDB = true
}
bot.dbContext = ctx bot.dbContext = ctx
bot.hasDBContext = true
return bot return bot
} }
@@ -298,14 +408,25 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
return bot return bot
} }
// SetPayloadType sets the payload encoding type used for callback data. // SetPayloadType sets the default payload encoding type used for callback data.
// JSON stores payload as a string: `{"cmd":"command","args":[...]}`. // JSON stores payload as a string: `{"cmd":"command","args":[...]}`.
// Base64 stores the same JSON encoded as a Base64URL string. // Base64 stores the same JSON encoded as a Base64URL string.
// InlineKeyboard.SetPayloadType may override this value for an individual keyboard.
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] { func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
bot.payloadType = t bot.payloadType = t
return bot return bot
} }
// GetPayloadType returns the bot's default callback payload encoding type.
func (bot *Bot[T]) GetPayloadType() BotPayloadType { return bot.payloadType }
// SetStrictPayloadType enables or disables strict callback payload decoding.
// When enabled, callback payloads must match the bot's default payload type.
func (bot *Bot[T]) SetStrictPayloadType(strict bool) *Bot[T] {
bot.strictPayloadType = strict
return bot
}
// AddUpdateType adds one or more update types to the list. // AddUpdateType adds one or more update types to the list.
// Does not overwrite existing types. // Does not overwrite existing types.
func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] { func (bot *Bot[T]) AddUpdateType(t ...tgapi.UpdateType) *Bot[T] {
@@ -331,15 +452,48 @@ func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] {
// Debug enables or disables debug logging. // Debug enables or disables debug logging.
func (bot *Bot[T]) Debug(debug bool) *Bot[T] { func (bot *Bot[T]) Debug(debug bool) *Bot[T] {
bot.debug = debug bot.debug = debug
level := slog.FATAL
if debug {
level = slog.DEBUG
}
bot.logger.Level(level)
if bot.RequestLogger != nil {
bot.RequestLogger.Level(level)
}
for _, p := range bot.plugins {
if p.logger == nil {
continue
}
p.logger.Level(level)
}
return bot return bot
} }
// AddPlugins registers one or more plugins. // AddPlugins registers one or more plugins.
// Plugins are executed in registration order unless filtered by middleware. // Plugins are executed in registration order unless filtered by middleware.
//
// Registration is a commit point for plugin configuration. The Bot stores
// plugin metadata internally, so plugins must be fully configured before they
// are passed here. Post-registration mutation through the original *Plugin is
// not a supported API, even if some changes appear to work due to shared maps.
func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] { func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
level := bot.GetLoggerLevel()
for _, p := range plugin { for _, p := range plugin {
bot.plugins = append(bot.plugins, *p) if p == nil {
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.name)) if bot.logger != nil {
bot.logger.Warn("nil plugin skipped")
}
continue
}
cloned := clonePlugin(p)
if cloned.logger == nil {
cloned.logger = utils.CreateLogger(cloned.name, level)
}
bot.plugins = append(bot.plugins, cloned)
if bot.logger != nil {
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", cloned.name))
}
} }
return bot return bot
} }
@@ -356,13 +510,14 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
// //
// Example: // Example:
// //
// bot.AddMiddleware(&authMiddleware, &rateLimitMiddleware) // bot.AddMiddleware(authMiddleware, rateLimitMiddleware)
// //
// Panics if any middleware has a nil name. // Middleware with an empty name are skipped with a warning.
func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] { func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] {
for _, m := range middleware { for _, m := range middleware {
if m.name == "" { if m.name == "" {
panic("laniakea: middleware must have a non-empty name") bot.logger.Warnln("middleware must have a non-empty name")
continue
} }
bot.middlewares = append(bot.middlewares, m) bot.middlewares = append(bot.middlewares, m)
bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name)) bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name))
@@ -393,12 +548,13 @@ func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] {
// //
// Example: // Example:
// //
// bot.AddRunner(&cleanupRunner) // bot.AddRunner(cleanupRunner)
// //
// Panics if runner has a nil name. // Runners with an empty name are skipped with a warning.
func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] { func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
if runner.name == "" { if runner.name == "" {
panic("laniakea: runner must have a non-empty name") bot.logger.Warnln("runner must have a non-empty name")
return bot
} }
bot.runners = append(bot.runners, runner) bot.runners = append(bot.runners, runner)
bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name)) bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name))
@@ -433,6 +589,11 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
// - Main bot logger // - Main bot logger
// - Request logger (if enabled) // - Request logger (if enabled)
// - API and Uploader loggers // - API and Uploader loggers
// - Already registered plugin loggers
//
// Call this after AddPlugins if plugin loggers should also receive the writer.
// Plugins registered later do not automatically inherit previously added
// database writers; call AddDatabaseLoggerWriter again after adding them.
// //
// Example: // Example:
// //
@@ -440,6 +601,14 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
// return db.QueryLogger() // return db.QueryLogger()
// }) // })
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] { func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
if !bot.hasDBContext {
bot.logger.Warnln("database context is not set; skipping database logger writer")
return bot
}
if isNilValue(bot.dbContext) {
bot.logger.Warnln("database context is nil; skipping database logger writer")
return bot
}
w := writer(bot.dbContext) w := writer(bot.dbContext)
bot.logger.AddWriter(w) bot.logger.AddWriter(w)
if bot.RequestLogger != nil { if bot.RequestLogger != nil {
@@ -448,6 +617,11 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
for _, l := range bot.extraLoggers { for _, l := range bot.extraLoggers {
l.AddWriter(w) l.AddWriter(w)
} }
for _, p := range bot.plugins {
if p.logger != nil {
p.logger.AddWriter(w)
}
}
return bot return bot
} }
@@ -474,16 +648,20 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
// // ... later ... // // ... later ...
// cancel() // triggers graceful shutdown // cancel() // triggers graceful shutdown
// _ = bot.Close() // _ = bot.Close()
func (bot *Bot[T]) RunWithContext(ctx context.Context) { //
// A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun.
func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
if len(bot.prefixes) == 0 { if len(bot.prefixes) == 0 {
bot.logger.Fatalln("no prefixes defined") return ErrNoPrefixes
return
} }
if len(bot.plugins) == 0 { if len(bot.plugins) == 0 {
bot.logger.Fatalln("no plugins defined") return ErrNoPlugins
return
} }
if err := bot.beginRun(); err != nil {
return err
}
defer bot.finishRun()
bot.ExecRunners(ctx) bot.ExecRunners(ctx)
@@ -497,6 +675,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
} }
close(bot.updateQueue) close(bot.updateQueue)
}() }()
retryDelay := time.Duration(0)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -504,10 +683,23 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
default: default:
updates, err := bot.Updates(ctx) updates, err := bot.Updates(ctx)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
return
}
bot.logger.Errorln("failed to fetch updates:", err) bot.logger.Errorln("failed to fetch updates:", err)
time.Sleep(time.Second) // exponential backoff retryDelay = nextPollRetryDelay(retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
continue continue
} }
retryDelay = 0
for _, update := range updates { for _, update := range updates {
u := update // copy loop variable to avoid race condition u := update // copy loop variable to avoid race condition
@@ -526,12 +718,13 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
for update := range bot.updateQueue { for update := range bot.updateQueue {
u := update // capture loop variable u := update // capture loop variable
pool.Submit(func() { pool.Submit(func() {
bot.handle(u) bot.handle(ctx, u)
}) })
} }
pool.Stop() // Wait for all tasks to complete and stop the pool pool.Stop() // Wait for all tasks to complete and stop the pool
bot.runnerOnceWG.Wait() bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait() bot.runnerBgWG.Wait()
return nil
} }
// Run starts the bot using a background context. // Run starts the bot using a background context.
@@ -540,6 +733,117 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
// Use this for simple bots where graceful shutdown is not required. // Use this for simple bots where graceful shutdown is not required.
// //
// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully. // For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully.
func (bot *Bot[T]) Run() { func (bot *Bot[T]) Run() error {
bot.RunWithContext(context.Background()) return bot.RunWithContext(context.Background())
}
func (bot *Bot[T]) beginRun() error {
bot.runStateMu.Lock()
defer bot.runStateMu.Unlock()
if bot.running || bot.ran {
return ErrBotAlreadyRun
}
bot.running = true
bot.ran = true
return nil
}
func (bot *Bot[T]) finishRun() {
bot.runStateMu.Lock()
bot.running = false
bot.runStateMu.Unlock()
}
func nextPollRetryDelay(prev time.Duration) time.Duration {
if prev <= 0 {
return time.Second
}
next := prev * 2
if next > 30*time.Second {
return 30 * time.Second
}
return next
}
func isNilValue[T any](v T) bool {
rv := reflect.ValueOf(v)
if !rv.IsValid() {
return true
}
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
func shouldWarnOnValueDBContext[T any]() bool {
t := reflect.TypeFor[T]()
if t == reflect.TypeFor[NoDB]() {
return false
}
switch t.Kind() {
case reflect.Pointer, reflect.Interface, reflect.Map, reflect.Slice, reflect.Func, reflect.Chan:
return false
default:
return true
}
}
func clonePlugin[T DbContext](p *Plugin[T]) Plugin[T] {
cloned := Plugin[T]{
name: p.name,
commands: make(map[string]*Command[T], len(p.commands)),
payloads: make(map[string]*Command[T], len(p.payloads)),
scenes: make(map[string]*Scene[T], len(p.scenes)),
middlewares: append(extypes.Slice[Middleware[T]](nil), p.middlewares...),
skipAutoCmd: p.skipAutoCmd,
logger: p.logger,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
onClose: p.onClose,
}
for name, command := range p.commands {
cloned.commands[name] = cloneCommand(command)
}
for name, command := range p.payloads {
cloned.payloads[name] = cloneCommand(command)
}
for name, scene := range p.scenes {
cloned.scenes[name] = cloneScene(scene)
}
maps.Copy(cloned.handlers, p.handlers)
return cloned
}
func cloneCommand[T DbContext](command *Command[T]) *Command[T] {
if command == nil {
return nil
}
cloned := *command
cloned.args = append(extypes.Slice[CommandArg](nil), command.args...)
cloned.middlewares = append(extypes.Slice[Middleware[T]](nil), command.middlewares...)
return &cloned
}
func cloneScene[T DbContext](scene *Scene[T]) *Scene[T] {
if scene == nil {
return nil
}
cloned := *scene
cloned.steps = make(map[string]SceneHandler[T], len(scene.steps))
cloned.commands = make(map[string]SceneHandler[T], len(scene.commands))
for name, handler := range scene.steps {
cloned.steps[name] = handler
}
for name, handler := range scene.commands {
cloned.commands[name] = handler
}
return &cloned
} }
+35 -10
View File
@@ -5,13 +5,13 @@ import (
"strconv" "strconv"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// BotOpts holds configuration options for initializing a Bot. // BotOpts holds configuration options for initializing a Bot.
// //
// Values are loaded from environment variables via LoadOptsFromEnv(). // Values are loaded from environment variables via LoadOptsFromEnv().
// Use NewOpts() to create a zero-value struct and set fields manually. // Use &BotOpts{} to create a value and set fields manually.
type BotOpts struct { type BotOpts struct {
// Token is the Telegram bot token (required). // Token is the Telegram bot token (required).
Token string Token string
@@ -56,7 +56,11 @@ type BotOpts struct {
// Use this to prioritize responsiveness over reliability. // Use this to prioritize responsiveness over reliability.
DropRLOverflow bool DropRLOverflow bool
// MaxWorkers is the maximum number of concurrency running update handlers. // StrictPayloadType disables callback payload fallback decoding.
// When enabled, the bot accepts only the configured default payload type.
StrictPayloadType bool
// MaxWorkers is the maximum number of update handlers that may run concurrently.
MaxWorkers int MaxWorkers int
} }
@@ -75,20 +79,31 @@ type BotOpts struct {
// - API_URL: custom API endpoint // - API_URL: custom API endpoint
// - RATE_LIMIT: max requests per second (default: 30) // - RATE_LIMIT: max requests per second (default: 30)
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
// //
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined. // Returns a populated BotOpts.
// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing.
func LoadOptsFromEnv() *BotOpts { func LoadOptsFromEnv() *BotOpts {
rateLimit := 30 rateLimit := 30
maxWorkers := 32
stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES"))
updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes))
for _, updateType := range stringUpdateTypes {
updateTypes = append(updateTypes, tgapi.UpdateType(updateType))
}
if rl := os.Getenv("RATE_LIMIT"); rl != "" { if rl := os.Getenv("RATE_LIMIT"); rl != "" {
if n, err := strconv.Atoi(rl); err == nil { if n, err := strconv.Atoi(rl); err == nil {
rateLimit = n rateLimit = n
} }
} }
stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) if mw := os.Getenv("MAX_WORKERS"); mw != "" {
updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil {
for _, updateType := range stringUpdateTypes { maxWorkers = n
updateTypes = append(updateTypes, tgapi.UpdateType(updateType)) }
} }
return &BotOpts{ return &BotOpts{
@@ -106,8 +121,11 @@ func LoadOptsFromEnv() *BotOpts {
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"), APIUrl: os.Getenv("API_URL"),
RateLimit: rateLimit, RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
MaxWorkers: maxWorkers,
} }
} }
@@ -196,6 +214,13 @@ func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
return opts return opts
} }
// SetStrictPayloadType enables or disables strict callback payload decoding.
// When enabled, the bot accepts only the configured default payload type.
func (opts *BotOpts) SetStrictPayloadType(strict bool) *BotOpts {
opts.StrictPayloadType = strict
return opts
}
// SetMaxWorkers sets the maximum number of concurrent update handlers. // SetMaxWorkers sets the maximum number of concurrent update handlers.
// Must be called before NewBot, as the value is captured during bot creation. // Must be called before NewBot, as the value is captured during bot creation.
// //
+10 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) { func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) {
@@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
t.Fatalf("unexpected prefixes: got %v want %v", got, want) t.Fatalf("unexpected prefixes: got %v want %v", got, want)
} }
} }
func TestLoadOptsFromEnvReadsStrictPayloadType(t *testing.T) {
t.Setenv("STRICT_PAYLOAD_TYPE", "true")
opts := LoadOptsFromEnv()
if !opts.StrictPayloadType {
t.Fatal("expected StrictPayloadType to be enabled")
}
}
+59
View File
@@ -0,0 +1,59 @@
package laniakea
func (bot *Bot[T]) getSession(key string) (SceneSession, error) {
return bot.sessionStore.Get(key)
}
func (bot *Bot[T]) setSession(key string, session SceneSession) error {
return bot.sessionStore.Set(key, session)
}
func (bot *Bot[T]) deleteSession(key string) error {
return bot.sessionStore.Delete(key)
}
func (bot *Bot[T]) findScene(name string) (*sceneMeta, bool) {
for _, plugin := range bot.plugins {
scene, ok := plugin.scenes[name]
if !ok {
continue
}
steps := make(map[string]struct{}, len(scene.steps))
for step := range scene.steps {
steps[step] = struct{}{}
}
return &sceneMeta{
Name: scene.Name,
Scope: scene.Scope,
Entry: scene.Entry,
Steps: steps,
}, true
}
return nil, false
}
func (bot *Bot[T]) findSceneSession(ctx *MsgContext) (string, SceneSession, error) {
var zero SceneSession
for _, scope := range bot.sceneScopePriority {
key, ok := buildSceneKey(scope, ctx)
if !ok {
continue
}
session, err := bot.sessionStore.Get(key)
if err != nil {
return "", zero, err
}
if session.Scene != "" {
return key, session, nil
}
}
return "", zero, ErrCantFindSession
}
func (bot *Bot[T]) buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
return buildSceneKey(scope, ctx)
}
+217
View File
@@ -0,0 +1,217 @@
package laniakea
import (
"context"
"errors"
"path/filepath"
"reflect"
"testing"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestGetUpdateTypesReturnsCopy(t *testing.T) {
bot := &Bot[NoDB]{updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage}}
got := bot.GetUpdateTypes()
got[0] = tgapi.UpdateTypeCallbackQuery
if want := []tgapi.UpdateType{tgapi.UpdateTypeMessage}; !reflect.DeepEqual(bot.updateTypes, want) {
t.Fatalf("GetUpdateTypes exposed internal slice: got %v want %v", bot.updateTypes, want)
}
}
func TestAddPluginsSnapshotsConfiguration(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "start")
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoDB) bool { return true }))
bot.AddPlugins(plugin)
cmd.SetDescription("mutated after registration")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error { return nil }, "late")
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoDB) bool { return true }))
registered := bot.plugins[0]
if _, exists := registered.commands["late"]; exists {
t.Fatal("late command leaked into registered plugin snapshot")
}
if registered.commands["start"].description != "" {
t.Fatalf("registered command description unexpectedly mutated: %q", registered.commands["start"].description)
}
if len(registered.middlewares) != 1 {
t.Fatalf("registered middlewares unexpectedly mutated: got %d want 1", len(registered.middlewares))
}
}
func TestBotPayloadTypeConfiguration(t *testing.T) {
bot := &Bot[NoDB]{payloadType: BotPayloadBase64}
if got := bot.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected initial payload type: %q", got)
}
bot.SetPayloadType(BotPayloadJson)
if got := bot.GetPayloadType(); got != BotPayloadJson {
t.Fatalf("unexpected updated payload type: %q", got)
}
bot.SetStrictPayloadType(true)
if !bot.strictPayloadType {
t.Fatal("expected strict payload type to be enabled")
}
}
func TestAddPluginsSkipsNilPlugin(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoDB]("demo")
bot.AddPlugins(nil, plugin)
if len(bot.plugins) != 1 {
t.Fatalf("expected exactly one registered plugin, got %d", len(bot.plugins))
}
if bot.plugins[0].name != "demo" {
t.Fatalf("unexpected plugin name: %q", bot.plugins[0].name)
}
}
func TestInitLoggersFallsBackToStdoutLoggerOnFileError(t *testing.T) {
bot := &Bot[NoDB]{}
bot.initLoggers(&BotOpts{
Debug: true,
WriteToFile: true,
UseRequestLogger: true,
LoggerBasePath: filepath.Join(t.TempDir(), "missing", "nested"),
})
if bot.logger == nil {
t.Fatal("expected main logger fallback")
}
if bot.RequestLogger == nil {
t.Fatal("expected request logger fallback")
}
if err := bot.RequestLogger.Close(); err != nil {
t.Fatalf("failed to close request logger: %v", err)
}
if err := bot.logger.Close(); err != nil {
t.Fatalf("failed to close main logger: %v", err)
}
}
func TestNextPollRetryDelay(t *testing.T) {
tests := []struct {
name string
prev time.Duration
want time.Duration
}{
{name: "initial", prev: 0, want: time.Second},
{name: "double", prev: 2 * time.Second, want: 4 * time.Second},
{name: "cap", prev: 20 * time.Second, want: 30 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nextPollRetryDelay(tt.prev); got != tt.want {
t.Fatalf("nextPollRetryDelay(%s) = %s, want %s", tt.prev, got, tt.want)
}
})
}
}
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsUnset(t *testing.T) {
bot := &Bot[NoDB]{logger: slog.CreateLogger()}
called := false
bot.AddDatabaseLoggerWriter(func(db NoDB) slog.LoggerWriter {
called = true
return nil
})
if called {
t.Fatal("expected database logger writer to be skipped when db context is unset")
}
}
func TestAddDatabaseLoggerWriterSkipsWhenDBContextIsNil(t *testing.T) {
type testDB struct{}
bot := &Bot[*testDB]{logger: slog.CreateLogger()}
var db *testDB
bot.DatabaseContext(db)
called := false
bot.AddDatabaseLoggerWriter(func(db *testDB) slog.LoggerWriter {
called = true
return nil
})
if called {
t.Fatal("expected database logger writer to be skipped when db context is nil")
}
}
func TestShouldWarnOnValueDBContext(t *testing.T) {
type testDB struct{}
type dbIface interface{ Ping() error }
tests := []struct {
name string
got bool
want bool
}{
{name: "NoDB", got: shouldWarnOnValueDBContext[NoDB](), want: false},
{name: "pointer", got: shouldWarnOnValueDBContext[*testDB](), want: false},
{name: "interface", got: shouldWarnOnValueDBContext[dbIface](), want: false},
{name: "map", got: shouldWarnOnValueDBContext[map[string]int](), want: false},
{name: "struct", got: shouldWarnOnValueDBContext[testDB](), want: true},
{name: "int", got: shouldWarnOnValueDBContext[int](), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Fatalf("shouldWarnOnValueDBContext = %v, want %v", tt.got, tt.want)
}
})
}
}
func TestDatabaseContextMarksValueWarningOnce(t *testing.T) {
type testDB struct{}
bot := &Bot[testDB]{logger: slog.CreateLogger()}
bot.DatabaseContext(testDB{})
if !bot.warnedValueDB {
t.Fatal("expected value-typed database context to mark warning state")
}
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
ptrBot.DatabaseContext(&testDB{})
if ptrBot.warnedValueDB {
t.Fatal("did not expect pointer-typed database context to mark warning state")
}
}
func TestRunWithContextRejectsSecondRun(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoDB]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
}
if err := bot.RunWithContext(ctx); err != nil {
t.Fatalf("first RunWithContext returned error: %v", err)
}
if err := bot.RunWithContext(ctx); !errors.Is(err, ErrBotAlreadyRun) {
t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err)
}
}
+15 -9
View File
@@ -4,13 +4,14 @@ import (
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// CmdRegexp matches command names allowed for Telegram command registration. // CmdRegexp matches command names allowed for Telegram command registration.
var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
// ErrTooManyCommands is returned when the total number of registered commands // ErrTooManyCommands is returned when the total number of registered commands
// exceeds Telegram's limit of 100 bot commands per bot. // exceeds Telegram's limit of 100 bot commands per bot.
@@ -20,7 +21,7 @@ var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$")
// bot initialization. // bot initialization.
var ErrTooManyCommands = errors.New("too many commands. max 100") var ErrTooManyCommands = errors.New("too many commands. max 100")
// generateBotCommand builds a BotCommand description with generated usage text. // Internal helper to build a BotCommand description with generated usage text.
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
desc := "" desc := ""
if len(cmd.description) > 0 { if len(cmd.description) > 0 {
@@ -44,13 +45,20 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
return tgapi.BotCommand{Command: cmd.command, Description: usage} return tgapi.BotCommand{Command: cmd.command, Description: usage}
} }
// checkCmdRegex reports whether cmd matches CmdRegexp. // Internal helper to validate Telegram command names.
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
// gatherCommandsForPlugin collects non-skipped, valid commands from one plugin. // Internal helper to collect non-skipped, valid commands from one plugin.
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0) commands := make([]tgapi.BotCommand, 0)
for _, cmd := range pl.commands { names := make([]string, 0, len(pl.commands))
for name := range pl.commands {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
cmd := pl.commands[name]
if cmd.skipAutoCmd { if cmd.skipAutoCmd {
continue continue
} }
@@ -62,9 +70,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands return commands
} }
// gatherCommands collects all commands from all plugins // Internal helper to collect all auto-generated commands from registered plugins.
// and converts them into tgapi.BotCommand objects.
// See gatherCommandsForPlugin.
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0) commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins { for _, pl := range bot.plugins {
+26 -5
View File
@@ -4,13 +4,14 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"reflect"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -37,13 +38,13 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
plugin := NewPlugin[NoDB]("overflow") plugin := NewPlugin[NoDB]("overflow")
exec := func(ctx *MsgContext, db *NoDB) {} exec := func(ctx *MsgContext, db NoDB) error { return nil }
for i := 0; i < 101; i++ { for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
} }
@@ -62,3 +63,23 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
t.Fatalf("expected no HTTP calls before limit validation, got %d", calls.Load()) t.Fatalf("expected no HTTP calls before limit validation, got %d", calls.Load())
} }
} }
func TestGatherCommandsForPluginReturnsSortedCommands(t *testing.T) {
plugin := NewPlugin[NoDB]("sorted")
exec := func(ctx *MsgContext, db NoDB) error { return nil }
plugin.AddCommand(NewCommand(exec, "zeta"))
plugin.AddCommand(NewCommand(exec, "alpha"))
plugin.AddCommand(NewCommand(exec, "mid"))
commands := gatherCommandsForPlugin(*plugin)
got := make([]string, 0, len(commands))
for _, cmd := range commands {
got = append(got, cmd.Command)
}
want := []string{"alpha", "mid", "zeta"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected command order: got %v want %v", got, want)
}
}
+18 -40
View File
@@ -1,55 +1,33 @@
/* /*
Package laniakea provides a modular, extensible framework for building scalable Telegram bots. Package laniakea provides a modular, extensible framework for building scalable Telegram bots.
It offers a fluent API for configuration and separates concerns through several core concepts: Core concepts:
- Bot: The central instance managing API communication, update processing, logging, - Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection.
rate limiting, and dependency injection. Created via NewBot[T]. - Plugins group commands, payloads, and non-command update handlers behind shared middleware.
- MsgContext provides access to the current update and reply/edit/delete helpers.
- Plugins: Organize commands and payloads into reusable units. - InlineKeyboard builds callback-driven keyboards and structured payloads.
A plugin can have multiple commands and shared middlewares. - DraftProvider accumulates multi-step replies before sending them.
- L10n stores key-based translations with fallback behavior.
- Commands: Named bot commands with descriptions, argument validation, and - Runners execute startup or background tasks alongside the polling loop.
execution logic. Automatically registrable across different chat scopes.
- Middleware: Functions that intercept and modify updates before they reach plugins.
Useful for authentication, logging, validation, etc. Return false to stop processing.
- MsgContext: Provides access to the incoming update and convenient methods for
responding, editing, deleting, and translating messages. Includes built-in rate limiting
and error handling. MarkdownV2 methods require manual escaping via EscapeMarkdownV2().
- InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons,
icons, URLs, and structured callback data (JSON or Base64).
- DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation
(random or linear). Drafts can be built incrementally and flushed atomically.
- L10n: Simple key-based localization system with fallback language support.
- Runners: Background goroutines for periodic tasks or oneoff initialization,
with configurable timeouts and async execution.
- RateLimiting & Logging: Builtin rate limiter (respects Telegram's retry_after)
and structured logging (JSON stdout + optional file output) with requestlevel tracing.
- Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers
via the type parameter T in Bot[T].
Example usage: Example usage:
bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()). bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv())
DatabaseContext(&myDB). if err != nil {
return err
}
bot.DatabaseContext(myDB).
AddUpdateType(tgapi.UpdateTypeMessage). AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!"). AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin). AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(&authMiddleware, &logMiddleware). AddMiddleware(authMiddleware, logMiddleware).
AddRunner(&cleanupRunner). AddRunner(cleanupRunner).
AddL10n(l10n.New()) AddL10n(l10n.New())
bot.Run() return bot.Run()
All public methods are safe for concurrent use unless stated otherwise. Configure bots, plugins, and localization before starting Run or RunWithContext.
Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset). Runtime accessors are safe for concurrent use unless stated otherwise.
*/ */
package laniakea package laniakea
+15 -16
View File
@@ -1,18 +1,14 @@
package laniakea package laniakea
import ( import (
"errors"
"math/rand/v2" "math/rand/v2"
"sync" "sync"
"sync/atomic" "sync/atomic"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID. // Interface for generating unique draft IDs.
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs.
type draftIdGenerator interface { type draftIdGenerator interface {
// Next returns the next unique draft ID. // Next returns the next unique draft ID.
Next() uint64 Next() uint64
@@ -38,12 +34,9 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
return g.lastId.Add(1) return g.lastId.Add(1)
} }
// DraftProvider manages a collection of Drafts and provides methods to create and // DraftProvider manages a collection of Drafts and a shared draft ID generator.
// configure them. It holds shared configuration (chat, parse mode, entities) and
// a draft ID generator.
// //
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines // DraftProvider is safe for concurrent use.
// requires external synchronization.
type DraftProvider struct { type DraftProvider struct {
mu sync.RWMutex mu sync.RWMutex
api *tgapi.API api *tgapi.API
@@ -133,10 +126,7 @@ type Draft struct {
// NewDraft creates a new draft with the provided parse mode. // NewDraft creates a new draft with the provided parse mode.
// //
// The draft inherits the provider's chatID, messageThreadID, and entities. // The caller must set a chat with SetChat before Push or Flush.
// If parseMode is zero, the provider's default parseMode is used.
//
// Panics if chatID is zero — call SetChat() on the provider first.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
id := p.generator.Next() id := p.generator.Next()
draft := &Draft{ draft := &Draft{
@@ -224,6 +214,12 @@ func (d *Draft) Flush() error {
if d.Message == "" { if d.Message == "" {
return nil return nil
} }
if d.chatID == 0 {
return ErrDraftChatIDZero
}
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageP{ params := tgapi.SendMessageP{
ChatID: d.chatID, ChatID: d.chatID,
@@ -242,12 +238,15 @@ func (d *Draft) Flush() error {
return err return err
} }
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. // Internal helper for Push that updates the server-side draft.
func (d *Draft) push(text string) error { func (d *Draft) push(text string) error {
if d.chatID == 0 { if d.chatID == 0 {
return ErrDraftChatIDZero return ErrDraftChatIDZero
} }
d.Message += text d.Message += text
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageDraftP{ params := tgapi.SendMessageDraftP{
ChatID: d.chatID, ChatID: d.chatID,
DraftID: d.ID, DraftID: d.ID,
+55
View File
@@ -0,0 +1,55 @@
package laniakea
import (
"errors"
"strings"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestDraftFlushRequiresChatID(t *testing.T) {
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone)
draft.Message = "hello"
if err := draft.Flush(); err != ErrDraftChatIDZero {
t.Fatalf("expected ErrDraftChatIDZero, got %v", err)
}
}
func TestMsgContextNewDraftWorksWithoutLimiter(t *testing.T) {
ctx := &MsgContext{
Api: &tgapi.API{},
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)},
},
Logger: slog.CreateLogger(),
draftProvider: NewRandomDraftProvider(&tgapi.API{}),
}
draft := ctx.NewDraft()
if draft == nil {
t.Fatal("expected draft")
}
if draft.chatID != 42 {
t.Fatalf("unexpected chat id: %d", draft.chatID)
}
}
func TestDraftFlushRejectsLongMessage(t *testing.T) {
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
draft.Message = strings.Repeat("a", maxMessageTextLen+1)
if err := draft.Flush(); !errors.Is(err, ErrMessageTooLong) {
t.Fatalf("expected ErrMessageTooLong, got %v", err)
}
}
func TestDraftPushRejectsLongMessage(t *testing.T) {
draft := NewRandomDraftProvider(&tgapi.API{}).NewDraft(tgapi.ParseNone).SetChat(42, 0)
if err := draft.Push(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) {
t.Fatalf("expected ErrMessageTooLong, got %v", err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package laniakea
import (
"errors"
"fmt"
"unicode/utf8"
)
const (
maxMessageTextLen = 4096
maxMessageCaptionLen = 1024
)
var (
// ErrEmptyMessage reports that a required message text is empty.
ErrEmptyMessage = errors.New("empty message")
// ErrMessageTooLong reports that a message exceeds Telegram's text limit.
ErrMessageTooLong = errors.New("message too long")
// ErrCaptionTooLong reports that a caption exceeds Telegram's caption limit.
ErrCaptionTooLong = errors.New("caption too long")
// ErrMessageSplitImpossible reports that automatic message splitting cannot preserve semantics.
ErrMessageSplitImpossible = errors.New("message split is impossible")
// ErrPayloadTypeMismatch reports that callback payload encoding does not match bot policy.
ErrPayloadTypeMismatch = errors.New("payload type mismatch")
// ErrDraftChatIDZero reports that a draft has no target chat ID.
ErrDraftChatIDZero = errors.New("zero draft chat ID")
// ErrMessageNil reports that a required message value is nil.
ErrMessageNil = errors.New("message is nil")
// ErrMessageContextNil reports that an operation requires ctx.Msg but none is set.
ErrMessageContextNil = errors.New("message context is nil")
// ErrEditTargetMissing reports that an edit operation has no message target.
ErrEditTargetMissing = errors.New("edit target is missing")
// ErrCallbackMessageMissing reports that a callback operation has no callback message target.
ErrCallbackMessageMissing = errors.New("callback message is missing")
// ErrDraftProviderNil reports that draft creation was requested without a draft provider.
ErrDraftProviderNil = errors.New("draft provider is nil")
// ErrAPIIsNil reports that an operation requires an API client but none is set.
ErrAPIIsNil = errors.New("api is nil")
// ErrMessageIDZero reports that an operation requires a non-zero message ID.
ErrMessageIDZero = errors.New("message ID is zero")
// ErrBindArgsTargetNotPointer reports that BindArgs received a nil or non-pointer destination.
ErrBindArgsTargetNotPointer = errors.New("bind args: dst must be a non-nil pointer")
// ErrBindArgsTargetNotStruct reports that BindArgs received a pointer to a non-struct value.
ErrBindArgsTargetNotStruct = errors.New("bind args: dst must point to a struct")
// ErrBindArgsUnsupportedFieldType reports that BindArgs encountered an unsupported field kind.
ErrBindArgsUnsupportedFieldType = errors.New("bind args: unsupported field type")
// ErrBindArgsConversion reports that BindArgs could not convert a string argument into a field type.
ErrBindArgsConversion = errors.New("bind args: conversion failed")
// ErrCantFindSession reports that no scene session matches the current context.
ErrCantFindSession = errors.New("can't find session for this context")
// ErrSceneNotFound reports that the requested scene is not registered.
ErrSceneNotFound = errors.New("scene not found")
// ErrSceneStepNotFound reports that the requested scene step is not registered.
ErrSceneStepNotFound = errors.New("scene step not found")
// ErrNotInScene reports that the current context has no active scene session.
ErrNotInScene = errors.New("not in scene")
// ErrSceneEntryNotSet reports that a scene has no configured entry step.
ErrSceneEntryNotSet = errors.New("scene entry step not set")
// ErrSceneRuntimeNil reports that scene APIs were used without an attached runtime.
ErrSceneRuntimeNil = errors.New("scene runtime is nil")
)
func validateMessageText(text string) error {
length := utf8.RuneCountInString(text)
switch {
case length == 0:
return ErrEmptyMessage
case length > maxMessageTextLen:
return fmt.Errorf("%w: got %d, limit %d", ErrMessageTooLong, length, maxMessageTextLen)
default:
return nil
}
}
func validateCaptionText(text string) error {
length := utf8.RuneCountInString(text)
if length > maxMessageCaptionLen {
return fmt.Errorf("%w: got %d, limit %d", ErrCaptionTooLong, length, maxMessageCaptionLen)
}
return nil
}
+5 -3
View File
@@ -1,10 +1,12 @@
module git.nix13.pw/scuroneko/laniakea module git.scuroneko.dev/scuroneko/laniakea
go 1.26 go 1.26
retract v1.0.0-rc.5
require ( require (
git.nix13.pw/scuroneko/extypes v1.2.2 git.scuroneko.dev/scuroneko/extypes v1.2.3
git.nix13.pw/scuroneko/slog v1.1.2 git.scuroneko.dev/scuroneko/slog v1.1.3
github.com/alitto/pond/v2 v2.7.0 github.com/alitto/pond/v2 v2.7.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
) )
+4 -4
View File
@@ -1,7 +1,7 @@
git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk= git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk= git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0= git.scuroneko.dev/scuroneko/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw=
git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM= git.scuroneko.dev/scuroneko/slog v1.1.3/go.mod h1:gnDap54sfZv3EuSyZd7fjOH46aLbDFpvtN2wgFcWkgE=
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+233 -53
View File
@@ -1,86 +1,90 @@
package laniakea package laniakea
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// ErrInvalidPayloadType is returned when callback payload encoding type is unknown. // ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
var ErrInvalidPayloadType = errors.New("invalid payload type") var ErrInvalidPayloadType = errors.New("invalid payload type")
func (bot *Bot[T]) handle(u *tgapi.Update) { func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
} }
}() }()
ctx := &MsgContext{ ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
msgCtx := &MsgContext{
Update: *u, Api: bot.api, Update: *u, Api: bot.api,
botLogger: bot.logger, Logger: bot.logger,
errorTemplate: bot.errorTemplate, errorTemplate: bot.errorTemplate,
l10n: bot.l10n, l10n: bot.l10n,
draftProvider: bot.draftProvider, draftProvider: bot.draftProvider,
sceneRuntime: bot,
payloadType: bot.payloadType, payloadType: bot.payloadType,
ctx: ctx,
} }
bot.prepareUpdateCtx(u, msgCtx)
for _, middleware := range bot.middlewares { for _, middleware := range bot.middlewares {
if !middleware.Execute(ctx, bot.dbContext) { if !middleware.Execute(msgCtx, bot.dbContext) {
return return
} }
} }
if u.CallbackQuery != nil { sceneHandled, err := bot.tryHandleScene(msgCtx)
bot.handleCallback(u, ctx) if err != nil {
} else { bot.logger.Errorln(err)
bot.handleMessage(u, ctx) return
}
if sceneHandled {
return
}
switch u.Type {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
bot.handleMessage(u, msgCtx)
case tgapi.UpdateTypeCallbackQuery:
bot.handleCallback(u, msgCtx)
default:
bot.handleUpdate(u, msgCtx)
} }
} }
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) { func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if update.Message == nil { var msg *tgapi.Message
return if update.Message != nil {
} msg = update.Message
if update.Message.From == nil { } else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return return
} }
var text string var text string
if len(update.Message.Text) > 0 { if len(msg.Text) > 0 {
text = update.Message.Text text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else { } else {
text = update.Message.Caption return
} }
text = strings.TrimSpace(text) prefix, cmd, args := bot.parseCommand(text)
prefix, hasPrefix := bot.checkPrefixes(text) if cmd == "" {
if !hasPrefix {
return return
} }
ctx.Prefix = prefix ctx.Prefix = prefix
ctx.FromID = update.Message.From.ID
ctx.From = update.Message.From
ctx.Msg = update.Message
// Убираем префикс
text = strings.TrimSpace(text[len(prefix):])
// Извлекаем команду как первое слово
spaceIndex := strings.Index(text, " ")
var cmd string
var args string
if spaceIndex == -1 {
cmd = text
args = ""
} else {
cmd = text[:spaceIndex]
args = strings.TrimSpace(text[spaceIndex:])
}
if strings.Contains(cmd, "@") { if strings.Contains(cmd, "@") {
botUsername := bot.username botUsername := bot.username
@@ -94,6 +98,10 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if _, exists := plugin.commands[cmd]; exists { if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args ctx.Text = args
ctx.Args = strings.Fields(args) // Убирает лишние пробелы ctx.Args = strings.Fields(args) // Убирает лишние пробелы
if plugin.logger != nil {
ctx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return return
} }
@@ -110,16 +118,6 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
return return
} }
ctx.FromID = update.CallbackQuery.From.ID
ctx.From = &update.CallbackQuery.From
if update.CallbackQuery.Message != nil {
ctx.Msg = update.CallbackQuery.Message
ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID
}
if update.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *update.CallbackQuery.InlineMessageID
}
ctx.CallbackQueryId = update.CallbackQuery.ID
ctx.Args = data.Args ctx.Args = data.Args
for _, plugin := range bot.plugins { for _, plugin := range bot.plugins {
@@ -128,6 +126,10 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
continue continue
} }
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) { if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return return
} }
@@ -136,9 +138,141 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
} }
} }
func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) {
for _, plugin := range bot.plugins {
handler, ok := plugin.handlers[u.Type]
if !ok {
continue
}
pluginCtx := cloneMsgContext(ctx)
if plugin.logger != nil {
pluginCtx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(pluginCtx, bot.dbContext) {
continue
}
if err := handler(pluginCtx, bot.dbContext); err != nil {
pluginCtx.error(err)
}
}
}
func cloneMsgContext(src *MsgContext) *MsgContext {
cloned := *src
if src.Args != nil {
cloned.Args = append([]string(nil), src.Args...)
}
return &cloned
}
func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
var from *tgapi.User
switch u.Type {
case tgapi.UpdateTypeMessage:
if u.Message != nil {
ctx.Msg = u.Message
}
case tgapi.UpdateTypeEditedMessage:
if u.EditedMessage != nil {
ctx.Msg = u.EditedMessage
}
case tgapi.UpdateTypeChannelPost:
if u.ChannelPost != nil {
ctx.Msg = u.ChannelPost
}
case tgapi.UpdateTypeEditedChannelPost:
if u.EditedChannelPost != nil {
ctx.Msg = u.EditedChannelPost
}
case tgapi.UpdateTypeBusinessMessage:
if u.BusinessMessage != nil {
ctx.Msg = u.BusinessMessage
}
case tgapi.UpdateTypeEditedBusinessMessage:
if u.EditedBusinessMessage != nil {
ctx.Msg = u.EditedBusinessMessage
}
case tgapi.UpdateTypeInlineQuery:
if u.InlineQuery != nil {
from = &u.InlineQuery.From
}
case tgapi.UpdateTypeChosenInlineResult:
if u.ChosenInlineResult != nil {
from = &u.ChosenInlineResult.From
}
case tgapi.UpdateTypeCallbackQuery:
if u.CallbackQuery != nil {
if u.CallbackQuery.Message != nil {
ctx.Msg = u.CallbackQuery.Message
ctx.CallbackMsgId = u.CallbackQuery.Message.MessageID
}
if u.CallbackQuery.InlineMessageID != nil {
ctx.InlineMsgId = *u.CallbackQuery.InlineMessageID
}
ctx.CallbackQueryId = u.CallbackQuery.ID
from = &u.CallbackQuery.From
}
case tgapi.UpdateTypeShippingQuery:
if u.ShippingQuery != nil {
from = &u.ShippingQuery.From
}
case tgapi.UpdateTypePreCheckoutQuery:
if u.PreCheckoutQuery != nil {
from = &u.PreCheckoutQuery.From
}
case tgapi.UpdateTypePurchasedPaidMedia:
if u.PurchasedPaidMedia != nil {
from = &u.PurchasedPaidMedia.From
}
case tgapi.UpdateTypeMyChatMember:
if u.MyChatMember != nil {
from = &u.MyChatMember.From
}
case tgapi.UpdateTypeChatMember:
if u.ChatMember != nil {
from = &u.ChatMember.From
}
case tgapi.UpdateTypeChatJoinRequest:
if u.ChatJoinRequest != nil {
from = &u.ChatJoinRequest.From
}
case tgapi.UpdateTypeBusinessConnection:
if u.BusinessConnection != nil {
from = &u.BusinessConnection.User
}
case tgapi.UpdateTypePollAnswer:
if u.PollAnswer != nil {
from = &u.PollAnswer.User
}
case tgapi.UpdateTypeMessageReaction:
if u.MessageReaction != nil {
from = u.MessageReaction.User
}
case tgapi.UpdateTypeChatBoost:
if u.ChatBoost != nil {
from = &u.ChatBoost.Boost.Source.User
}
case tgapi.UpdateTypeRemovedChatBoost:
if u.RemovedChatBoost != nil {
from = &u.RemovedChatBoost.Source.User
}
}
if ctx.Msg != nil && from == nil {
from = ctx.Msg.From
}
if from != nil {
ctx.From = from
ctx.FromID = from.ID
}
}
func (bot *Bot[T]) checkPrefixes(text string) (string, bool) { func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
for _, prefix := range bot.prefixes { for _, prefix := range bot.prefixes {
if prefix == "" { if prefix == "" {
if bot.logger != nil {
bot.logger.Warnln("empty prefix is not allowed")
}
continue continue
} }
if strings.HasPrefix(text, prefix) { if strings.HasPrefix(text, prefix) {
@@ -147,6 +281,23 @@ func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
} }
return "", false return "", false
} }
func (bot *Bot[T]) parseCommand(text string) (prefix, cmd, args string) {
if prefix, hasPrefix := bot.checkPrefixes(text); hasPrefix {
text = strings.TrimSpace(text[len(prefix):])
spaceIndex := strings.Index(text, " ")
var cmd string
var args string
if spaceIndex == -1 {
cmd = text
args = ""
} else {
cmd = text[:spaceIndex]
args = strings.TrimSpace(text[spaceIndex:])
}
return prefix, cmd, args
}
return "", "", ""
}
func encodeJsonPayload(d CallbackData) (string, error) { func encodeJsonPayload(d CallbackData) (string, error) {
b, err := json.Marshal(d) b, err := json.Marshal(d)
@@ -186,19 +337,48 @@ func decodeBase64Payload(s string) (CallbackData, error) {
} }
return decodeJsonPayload(string(b)) return decodeJsonPayload(string(b))
} }
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) { func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
switch payloadType { switch payloadType {
case BotPayloadBase64: case BotPayloadBase64:
return decodeBase64Payload(s) data, err := decodeBase64Payload(s)
if err == nil {
return data, BotPayloadBase64, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
}
data, err = decodeJsonPayload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadJson, nil
case BotPayloadJson: case BotPayloadJson:
return decodeJsonPayload(s) data, err := decodeJsonPayload(s)
if err == nil {
return data, BotPayloadJson, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson)
}
data, err = decodeBase64Payload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadBase64, nil
} }
return CallbackData{}, ErrInvalidPayloadType return CallbackData{}, "", ErrInvalidPayloadType
} }
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) { // func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
// return encodePayload(bot.payloadType, d) // return encodePayload(bot.payloadType, d)
// } // }
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
return decodePayload(bot.payloadType, s) data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
if err != nil {
return CallbackData{}, err
}
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson())
}
return data, nil
} }
+312 -1
View File
@@ -1,6 +1,12 @@
package laniakea package laniakea
import "testing" import (
"context"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) { func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
bot := &Bot[NoDB]{prefixes: []string{"", "/"}} bot := &Bot[NoDB]{prefixes: []string{"", "/"}}
@@ -12,3 +18,308 @@ func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
t.Fatalf("unexpected prefix result: prefix=%q ok=%v", prefix, ok) t.Fatalf("unexpected prefix result: prefix=%q ok=%v", prefix, ok)
} }
} }
func TestBotMiddlewareReceivesLogger(t *testing.T) {
logger := slog.CreateLogger()
called := false
bot := &Bot[NoDB]{
logger: logger,
middlewares: []Middleware[NoDB]{
NewMiddleware("logger-check", func(ctx *MsgContext, db NoDB) bool {
called = true
if ctx.Logger != logger {
t.Fatalf("expected bot logger in middleware context, got %#v", ctx.Logger)
}
return true
}),
},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 1,
Type: tgapi.UpdateTypePoll,
Poll: &tgapi.Poll{
ID: "poll",
Question: "question",
},
})
if !called {
t.Fatal("expected bot middleware to be called")
}
}
func TestAddUpdateHandlerRejectsReservedUpdateTypes(t *testing.T) {
plugin := NewPlugin[NoDB]("test")
handler := func(ctx *MsgContext, db NoDB) error { return nil }
for _, updateType := range []tgapi.UpdateType{
tgapi.UpdateTypeMessage,
tgapi.UpdateTypeChannelPost,
tgapi.UpdateTypeCallbackQuery,
} {
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("AddUpdateHandler(%q) panicked: %v", updateType, r)
}
}()
plugin.AddUpdateHandler(updateType, handler)
}()
if _, ok := plugin.handlers[updateType]; ok {
t.Fatalf("reserved update type %q must not be registered", updateType)
}
}
}
func TestHandleUpdateHandlersPopulateFromContext(t *testing.T) {
tests := []struct {
name string
update *tgapi.Update
wantID int64
}{
{
name: "inline query",
update: &tgapi.Update{
UpdateID: 1,
Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{
ID: "iq",
From: tgapi.User{ID: 41},
Query: "ping",
},
},
wantID: 41,
},
{
name: "chosen inline result",
update: &tgapi.Update{
UpdateID: 2,
Type: tgapi.UpdateTypeChosenInlineResult,
ChosenInlineResult: &tgapi.ChosenInlineResult{
ResultID: "res",
From: tgapi.User{ID: 77},
Query: "pong",
},
},
wantID: 77,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
called := false
plugin := NewPlugin[NoDB]("test").AddUpdateHandler(tt.update.Type, func(ctx *MsgContext, db NoDB) error {
called = true
if ctx.Update.UpdateID != tt.update.UpdateID {
t.Fatalf("unexpected update in context: got %d want %d", ctx.Update.UpdateID, tt.update.UpdateID)
}
if ctx.From == nil {
t.Fatal("expected ctx.From to be populated")
}
if ctx.FromID != tt.wantID {
t.Fatalf("unexpected FromID: got %d want %d", ctx.FromID, tt.wantID)
}
if ctx.From.ID != tt.wantID {
t.Fatalf("unexpected ctx.From.ID: got %d want %d", ctx.From.ID, tt.wantID)
}
if ctx.Msg != nil {
t.Fatalf("did not expect message context for %s", tt.name)
}
return nil
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
}
bot.handle(context.Background(), tt.update)
if !called {
t.Fatalf("expected update handler for %s to be called", tt.name)
}
})
}
}
func TestHandleUpdateHandlersReceiveIsolatedContexts(t *testing.T) {
firstCalled := false
secondCalled := false
first := NewPlugin[NoDB]("first").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error {
firstCalled = true
if ctx.FromID != 41 {
t.Fatalf("unexpected FromID in first handler: got %d want 41", ctx.FromID)
}
ctx.From = nil
ctx.FromID = 999
ctx.Text = "mutated"
ctx.Args = []string{"mutated"}
return nil
})
second := NewPlugin[NoDB]("second").AddUpdateHandler(tgapi.UpdateTypeInlineQuery, func(ctx *MsgContext, db NoDB) error {
secondCalled = true
if ctx.From == nil {
t.Fatal("expected ctx.From to remain populated for second handler")
}
if ctx.FromID != 41 {
t.Fatalf("unexpected FromID in second handler: got %d want 41", ctx.FromID)
}
if ctx.Text != "" {
t.Fatalf("unexpected leaked Text in second handler: %q", ctx.Text)
}
if len(ctx.Args) != 0 {
t.Fatalf("unexpected leaked Args in second handler: %v", ctx.Args)
}
return nil
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{
clonePlugin(first),
clonePlugin(second),
},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 3,
Type: tgapi.UpdateTypeInlineQuery,
InlineQuery: &tgapi.InlineQuery{
ID: "iq",
From: tgapi.User{ID: 41},
Query: "ping",
},
})
if !firstCalled || !secondCalled {
t.Fatalf("expected both handlers to be called, got first=%v second=%v", firstCalled, secondCalled)
}
}
func TestHandleChannelPostCommandWithSenderChat(t *testing.T) {
called := false
plugin := NewPlugin[NoDB]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
called = true
if ctx.Msg == nil {
t.Fatal("expected message context")
}
if ctx.Msg.Chat == nil || ctx.Msg.Chat.ID != -1001 {
t.Fatalf("unexpected chat context: %#v", ctx.Msg.Chat)
}
if ctx.From != nil {
t.Fatalf("expected ctx.From to stay nil for sender_chat updates, got %#v", ctx.From)
}
if ctx.FromID != 0 {
t.Fatalf("expected zero FromID for sender_chat updates, got %d", ctx.FromID)
}
return nil
}, "ping")
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 10,
Type: tgapi.UpdateTypeChannelPost,
ChannelPost: &tgapi.Message{
MessageID: 55,
Text: "/ping",
SenderChat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
Chat: &tgapi.Chat{ID: -1001, Type: string(tgapi.ChatTypeChannel)},
},
})
if !called {
t.Fatal("expected channel post command handler to be called")
}
}
func TestCommandHandlerBindArgsEndToEnd(t *testing.T) {
type banInput struct {
UserID int
Reason string
}
var got banInput
plugin := NewPlugin[NoDB]("test")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
return ctx.BindArgs(&got)
}, "ban",
NewCommandArg("user_id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("reason").SetRequired(),
)
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 11,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 1,
Text: "/ban 42 too loud",
Chat: &tgapi.Chat{ID: 99, Type: string(tgapi.ChatTypePrivate)},
},
})
want := banInput{UserID: 42, Reason: "too loud"}
if got != want {
t.Fatalf("unexpected bound input: got %#v want %#v", got, want)
}
}
func TestPayloadHandlerBindArgsEndToEnd(t *testing.T) {
type payloadInput struct {
ID int
Note string
}
var got payloadInput
plugin := NewPlugin[NoDB]("test")
plugin.NewPayload(func(ctx *MsgContext, db NoDB) error {
return ctx.BindArgs(&got)
}, "approve",
NewCommandArg("id").SetValueType(CommandValueIntType).SetRequired(),
NewCommandArg("note").SetRequired(),
)
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
payloadType: BotPayloadJson,
plugins: []Plugin[NoDB]{clonePlugin(plugin)},
}
data, err := encodeJsonPayload(CallbackData{
Command: "approve",
Args: []string{"7", "looks", "good"},
})
if err != nil {
t.Fatalf("encodeJsonPayload returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 12,
Type: tgapi.UpdateTypeCallbackQuery,
CallbackQuery: &tgapi.CallbackQuery{
ID: "cb-1",
Data: data,
From: tgapi.User{ID: 1},
},
})
want := payloadInput{ID: 7, Note: "looks good"}
if got != want {
t.Fatalf("unexpected bound payload input: got %#v want %#v", got, want)
}
}
+25 -20
View File
@@ -3,17 +3,16 @@ package laniakea
import ( import (
"fmt" "fmt"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary are predefined
// Telegram keyboard button styles for visual feedback.
//
// These values map directly to Telegram Bot API's InlineKeyboardButton style field.
const ( const (
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" // ButtonStyleDanger marks a destructive inline keyboard action.
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger"
// ButtonStyleSuccess marks a confirmatory inline keyboard action.
ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success" ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success"
// ButtonStylePrimary marks a primary inline keyboard action.
ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary"
) )
@@ -83,8 +82,7 @@ func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) In
return b return b
} }
// build converts the builder state into a tgapi.InlineKeyboardButton. // Internal helper that converts the builder state into a Telegram button.
// This method is typically called internally by InlineKeyboard.AddButton().
func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton {
return tgapi.InlineKeyboardButton{ return tgapi.InlineKeyboardButton{
Text: b.text, Text: b.text,
@@ -138,16 +136,23 @@ func NewInlineKeyboard(payloadType BotPayloadType, maxRow int) *InlineKeyboard {
} }
} }
// SetPayloadType sets the serialization format for callback data added via // SetPayloadType sets the keyboard-local serialization format for callback data added via
// AddCallbackButton and AddCallbackButtonStyle methods. // AddCallbackButton and AddCallbackButtonStyle methods.
// It should be one of BotPayloadJson or BotPayloadBase64. // It overrides the bot's default payload type for this keyboard only.
func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard { func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
in.payloadType = t in.payloadType = t
return in return in
} }
// append adds a button to the current line. If the line is full, it auto-flushes. // GetPayloadType returns the keyboard-local callback payload encoding type.
// This is an internal helper used by other builder methods. func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType }
func (in *InlineKeyboard) SetMaxRow(maxRow int) *InlineKeyboard {
in.maxRow = maxRow
return in
}
// Internal helper that appends a button and auto-flushes a full row.
func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard { func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
if in.CurrentLine.Len() == in.maxRow { if in.CurrentLine.Len() == in.maxRow {
in.AddLine() in.AddLine()
@@ -235,12 +240,12 @@ type CallbackData struct {
// (int, string, bool, float64) but may not serialize complex structs meaningfully. // (int, string, bool, float64) but may not serialize complex structs meaningfully.
// //
// Use this to build callback payloads for bot command routing. // Use this to build callback payloads for bot command routing.
func NewCallbackData(command string, args ...any) *CallbackData { func NewCallbackData(command string, args ...any) CallbackData {
stringArgs := make([]string, len(args)) stringArgs := make([]string, len(args))
for i, arg := range args { for i, arg := range args {
stringArgs[i] = fmt.Sprint(arg) stringArgs[i] = fmt.Sprint(arg)
} }
return &CallbackData{ return CallbackData{
Command: command, Command: command,
Args: stringArgs, Args: stringArgs,
} }
@@ -253,8 +258,8 @@ func NewCallbackData(command string, args ...any) *CallbackData {
// //
// This fallback ensures the bot receives a valid JSON payload even if internal // This fallback ensures the bot receives a valid JSON payload even if internal
// errors occur — avoiding "invalid callback_data" errors from Telegram. // errors occur — avoiding "invalid callback_data" errors from Telegram.
func (d *CallbackData) ToJson() string { func (d CallbackData) ToJson() string {
data, err := encodeJsonPayload(*d) data, err := encodeJsonPayload(d)
if err != nil { if err != nil {
// Fallback: return minimal valid JSON to avoid Telegram API rejection // Fallback: return minimal valid JSON to avoid Telegram API rejection
return `{"cmd":""}` return `{"cmd":""}`
@@ -264,8 +269,8 @@ func (d *CallbackData) ToJson() string {
// ToBase64 serializes the CallbackData to a JSON string and then encodes it as Base64. // ToBase64 serializes the CallbackData to a JSON string and then encodes it as Base64.
// Returns an empty string if serialization or encoding fails. // Returns an empty string if serialization or encoding fails.
func (d *CallbackData) ToBase64() string { func (d CallbackData) ToBase64() string {
s, err := encodeBase64Payload(*d) s, err := encodeBase64Payload(d)
if err != nil { if err != nil {
return `` return ``
} }
@@ -275,7 +280,7 @@ func (d *CallbackData) ToBase64() string {
// Encode serializes the CallbackData according to the specified payload type. // Encode serializes the CallbackData according to the specified payload type.
// Supported types: BotPayloadJson and BotPayloadBase64. // Supported types: BotPayloadJson and BotPayloadBase64.
// For unknown types, returns an empty string. // For unknown types, returns an empty string.
func (d *CallbackData) Encode(t BotPayloadType) string { func (d CallbackData) Encode(t BotPayloadType) string {
switch t { switch t {
case BotPayloadBase64: case BotPayloadBase64:
return d.ToBase64() return d.ToBase64()
+97
View File
@@ -0,0 +1,97 @@
package laniakea
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
kb := NewInlineKeyboardJson(2).
AddCallbackButton("A", "cmd", 1).
AddCallbackButton("B", "cmd", 2).
AddCallbackButton("C", "cmd", 3)
markup := kb.Get()
if got := len(markup.InlineKeyboard); got != 2 {
t.Fatalf("unexpected row count: %d", got)
}
if got := len(markup.InlineKeyboard[0]); got != 2 {
t.Fatalf("unexpected first row size: %d", got)
}
if got := len(markup.InlineKeyboard[1]); got != 1 {
t.Fatalf("unexpected second row size: %d", got)
}
if !strings.Contains(markup.InlineKeyboard[0][0].CallbackData, `"cmd":"cmd"`) {
t.Fatalf("expected JSON callback payload, got %q", markup.InlineKeyboard[0][0].CallbackData)
}
}
func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
kb := NewInlineKeyboardBase64(3).
AddButton(
NewInlineKbButton("Docs").
SetStyle(ButtonStylePrimary).
SetUrl("https://example.test"),
)
button := kb.Get().InlineKeyboard[0][0]
if button.Style != ButtonStylePrimary {
t.Fatalf("unexpected style: %q", button.Style)
}
if button.URL != "https://example.test" {
t.Fatalf("unexpected url: %q", button.URL)
}
}
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
kb := NewInlineKeyboardJson(2)
if got := kb.GetPayloadType(); got != BotPayloadJson {
t.Fatalf("unexpected initial payload type: %q", got)
}
kb.SetPayloadType(BotPayloadBase64)
if got := kb.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected updated payload type: %q", got)
}
}
func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) {
kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false)
if err != nil {
t.Fatalf("decodePayload returned error: %v", err)
}
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
}
}
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
kb := NewInlineKeyboardJson(1).
AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
if err != nil {
t.Fatalf("decodePayload returned error: %v", err)
}
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
}
}
func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) {
kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1)
_, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true)
if !errors.Is(err, ErrPayloadTypeMismatch) {
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
}
}
+33 -38
View File
@@ -1,21 +1,18 @@
package laniakea package laniakea
// DictEntry represents a single localized entry with language-to-text mappings. import "sync"
// Example: {"ru": "Привет", "en": "Hello"}.
// DictEntry maps language codes to translated strings.
type DictEntry map[string]string type DictEntry map[string]string
// L10n is a localization manager that maps keys to language-specific strings. // L10n stores translations with a configurable fallback language and is safe for concurrent use.
type L10n struct { type L10n struct {
entries map[string]DictEntry // Map of translation keys to language dictionaries mu sync.RWMutex
fallbackLang string // Language code to use when requested language is missing entries map[string]DictEntry
fallbackLang string
} }
// NewL10n creates a new L10n instance with the specified fallback language. // NewL10n creates a localization store with the given fallback language.
// The fallback language is used when a requested language is not available
// for a given key.
//
// Example: NewL10n("en") will return "Hello" for key "greeting" if "ru" is requested
// but no "ru" entry exists.
func NewL10n(fallbackLanguage string) *L10n { func NewL10n(fallbackLanguage string) *L10n {
return &L10n{ return &L10n{
entries: make(map[string]DictEntry), entries: make(map[string]DictEntry),
@@ -23,54 +20,52 @@ func NewL10n(fallbackLanguage string) *L10n {
} }
} }
// AddDictEntry adds a new translation entry for the given key. // AddDictEntry stores translations for key.
// The value must be a DictEntry mapping language codes (e.g., "en", "ru") to their translated strings.
//
// If a key already exists, it is overwritten.
//
// Returns the L10n instance for method chaining.
func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n {
l.entries[key] = value l.mu.Lock()
defer l.mu.Unlock()
if l.entries == nil {
l.entries = make(map[string]DictEntry)
}
l.entries[key] = cloneDictEntry(value)
return l return l
} }
// GetFallbackLanguage returns the currently configured fallback language code. // GetFallbackLanguage returns the currently configured fallback language code.
func (l *L10n) GetFallbackLanguage() string { func (l *L10n) GetFallbackLanguage() string {
l.mu.RLock()
defer l.mu.RUnlock()
return l.fallbackLang return l.fallbackLang
} }
// Translate retrieves the translation for the given key and language. // Translate returns the translation for key in lang, falling back to the configured language or the key itself.
//
// Behavior:
// - If the key exists and the language has a translation → returns the translation
// - If the key exists but the language is missing → returns the fallback language's value
// - If the key does not exist → returns the key string itself (as fallback)
//
// Example:
//
// l.AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Привет"})
// l.Translate("en", "greeting") → "Hello"
// l.Translate("es", "greeting") → "Hello" (fallback to "en")
// l.Translate("en", "unknown") → "unknown" (key not found)
//
// This behavior ensures that missing translations do not break UI or logs —
// instead, the original key is displayed, making it easy to identify gaps.
func (l *L10n) Translate(lang, key string) string { func (l *L10n) Translate(lang, key string) string {
l.mu.RLock()
defer l.mu.RUnlock()
entries, exists := l.entries[key] entries, exists := l.entries[key]
if !exists { if !exists {
return key // Return key as fallback when translation is missing return key
} }
// Try requested language
if translation, ok := entries[lang]; ok { if translation, ok := entries[lang]; ok {
return translation return translation
} }
// Fall back to configured fallback language
if fallback, ok := entries[l.fallbackLang]; ok { if fallback, ok := entries[l.fallbackLang]; ok {
return fallback return fallback
} }
// If fallback language is also missing, return the key
return key return key
} }
func cloneDictEntry(src DictEntry) DictEntry {
if src == nil {
return nil
}
cloned := make(DictEntry, len(src))
for lang, text := range src {
cloned[lang] = text
}
return cloned
}
+77
View File
@@ -0,0 +1,77 @@
package laniakea
import (
"fmt"
"sync"
"testing"
)
func TestL10nTranslateUsesFallbackAndKey(t *testing.T) {
l10n := NewL10n("en").
AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Privet"}).
AddDictEntry("partial", DictEntry{"ru": "Tolko ru"})
tests := []struct {
name string
lang string
key string
want string
}{
{name: "exact match", lang: "ru", key: "greeting", want: "Privet"},
{name: "fallback language", lang: "es", key: "greeting", want: "Hello"},
{name: "missing fallback returns key", lang: "en", key: "partial", want: "partial"},
{name: "unknown key returns key", lang: "en", key: "unknown", want: "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := l10n.Translate(tt.lang, tt.key); got != tt.want {
t.Fatalf("unexpected translation: got %q want %q", got, tt.want)
}
})
}
}
func TestL10nAddDictEntryCopiesInput(t *testing.T) {
l10n := NewL10n("en")
entry := DictEntry{"en": "Hello"}
l10n.AddDictEntry("greeting", entry)
entry["en"] = "Mutated"
if got := l10n.Translate("en", "greeting"); got != "Hello" {
t.Fatalf("unexpected translation after external mutation: got %q", got)
}
}
func TestL10nZeroValueIsUsable(t *testing.T) {
var l10n L10n
l10n.AddDictEntry("greeting", DictEntry{"en": "Hello"})
if got := l10n.Translate("en", "greeting"); got != "Hello" {
t.Fatalf("unexpected translation from zero-value l10n: got %q", got)
}
}
func TestL10nConcurrentAccess(t *testing.T) {
l10n := NewL10n("en")
l10n.AddDictEntry("base", DictEntry{"en": "Hello"})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 100; j++ {
l10n.AddDictEntry(fmt.Sprintf("key-%d-%d", i, j), DictEntry{"en": "value"})
_ = l10n.Translate("en", "base")
}
}(i)
}
wg.Wait()
if got := l10n.Translate("en", "base"); got != "Hello" {
t.Fatalf("unexpected translation after concurrent access: got %q", got)
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// Updates fetches new updates from Telegram API using long polling. // Updates fetches new updates from Telegram API using long polling.
+317 -49
View File
@@ -2,11 +2,15 @@ package laniakea
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"reflect"
"strconv"
"strings"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// MsgContext holds the context for handling a Telegram message or callback query. // MsgContext holds the context for handling a Telegram message or callback query.
@@ -19,6 +23,10 @@ type MsgContext struct {
Msg *tgapi.Message Msg *tgapi.Message
From *tgapi.User From *tgapi.User
// Logger is the logger assigned by the matched plugin for the current handler call.
// It may fall back to the bot logger when the plugin has no dedicated logger.
Logger *slog.Logger
InlineMsgId string InlineMsgId string
CallbackMsgId int CallbackMsgId int
CallbackQueryId string CallbackQueryId string
@@ -28,10 +36,12 @@ type MsgContext struct {
Args []string Args []string
errorTemplate string errorTemplate string
botLogger *slog.Logger
l10n *L10n l10n *L10n
draftProvider *DraftProvider draftProvider *DraftProvider
payloadType BotPayloadType payloadType BotPayloadType
sceneRuntime sceneRuntime
ctx context.Context
} }
// AnswerMessage represents a message sent or edited via MsgContext. // AnswerMessage represents a message sent or edited via MsgContext.
@@ -43,9 +53,12 @@ type AnswerMessage struct {
ctx *MsgContext // internal back-reference ctx *MsgContext // internal back-reference
} }
// edit is an internal helper to edit a message's text with optional keyboard and parse mode. // Internal helper for text edits with optional keyboard and parse mode.
// Used by Edit, EditMarkdown, EditCallback, etc.
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageTextP{ params := tgapi.EditMessageTextP{
Text: text, Text: text,
ParseMode: parseMode, ParseMode: parseMode,
@@ -57,15 +70,15 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
case ctx.InlineMsgId != "": case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId params.InlineMessageID = ctx.InlineMsgId
default: default:
ctx.botLogger.Errorln("Can't edit message: no valid message target") ctx.Logger.Errorln(ErrEditTargetMissing)
return nil return nil
} }
if keyboard != nil { if keyboard != nil {
params.ReplyMarkup = keyboard.Get() params.ReplyMarkup = keyboard.Get()
} }
msg, _, err := ctx.Api.EditMessageText(params) msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
resultMessageID := messageId resultMessageID := messageId
@@ -91,11 +104,10 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2) return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2)
} }
// editCallback is an internal helper to edit the message associated with a callback query. // Internal helper for editing callback-linked messages.
// Supports both regular callback messages and inline callback messages.
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" { if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
ctx.botLogger.Errorln("Can't edit non-callback update message") ctx.Logger.Errorln(ErrCallbackMessageMissing)
return nil return nil
} }
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode) return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
@@ -125,9 +137,12 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2) return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2)
} }
// editPhotoText edits the caption of a photo/video message. // Internal helper for media-caption edits.
// Returns nil when no valid edit target is available for the current context.
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageCaptionP{ params := tgapi.EditMessageCaptionP{
Caption: text, Caption: text,
ParseMode: parseMode, ParseMode: parseMode,
@@ -139,16 +154,16 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
case ctx.InlineMsgId != "": case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId params.InlineMessageID = ctx.InlineMsgId
default: default:
ctx.botLogger.Errorln("Can't edit caption: no valid message target") ctx.Logger.Errorln(ErrEditTargetMissing)
return nil return nil
} }
if kb != nil { if kb != nil {
params.ReplyMarkup = kb.Get() params.ReplyMarkup = kb.Get()
} }
msg, _, err := ctx.Api.EditMessageCaption(params) msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
resultMessageID := messageId resultMessageID := messageId
@@ -184,11 +199,14 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2) return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2)
} }
// answer sends a new message with optional keyboard and parse mode. // Internal helper for message replies with optional keyboard and parse mode.
// Uses API limiter to respect Telegram rate limits per chat.
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't answer message without a message") ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
return nil return nil
} }
params := tgapi.SendMessageP{ params := tgapi.SendMessageP{
@@ -206,9 +224,9 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
} }
msg, err := ctx.Api.SendMessage(params) msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
return &AnswerMessage{ return &AnswerMessage{
@@ -221,6 +239,14 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage {
return ctx.answer(text, nil, tgapi.ParseNone) return ctx.answer(text, nil, tgapi.ParseNone)
} }
// AnswerLong sends one or more plain-text messages if text exceeds Telegram's limit.
//
// The text is split into Telegram-safe chunks. Returned messages preserve send
// order. If a chunk fails to send, already-sent messages are returned.
func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
return ctx.answerLong(text, nil, tgapi.ParseNone)
}
// AnswerMarkdown sends a message using MarkdownV2 formatting. // AnswerMarkdown sends a message using MarkdownV2 formatting.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -233,6 +259,11 @@ func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage {
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone) return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
} }
// AnswerLongf formats a string using fmt.Sprintf and sends it as one or more plain-text messages.
func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessage {
return ctx.answerLong(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
}
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2. // AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -245,6 +276,13 @@ func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage
return ctx.answer(text, kb, tgapi.ParseNone) return ctx.answer(text, kb, tgapi.ParseNone)
} }
// KeyboardLong sends long plain text split across multiple messages.
//
// The inline keyboard is attached only to the final chunk.
func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage {
return ctx.answerLong(text, kb, tgapi.ParseNone)
}
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2. // KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -252,10 +290,53 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *
return ctx.answer(text, keyboard, tgapi.ParseMDV2) return ctx.answer(text, keyboard, tgapi.ParseMDV2)
} }
// answerPhoto sends a photo with optional caption and keyboard. func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
if parseMode != tgapi.ParseNone {
ctx.Logger.Errorln(ErrMessageSplitImpossible)
return nil
}
if ctx.Msg == nil {
ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if err := validateMessageText(text); err == nil {
msg := ctx.answer(text, keyboard, parseMode)
if msg == nil {
return nil
}
return []*AnswerMessage{msg}
} else if !errors.Is(err, ErrMessageTooLong) {
ctx.Logger.Errorln(err)
return nil
}
parts := SplitMessageText(text)
messages := make([]*AnswerMessage, 0, len(parts))
for i, part := range parts {
partKeyboard := (*InlineKeyboard)(nil)
if i == len(parts)-1 {
partKeyboard = keyboard
}
msg := ctx.answer(part, partKeyboard, parseMode)
if msg == nil {
break
}
messages = append(messages, msg)
}
if len(messages) == 0 {
return nil
}
return messages
}
// Internal helper for photo replies with optional caption and keyboard.
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't answer message without a message") ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
return nil return nil
} }
params := tgapi.SendPhotoP{ params := tgapi.SendPhotoP{
@@ -274,9 +355,9 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID) params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
} }
msg, err := ctx.Api.SendPhoto(params) msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
return &AnswerMessage{ return &AnswerMessage{
@@ -320,22 +401,22 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
} }
// delete removes a message by ID. // Internal helper that deletes a message by ID.
func (ctx *MsgContext) delete(messageId int) { func (ctx *MsgContext) delete(messageId int) {
if messageId == 0 { if messageId == 0 {
ctx.botLogger.Errorln("Can't delete message: message ID zero") ctx.Logger.Errorln(ErrMessageIDZero)
return return
} }
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't delete message: no chat message context") ctx.Logger.Errorln(ErrMessageContextNil)
return return
} }
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{
ChatID: ctx.Msg.Chat.ID, ChatID: ctx.Msg.Chat.ID,
MessageID: messageId, MessageID: messageId,
}) })
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
} }
} }
@@ -345,24 +426,23 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
// CallbackDelete deletes the message that triggered the callback query. // CallbackDelete deletes the message that triggered the callback query.
func (ctx *MsgContext) CallbackDelete() { func (ctx *MsgContext) CallbackDelete() {
if ctx.CallbackMsgId == 0 { if ctx.CallbackMsgId == 0 {
ctx.botLogger.Errorln("Can't delete callback message: no callback message ID") ctx.Logger.Errorln(ErrCallbackMessageMissing)
return return
} }
ctx.delete(ctx.CallbackMsgId) ctx.delete(ctx.CallbackMsgId)
} }
// answerCallbackQuery sends a response to a callback query (optional text/alert/url). // Internal helper that answers a callback query with optional text, alert, or URL.
// Does nothing if CallbackQueryId is empty.
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryId) == 0 { if len(ctx.CallbackQueryId) == 0 {
return return
} }
_, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{ _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{
CallbackQueryID: ctx.CallbackQueryId, CallbackQueryID: ctx.CallbackQueryId,
Text: text, ShowAlert: showAlert, URL: url, Text: text, ShowAlert: showAlert, URL: url,
}) })
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
} }
} }
@@ -381,7 +461,7 @@ func (ctx *MsgContext) AnswerCbQueryUrl(u string) { ctx.answerCallbackQuery(u, "
// SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity. // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't send action without chat message context") ctx.Logger.Errorln("Can't send action without chat message context")
return return
} }
params := tgapi.SendChatActionP{ params := tgapi.SendChatActionP{
@@ -390,16 +470,13 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
if ctx.Msg.MessageThreadID > 0 { if ctx.Msg.MessageThreadID > 0 {
params.MessageThreadID = ctx.Msg.MessageThreadID params.MessageThreadID = ctx.Msg.MessageThreadID
} }
_, err := ctx.Api.SendChatAction(params) _, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
} }
} }
// error sends an error message to the user and logs it. // Internal helper that formats, sends, and logs an error.
// Uses errorTemplate to format the message.
// For callbacks: sends as callback answer (no alert).
// For regular messages: sends as plain text.
func (ctx *MsgContext) error(err error) { func (ctx *MsgContext) error(err error) {
text := fmt.Sprintf(ctx.errorTemplate, err.Error()) text := fmt.Sprintf(ctx.errorTemplate, err.Error())
@@ -408,7 +485,7 @@ func (ctx *MsgContext) error(err error) {
} else { } else {
ctx.answer(text, nil, tgapi.ParseNone) ctx.answer(text, nil, tgapi.ParseNone)
} }
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
} }
// Error is an alias for error(). // Error is an alias for error().
@@ -416,15 +493,25 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil") ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if ctx.Api == nil {
ctx.Logger.Errorln(ErrAPIIsNil)
return nil
}
if ctx.draftProvider == nil {
ctx.Logger.Errorln(ErrDraftProviderNil)
return nil return nil
} }
c, cancel := context.WithTimeout(context.Background(), 5*time.Second) if ctx.Api.Limiter != nil {
defer cancel() c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { defer cancel()
ctx.botLogger.Errorln(err) if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
return nil ctx.Logger.Errorln(err)
return nil
}
} }
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID) draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
@@ -459,3 +546,184 @@ func (ctx *MsgContext) Translate(key string) string {
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard { func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
return NewInlineKeyboard(ctx.payloadType, maxRow) return NewInlineKeyboard(ctx.payloadType, maxRow)
} }
func bindPositional(args []string, dst any) error {
v := reflect.ValueOf(dst)
if v.Kind() != reflect.Pointer || v.IsNil() {
return ErrBindArgsTargetNotPointer
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return ErrBindArgsTargetNotStruct
}
t := v.Type()
fields := make([]int, 0, v.NumField())
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
if !field.CanSet() {
continue
}
fields = append(fields, i)
}
argIndex := 0
for fieldPos, fieldIndex := range fields {
field := v.Field(fieldIndex)
fieldType := t.Field(fieldIndex)
if argIndex >= len(args) {
// Leave trailing fields at their zero values when arguments run out.
break
}
isLastBindableField := fieldPos == len(fields)-1
raw := args[argIndex]
if isLastBindableField && field.Kind() == reflect.String {
raw = strings.Join(args[argIndex:], " ")
}
switch field.Kind() {
case reflect.String:
field.SetString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetUint(n)
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetFloat(f)
case reflect.Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetBool(b)
default:
return fmt.Errorf("%w: field %s: %s", ErrBindArgsUnsupportedFieldType, fieldType.Name, field.Kind())
}
if isLastBindableField && field.Kind() == reflect.String {
break
}
argIndex++
}
return nil
}
// BindArgs binds positional command arguments from ctx.Args into dst.
//
// Exported struct fields are filled in declaration order. When fewer arguments
// are provided than fields, the remaining fields keep their zero values. If the
// final bindable field is a string, it receives the remaining arguments joined
// with spaces.
func (ctx *MsgContext) BindArgs(dst any) error {
return bindPositional(ctx.Args, dst)
}
// Context returns the request-scoped context associated with the current update.
func (ctx *MsgContext) Context() context.Context {
if ctx.ctx == nil {
return context.Background()
}
return ctx.ctx
}
// EnterScene enters the named scene at its configured entry step.
func (ctx *MsgContext) EnterScene(name string) error {
if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
scene, ok := ctx.sceneRuntime.findScene(name)
if !ok {
return ErrSceneNotFound
}
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok {
return ErrCantFindSession
}
if scene.Entry == "" {
return ErrSceneEntryNotSet
}
if _, ok := scene.Steps[scene.Entry]; !ok {
return ErrSceneStepNotFound
}
session := SceneSession{
Scene: scene.Name,
Step: scene.Entry,
}
return ctx.sceneRuntime.setSession(key, session)
}
// EnterSceneStep enters the named scene at a specific step.
func (ctx *MsgContext) EnterSceneStep(name, step string) error {
if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
scene, ok := ctx.sceneRuntime.findScene(name)
if !ok {
return ErrSceneNotFound
}
if _, ok := scene.Steps[step]; !ok {
return ErrSceneStepNotFound
}
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok {
return ErrCantFindSession
}
session := SceneSession{
Scene: scene.Name,
Step: step,
}
return ctx.sceneRuntime.setSession(key, session)
}
// ExitScene leaves the currently active scene for this context.
func (ctx *MsgContext) ExitScene() error {
if ctx.sceneRuntime == nil {
return ErrSceneRuntimeNil
}
_, session, err := ctx.sceneRuntime.findSceneSession(ctx)
if err != nil {
return err
}
if session.Scene == "" {
return ErrNotInScene
}
scene, ok := ctx.sceneRuntime.findScene(session.Scene)
if !ok {
return ErrSceneNotFound
}
key, ok := ctx.sceneRuntime.buildSceneKey(scene.Scope, ctx)
if !ok {
return ErrCantFindSession
}
return ctx.sceneRuntime.deleteSession(key)
}
+251 -5
View File
@@ -2,13 +2,15 @@ package laniakea
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"reflect"
"strings" "strings"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
@@ -37,8 +39,8 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -48,7 +50,7 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}, Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)},
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
}, },
botLogger: slog.CreateLogger(), Logger: slog.CreateLogger(),
} }
answer := ctx.AnswerPhoto("photo-id", "caption") answer := ctx.AnswerPhoto("photo-id", "caption")
@@ -62,3 +64,247 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
t.Fatalf("unexpected direct_messages_topic_id: %v", got) t.Fatalf("unexpected direct_messages_topic_id: %v", got)
} }
} }
func TestBindArgsBindsScalarFields(t *testing.T) {
type input struct {
ID int
Active bool
Score float64
Name string
}
ctx := &MsgContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}}
var got input
if err := ctx.BindArgs(&got); err != nil {
t.Fatalf("BindArgs returned error: %v", err)
}
want := input{
ID: 42,
Active: true,
Score: 3.5,
Name: "Ada Lovelace",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected bound value: got %#v want %#v", got, want)
}
}
func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) {
type input struct {
ID int
Reason string
Admin bool
}
ctx := &MsgContext{Args: []string{"7"}}
var got input
if err := ctx.BindArgs(&got); err != nil {
t.Fatalf("BindArgs returned error: %v", err)
}
if got.ID != 7 {
t.Fatalf("unexpected ID: got %d want 7", got.ID)
}
if got.Reason != "" {
t.Fatalf("expected zero-value Reason, got %q", got.Reason)
}
if got.Admin {
t.Fatal("expected zero-value Admin")
}
}
func TestBindArgsRejectsInvalidTargets(t *testing.T) {
ctx := &MsgContext{Args: []string{"1"}}
if err := ctx.BindArgs(nil); !errors.Is(err, ErrBindArgsTargetNotPointer) {
t.Fatalf("expected ErrBindArgsTargetNotPointer for nil target, got %v", err)
}
var notStruct int
if err := ctx.BindArgs(&notStruct); !errors.Is(err, ErrBindArgsTargetNotStruct) {
t.Fatalf("expected ErrBindArgsTargetNotStruct for non-struct target, got %v", err)
}
}
func TestBindArgsReportsConversionFailures(t *testing.T) {
type input struct {
ID int
}
ctx := &MsgContext{Args: []string{"oops"}}
var got input
err := ctx.BindArgs(&got)
if err == nil {
t.Fatal("expected BindArgs to fail")
}
if !errors.Is(err, ErrBindArgsConversion) {
t.Fatalf("expected ErrBindArgsConversion, got %v", err)
}
if !strings.Contains(err.Error(), "field ID") {
t.Fatalf("expected field name in error, got %v", err)
}
}
func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) {
type input struct {
Tags []string
}
ctx := &MsgContext{Args: []string{"tag"}}
var got input
err := ctx.BindArgs(&got)
if err == nil {
t.Fatal("expected BindArgs to fail")
}
if !errors.Is(err, ErrBindArgsUnsupportedFieldType) {
t.Fatalf("expected ErrBindArgsUnsupportedFieldType, got %v", err)
}
}
func TestAnswerRejectsEmptyMessage(t *testing.T) {
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}},
Logger: slog.CreateLogger(),
}
if answer := ctx.Answer(""); answer != nil {
t.Fatal("expected nil answer for empty message")
}
}
func TestAnswerRejectsLongMessageWithoutSendingRequest(t *testing.T) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request")
return nil, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}},
Logger: slog.CreateLogger(),
}
if answer := ctx.Answer(strings.Repeat("a", maxMessageTextLen+1)); answer != nil {
t.Fatal("expected nil answer for long message")
}
}
func TestValidateMessageText(t *testing.T) {
if err := validateMessageText(""); !errors.Is(err, ErrEmptyMessage) {
t.Fatalf("expected ErrEmptyMessage, got %v", err)
}
if err := validateMessageText(strings.Repeat("a", maxMessageTextLen+1)); !errors.Is(err, ErrMessageTooLong) {
t.Fatalf("expected ErrMessageTooLong, got %v", err)
}
if err := validateMessageText("ok"); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
func TestValidateCaptionText(t *testing.T) {
if err := validateCaptionText(strings.Repeat("a", maxMessageCaptionLen+1)); !errors.Is(err, ErrCaptionTooLong) {
t.Fatalf("expected ErrCaptionTooLong, got %v", err)
}
if err := validateCaptionText(""); err != nil {
t.Fatalf("expected nil error, got %v", err)
}
}
func TestSplitMessageTextPreservesContent(t *testing.T) {
text := "alpha beta\n" + strings.Repeat("x", maxMessageTextLen) + " omega"
parts := SplitMessageText(text)
if len(parts) < 2 {
t.Fatalf("expected multiple parts, got %d", len(parts))
}
for i, part := range parts {
if got := len([]rune(part)); got > maxMessageTextLen {
t.Fatalf("part %d exceeded limit: %d", i, got)
}
}
if got := strings.Join(parts, ""); got != text {
t.Fatalf("split/join mismatch: got %q want %q", got, text)
}
}
func TestAnswerLongSplitsRequestsAndAttachesKeyboardToLastChunk(t *testing.T) {
var requests []map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
requests = append(requests, got)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"message_id":9,"date":1}}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
ctx := &MsgContext{
Api: api,
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)}},
Logger: slog.CreateLogger(),
}
kb := NewInlineKeyboardJson(1).AddCallbackButton("A", "cmd")
text := strings.Repeat("a", maxMessageTextLen) + " " + strings.Repeat("b", 32)
messages := ctx.KeyboardLong(text, kb)
if got := len(messages); got != 2 {
t.Fatalf("expected 2 sent messages, got %d", got)
}
if got := len(requests); got != 2 {
t.Fatalf("expected 2 requests, got %d", got)
}
if _, ok := requests[0]["reply_markup"]; ok {
t.Fatal("did not expect keyboard on first chunk")
}
if _, ok := requests[1]["reply_markup"]; !ok {
t.Fatal("expected keyboard on final chunk")
}
gotTexts := []string{requests[0]["text"].(string), requests[1]["text"].(string)}
wantTexts := SplitMessageText(text)
if !reflect.DeepEqual(gotTexts, wantTexts) {
t.Fatalf("unexpected chunk texts: got %q want %q", gotTexts, wantTexts)
}
}
+136 -37
View File
@@ -4,10 +4,13 @@ import (
"errors" "errors"
"regexp" "regexp"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
) )
// CommandValueType defines the expected type of a command argument. // CommandValueType defines the expected type of command argument.
type CommandValueType string type CommandValueType string
const ( const (
@@ -49,12 +52,12 @@ type CommandArg struct {
// NewCommandArg creates a new CommandArg with the given text and type. // NewCommandArg creates a new CommandArg with the given text and type.
// Uses a default regex based on the type (string or int). // Uses a default regex based on the type (string or int).
// For CommandValueAnyType, no validation is performed. // For CommandValueAnyType, no validation is performed.
func NewCommandArg(text string) *CommandArg { func NewCommandArg(text string) CommandArg {
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false} return CommandArg{CommandValueAnyType, text, CommandRegexString, false}
} }
// SetValueType sets expected value type and switches built-in validation regexp. // SetValueType sets expected value type and switches built-in validation regexp.
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { func (c CommandArg) SetValueType(t CommandValueType) CommandArg {
regex := CommandRegexString regex := CommandRegexString
switch t { switch t {
case CommandValueIntType: case CommandValueIntType:
@@ -71,14 +74,15 @@ func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
// SetRequired marks this argument as required. // SetRequired marks this argument as required.
// Returns the receiver for method chaining. // Returns the receiver for method chaining.
func (c *CommandArg) SetRequired() *CommandArg { func (c CommandArg) SetRequired() CommandArg {
c.required = true c.required = true
return c return c
} }
// CommandExecutor is the function type that executes a command. // CommandExecutor is the function type that executes a command.
// It receives the message context and a database context (generic). // It receives the message context and a database context (generic).
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T) // Returning a non-nil error routes it through the bot's error handler.
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext T) error
// Command represents a bot command with arguments, description, and executor. // Command represents a bot command with arguments, description, and executor.
// Can be registered in a Plugin and optionally skipped from auto-generation. // Can be registered in a Plugin and optionally skipped from auto-generation.
@@ -122,14 +126,12 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] {
return c return c
} }
// validateArgs checks if the provided arguments match the command's requirements. // Internal helper that validates provided command arguments.
// Returns ErrCmdArgCountMismatch if too few arguments are provided.
// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation.
func (c *Command[T]) validateArgs(args []string) error { func (c *Command[T]) validateArgs(args []string) error {
// Count required args for i := range c.args.Len() {
requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len() if i >= len(args) && c.args.Get(i).required {
if len(args) < requiredCount { return ErrCmdArgCountMismatch
return ErrCmdArgCountMismatch }
} }
// Validate each argument against its regex // Validate each argument against its regex
@@ -151,19 +153,35 @@ func (c *Command[T]) validateArgs(args []string) error {
// Plugin represents a collection of commands and payloads (e.g., callback handlers), // Plugin represents a collection of commands and payloads (e.g., callback handlers),
// with shared middleware and configuration. // with shared middleware and configuration.
//
// A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins.
// After registration, treat the plugin as committed and do not mutate it further.
// Post-registration changes through the original *Plugin are not a supported API.
type Plugin[T DbContext] struct { type Plugin[T DbContext] struct {
name string // Name of the plugin (e.g., "admin", "user") name string // Name of the plugin (e.g., "admin", "user")
commands map[string]*Command[T] // Registered commands (triggered by message) commands map[string]*Command[T] // Registered commands (triggered by message)
payloads map[string]*Command[T] // Registered payloads (triggered by callback data) payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
scenes map[string]*Scene[T] // Optional scenes for multi-step interactions
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
logger *slog.Logger
handlers map[tgapi.UpdateType]CommandExecutor[T]
onClose func() error
} }
// NewPlugin creates a new Plugin with the given name. // NewPlugin creates a new Plugin with the given name.
func NewPlugin[T DbContext](name string) *Plugin[T] { func NewPlugin[T DbContext](name string) *Plugin[T] {
return &Plugin[T]{ return &Plugin[T]{
name, make(map[string]*Command[T]), name: name,
make(map[string]*Command[T]), extypes.Slice[Middleware[T]]{}, false, commands: make(map[string]*Command[T]),
payloads: make(map[string]*Command[T]),
middlewares: make(extypes.Slice[Middleware[T]], 0),
scenes: make(map[string]*Scene[T]),
skipAutoCmd: false,
logger: nil,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
} }
} }
@@ -197,6 +215,43 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...
return cmd return cmd
} }
// AddScene registers a multi-step scene in the plugin.
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil {
return p
}
scene.PluginName = p.name
scene.setPluginName(p.name)
p.scenes[scene.Name] = scene
return p
}
// NewScene creates, registers, and returns a new scene owned by the plugin.
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
scene := NewScene[T](name)
scene.setPluginName(p.name)
p.AddScene(scene)
return scene
}
// AddUpdateHandler registers a handler for a non-command update type.
// Message, channel post, and callback query updates stay on the command/payload flow.
func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] {
switch t {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
if p.logger == nil {
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel())
logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
_ = logger.Close()
return p
}
p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
return p
}
p.handlers[t] = handler
return p
}
// AddMiddleware adds a middleware to the plugin's global middleware chain. // AddMiddleware adds a middleware to the plugin's global middleware chain.
// Middlewares are executed before any command or payload. // Middlewares are executed before any command or payload.
func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] {
@@ -210,10 +265,53 @@ func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] {
return p return p
} }
// executeCmd finds and executes a command by its trigger string. // SetLogger sets the logger used for this plugin's handlers.
// Validates arguments and runs middlewares before executor. //
// On error, sends an error message to the user via ctx.error(). // Call this before Bot.AddPlugins. If the plugin is already registered, changing
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { // the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) SetLogger(l *slog.Logger) *Plugin[T] {
p.logger = l
return p
}
// RemoveLogger clears the custom logger for this plugin.
//
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
// the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) RemoveLogger() *Plugin[T] {
p.logger = nil
return p
}
// SetOnClose registers a callback invoked from Plugin.Close after the plugin
// logger is closed.
//
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
// the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) SetOnClose(f func() error) *Plugin[T] {
p.onClose = f
return p
}
// Close releases plugin-owned resources such as its logger and optional
// OnClose callback.
func (p *Plugin[T]) Close() error {
var e []error
if p.logger != nil {
if err := p.logger.Close(); err != nil {
e = append(e, err)
}
}
if p.onClose != nil {
if err := p.onClose(); err != nil {
e = append(e, err)
}
}
return errors.Join(e...)
}
// Internal helper that validates and executes a command handler.
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) {
command, exists := p.commands[cmd] command, exists := p.commands[cmd]
if !exists { if !exists {
ctx.error(errors.New("command not found")) ctx.error(errors.New("command not found"))
@@ -227,19 +325,19 @@ func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) {
// Run command-specific middlewares // Run command-specific middlewares
for _, m := range command.middlewares { for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) { if !m.Execute(ctx, db) {
return return
} }
} }
// Execute command // Execute command
command.exec(ctx, dbContext) if err := command.exec(ctx, db); err != nil {
ctx.error(err)
}
} }
// executePayload finds and executes a payload by its callback_data string. // Internal helper that validates and executes a payload handler.
// Validates arguments and runs middlewares before executor. func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) {
// On error, sends an error message to the user via ctx.error().
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) {
command, exists := p.payloads[payload] command, exists := p.payloads[payload]
if !exists { if !exists {
ctx.error(errors.New("payload not found")) ctx.error(errors.New("payload not found"))
@@ -253,18 +351,19 @@ func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T
// Run command-specific middlewares // Run command-specific middlewares
for _, m := range command.middlewares { for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) { if !m.Execute(ctx, db) {
return return
} }
} }
// Execute payload // Execute payload
command.exec(ctx, dbContext) if err := command.exec(ctx, db); err != nil {
ctx.error(err)
}
} }
// executeMiddlewares runs all plugin middlewares in order. // Internal helper that runs plugin middlewares in order.
// Returns false if any middleware returns false (blocks execution). func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool {
func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
for _, m := range p.middlewares { for _, m := range p.middlewares {
if !m.Execute(ctx, db) { if !m.Execute(ctx, db) {
return false return false
@@ -276,7 +375,7 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
// MiddlewareExecutor is the function type for middleware logic. // MiddlewareExecutor is the function type for middleware logic.
// Returns true to continue execution, false to block it. // Returns true to continue execution, false to block it.
// If async, return value is ignored. // If async, return value is ignored.
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db T) bool
// Middleware represents a reusable execution interceptor. // Middleware represents a reusable execution interceptor.
// Can be synchronous (blocking) or asynchronous (non-blocking). // Can be synchronous (blocking) or asynchronous (non-blocking).
@@ -288,19 +387,19 @@ type Middleware[T DbContext] struct {
} }
// NewMiddleware creates a new synchronous middleware. // NewMiddleware creates a new synchronous middleware.
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] { func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] {
return &Middleware[T]{name, executor, 0, false} return Middleware[T]{name, executor, 0, false}
} }
// SetOrder sets the execution order (currently ignored). // SetOrder sets the execution order (currently ignored).
func (m *Middleware[T]) SetOrder(order int) *Middleware[T] { func (m Middleware[T]) SetOrder(order int) Middleware[T] {
m.order = order m.order = order
return m return m
} }
// SetAsync marks the middleware to run asynchronously. // SetAsync marks the middleware to run asynchronously.
// Execution continues regardless of its return value. // Execution continues regardless of its return value.
func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { func (m Middleware[T]) SetAsync(async bool) Middleware[T] {
m.async = async m.async = async
return m return m
} }
@@ -308,7 +407,7 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
// Execute runs the middleware. // Execute runs the middleware.
// If async, runs in a goroutine and returns true immediately. // If async, runs in a goroutine and returns true immediately.
// Otherwise, returns the result of the executor. // Otherwise, returns the result of the executor.
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { func (m Middleware[T]) Execute(ctx *MsgContext, db T) bool {
if m.async { if m.async {
ctx := *ctx // copy context to avoid race condition ctx := *ctx // copy context to avoid race condition
go func(ctx MsgContext) { go func(ctx MsgContext) {
+18 -2
View File
@@ -6,7 +6,7 @@ import (
) )
func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "int", *NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) intCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
if err := intCmd.validateArgs([]string{"123"}); err != nil { if err := intCmd.validateArgs([]string{"123"}); err != nil {
t.Fatalf("expected valid integer argument, got %v", err) t.Fatalf("expected valid integer argument, got %v", err)
} }
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
} }
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "bool", *NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db NoDB) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
if err := boolCmd.validateArgs([]string{"false"}); err != nil { if err := boolCmd.validateArgs([]string{"false"}); err != nil {
t.Fatalf("expected valid bool argument, got %v", err) t.Fatalf("expected valid bool argument, got %v", err)
} }
@@ -22,3 +22,19 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial bool match, got %v", err) t.Fatalf("expected ErrCmdArgRegexpMismatch for partial bool match, got %v", err)
} }
} }
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand[NoDB](
func(ctx *MsgContext, db NoDB) error { return nil },
"mixed",
NewCommandArg("optional"),
NewCommandArg("required").SetRequired(),
)
if err := cmd.validateArgs([]string{"only-optional"}); !errors.Is(err, ErrCmdArgCountMismatch) {
t.Fatalf("expected ErrCmdArgCountMismatch when required second arg is missing, got %v", err)
}
if err := cmd.validateArgs([]string{"optional", "required"}); err != nil {
t.Fatalf("expected both args to validate, got %v", err)
}
}
+5 -5
View File
@@ -33,8 +33,8 @@ type Runner[T DbContext] struct {
// //
// Builder methods (Onetime, Async, Timeout) can be chained to customize behavior. // Builder methods (Onetime, Async, Timeout) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute(). // DO NOT call builder methods concurrently or after Execute().
func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] {
return &Runner[T]{ return Runner[T]{
name: name, name: name,
fn: fn, fn: fn,
async: true, // Default: run asynchronously async: true, // Default: run asynchronously
@@ -45,7 +45,7 @@ func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] {
// Onetime sets whether the runner executes once or repeatedly. // Onetime sets whether the runner executes once or repeatedly.
// If true, the runner runs only once. // If true, the runner runs only once.
// If false, the runner runs in a loop with the configured timeout. // If false, the runner runs in a loop with the configured timeout.
func (r *Runner[T]) Onetime(onetime bool) *Runner[T] { func (r Runner[T]) Onetime(onetime bool) Runner[T] {
r.onetime = onetime r.onetime = onetime
return r return r
} }
@@ -55,7 +55,7 @@ func (r *Runner[T]) Onetime(onetime bool) *Runner[T] {
// If false, the runner blocks the caller during execution. // If false, the runner blocks the caller during execution.
// //
// Note: If onetime=false and async=false, the runner will be skipped with a warning. // Note: If onetime=false and async=false, the runner will be skipped with a warning.
func (r *Runner[T]) Async(async bool) *Runner[T] { func (r Runner[T]) Async(async bool) Runner[T] {
r.async = async r.async = async
return r return r
} }
@@ -69,7 +69,7 @@ func (r *Runner[T]) Async(async bool) *Runner[T] {
// //
// A zero value (time.Duration(0)) is allowed but may trigger a warning // A zero value (time.Duration(0)) is allowed but may trigger a warning
// if used with a background (non-onetime) async runner. // if used with a background (non-onetime) async runner.
func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] {
r.timeout = timeout r.timeout = timeout
return r return r
} }
+62
View File
@@ -0,0 +1,62 @@
package laniakea
import (
"context"
"sync/atomic"
"testing"
"time"
"git.scuroneko.dev/scuroneko/slog"
)
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
var calls atomic.Int32
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
runners: []Runner[NoDB]{
NewRunner("sync-once", func(*Bot[NoDB]) error {
calls.Add(1)
return nil
}).Onetime(true).Async(false),
},
}
bot.ExecRunners(context.Background())
if got := calls.Load(); got != 1 {
t.Fatalf("unexpected sync runner call count: %d", got)
}
}
func TestExecRunnersStopsBackgroundRunnerOnCancel(t *testing.T) {
var calls atomic.Int32
triggered := make(chan struct{}, 1)
ctx, cancel := context.WithCancel(context.Background())
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
runners: []Runner[NoDB]{
NewRunner("background", func(*Bot[NoDB]) error {
if calls.Add(1) == 1 {
triggered <- struct{}{}
}
return nil
}).Timeout(5 * time.Millisecond),
},
}
bot.ExecRunners(ctx)
select {
case <-triggered:
case <-time.After(time.Second):
t.Fatal("background runner did not execute")
}
cancel()
bot.runnerBgWG.Wait()
if calls.Load() == 0 {
t.Fatal("expected background runner to be called at least once")
}
}
+238
View File
@@ -0,0 +1,238 @@
package laniakea
import (
"encoding/json"
"sync"
)
// SceneHandler handles a scene step, scene command, or fallback message.
type SceneHandler[T any] func(ctx *SceneContext, db T) (SceneResult, error)
// Scene defines a multi-step conversational flow.
type Scene[T any] struct {
// Name identifies the scene in plugin registration and session state.
Name string
// Scope controls how active scene sessions are keyed and shared.
Scope SceneScope
// Entry names the first step used by MsgContext.EnterScene.
Entry string
// PluginName stores the owning plugin name for scene resolution.
PluginName string
steps map[string]SceneHandler[T]
commands map[string]SceneHandler[T]
message SceneHandler[T]
}
// NewScene creates a new scene with user-chat scope by default.
func NewScene[T any](name string) *Scene[T] {
return &Scene[T]{
Name: name,
Scope: SceneScopeUserChat,
Entry: "",
steps: make(map[string]SceneHandler[T]),
commands: make(map[string]SceneHandler[T]),
message: nil,
}
}
// SetScope changes how scene sessions are keyed and shared.
func (s *Scene[T]) SetScope(scope SceneScope) *Scene[T] {
s.Scope = scope
return s
}
// SetEntry sets the initial step entered by MsgContext.EnterScene.
func (s *Scene[T]) SetEntry(step string) *Scene[T] {
s.Entry = step
return s
}
func (s *Scene[T]) setPluginName(name string) *Scene[T] {
s.PluginName = name
return s
}
// OnStep registers a handler for a named scene step.
func (s *Scene[T]) OnStep(step string, handler SceneHandler[T]) *Scene[T] {
s.steps[step] = handler
return s
}
// OnCommand registers a command handler active while the scene is running.
func (s *Scene[T]) OnCommand(cmd string, handler SceneHandler[T]) *Scene[T] {
s.commands[cmd] = handler
return s
}
// OnMessage registers a fallback handler used when no scene command or step matches.
func (s *Scene[T]) OnMessage(handler SceneHandler[T]) *Scene[T] {
s.message = handler
return s
}
func (s *Scene[T]) executeCommand(cmd string, ctx *SceneContext, db T) (SceneResult, bool, error) {
handler, ok := s.commands[cmd]
if !ok {
return SceneResult{}, false, nil
}
result, err := handler(ctx, db)
return result, true, err
}
func (s *Scene[T]) executeStep(step string, ctx *SceneContext, db T) (SceneResult, bool, error) {
handler, ok := s.steps[step]
if !ok {
return SceneResult{}, false, nil
}
result, err := handler(ctx, db)
return result, true, err
}
func (s *Scene[T]) executeMessage(ctx *SceneContext, db T) (SceneResult, bool, error) {
if s.message == nil {
return SceneResult{}, false, nil
}
result, err := s.message(ctx, db)
return result, true, err
}
// SceneSession stores the active scene state for one session key.
type SceneSession struct {
// Scene is the registered scene name for the active session.
Scene string
// Step is the current step name inside the active scene.
Step string
// Data stores opaque session payload bytes, typically JSON.
Data []byte
}
// SetData stores arbitrary opaque session data.
func (s *SceneSession) SetData(data []byte) {
s.Data = data
}
// GetData returns the raw session data payload.
func (s *SceneSession) GetData() []byte {
return s.Data
}
// HasData reports whether the session has a non-empty data payload.
func (s *SceneSession) HasData() bool {
return len(s.Data) > 0
}
// ClearData removes any stored session data.
func (s *SceneSession) ClearData() {
s.Data = nil
}
// BindData unmarshals the stored JSON payload into v.
func (s *SceneSession) BindData(v any) error {
if len(s.Data) == 0 {
return nil
}
return json.Unmarshal(s.Data, v)
}
// SaveData marshals v as JSON and stores it in the session.
func (s *SceneSession) SaveData(v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
s.Data = data
return nil
}
// SessionStore persists scene sessions by key.
type SessionStore interface {
Get(key string) (SceneSession, error)
Set(key string, session SceneSession) error
Delete(key string) error
}
// MemorySessionStore stores scene sessions in memory.
type MemorySessionStore struct {
store map[string]SceneSession
mu sync.RWMutex
}
// NewMemorySessionStore creates an empty in-memory session store.
func NewMemorySessionStore() *MemorySessionStore {
return &MemorySessionStore{
store: make(map[string]SceneSession),
}
}
// Get returns the session stored under key, or the zero session when absent.
func (s *MemorySessionStore) Get(key string) (SceneSession, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if session, ok := s.store[key]; ok {
return session, nil
}
return SceneSession{}, nil
}
// Set stores session under key.
func (s *MemorySessionStore) Set(key string, session SceneSession) error {
s.mu.Lock()
s.store[key] = session
s.mu.Unlock()
return nil
}
// Delete removes the session stored under key.
func (s *MemorySessionStore) Delete(key string) error {
s.mu.Lock()
delete(s.store, key)
s.mu.Unlock()
return nil
}
// SceneResult describes how scene execution should proceed after a handler returns.
type SceneResult struct {
Action SceneAction
Next string
}
// SceneAction controls how the bot updates scene state after a handler returns.
type SceneAction int
const (
// SceneActionStay keeps the current scene and step active.
SceneActionStay SceneAction = iota
// SceneActionNext moves the session to another named step.
SceneActionNext
// SceneActionExit removes the current scene session.
SceneActionExit
// SceneActionPass lets normal bot routing continue after the scene handler.
SceneActionPass
)
// SceneScope defines how scene sessions are keyed.
type SceneScope int
const (
// SceneScopeUser shares a scene across all chats for one user.
SceneScopeUser SceneScope = iota
// SceneScopeChat shares a scene across all users in one chat.
SceneScopeChat
// SceneScopeUserChat isolates a scene per user-chat pair.
SceneScopeUserChat
)
type sceneRuntime interface {
findScene(name string) (*sceneMeta, bool)
getSession(key string) (SceneSession, error)
setSession(key string, session SceneSession) error
deleteSession(key string) error
buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool)
findSceneSession(ctx *MsgContext) (string, SceneSession, error)
}
type sceneMeta struct {
Name string
Scope SceneScope
Entry string
Steps map[string]struct{}
}
+41
View File
@@ -0,0 +1,41 @@
package laniakea
// SceneContext wraps MsgContext with scene session state for scene handlers.
type SceneContext struct {
*MsgContext
sess SceneSession
key string
}
// Next advances the current scene to step.
func (ctx *SceneContext) Next(step string) SceneResult {
return SceneResult{
Action: SceneActionNext,
Next: step,
}
}
// Stay keeps the current scene step active.
func (ctx *SceneContext) Stay() SceneResult {
return SceneResult{Action: SceneActionStay}
}
// Exit leaves the current scene.
func (ctx *SceneContext) Exit() SceneResult {
return SceneResult{Action: SceneActionExit}
}
// Pass stops scene handling and lets normal routing continue.
func (ctx *SceneContext) Pass() SceneResult {
return SceneResult{Action: SceneActionPass}
}
// BindData unmarshals the current scene session payload into v.
func (ctx *SceneContext) BindData(v any) error {
return ctx.sess.BindData(v)
}
// SaveData marshals v and stores it in the current scene session payload.
func (ctx *SceneContext) SaveData(v any) error {
return ctx.sess.SaveData(v)
}
+147
View File
@@ -0,0 +1,147 @@
package laniakea
import (
"errors"
"fmt"
"strings"
)
func (bot *Bot[T]) tryHandleScene(ctx *MsgContext) (bool, error) {
key, session, err := bot.findSceneSession(ctx)
if err != nil {
if errors.Is(err, ErrCantFindSession) || errors.Is(err, ErrMessageNil) {
return false, nil
}
return false, err
}
if session.Scene == "" {
return false, nil
}
for _, plugin := range bot.plugins {
scene, ok := plugin.scenes[session.Scene]
if !ok {
continue
}
if scene.PluginName != "" && scene.PluginName != plugin.name {
continue
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return false, nil
}
sceneCtx := &SceneContext{
MsgContext: ctx,
sess: session,
key: key,
}
return bot.executeScene(scene, sceneCtx)
}
return false, ErrSceneNotFound
}
func (bot *Bot[T]) executeScene(scene *Scene[T], ctx *SceneContext) (bool, error) {
if ctx.MsgContext == nil || ctx.sess.Scene == "" {
return false, nil
}
var text string
if ctx.Msg != nil {
text = ctx.Msg.Text
if text == "" {
text = ctx.Msg.Caption
}
}
text = strings.TrimSpace(text)
prefix, cmd, args := bot.parseCommand(text)
if cmd != "" {
ctx.Prefix = prefix
ctx.Text = args
ctx.Args = strings.Fields(args)
res, matched, err := scene.executeCommand(cmd, ctx, bot.dbContext)
if err != nil {
return false, err
}
if matched {
return bot.applySceneResult(scene, ctx, res)
}
}
ctx.Text = text
ctx.Args = nil
ctx.Prefix = ""
if ctx.sess.Step != "" {
res, matched, err := scene.executeStep(ctx.sess.Step, ctx, bot.dbContext)
if err != nil {
return false, err
}
if matched {
return bot.applySceneResult(scene, ctx, res)
}
}
res, matched, err := scene.executeMessage(ctx, bot.dbContext)
if err != nil {
return false, err
}
if matched {
return bot.applySceneResult(scene, ctx, res)
}
return false, nil
}
func (bot *Bot[T]) applySceneResult(scene *Scene[T], ctx *SceneContext, result SceneResult) (bool, error) {
switch result.Action {
case SceneActionStay:
if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil {
return false, err
}
return true, nil
case SceneActionNext:
if result.Next == "" {
return false, ErrSceneStepNotFound
}
if _, ok := scene.steps[result.Next]; !ok {
return false, ErrSceneStepNotFound
}
ctx.sess.Step = result.Next
if err := bot.sessionStore.Set(ctx.key, ctx.sess); err != nil {
return false, err
}
return true, nil
case SceneActionExit:
if err := bot.sessionStore.Delete(ctx.key); err != nil {
return false, err
}
return true, nil
case SceneActionPass:
return false, nil
default:
return false, nil
}
}
func buildSceneKey(scope SceneScope, ctx *MsgContext) (string, bool) {
if ctx == nil {
return "", false
}
switch scope {
case SceneScopeUserChat:
if ctx.Msg == nil || ctx.Msg.Chat == nil || ctx.FromID == 0 {
return "", false
}
return fmt.Sprintf("user_id:%d:chat_id:%d", ctx.FromID, ctx.Msg.Chat.ID), true
case SceneScopeChat:
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return "", false
}
return fmt.Sprintf("chat_id:%d", ctx.Msg.Chat.ID), true
case SceneScopeUser:
if ctx.FromID == 0 {
return "", false
}
return fmt.Sprintf("user_id:%d", ctx.FromID), true
default:
return "", false
}
}
+468
View File
@@ -0,0 +1,468 @@
package laniakea
import (
"context"
"errors"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
type failingSessionStore struct {
getErr error
setErr error
deleteErr error
}
func (s failingSessionStore) Get(key string) (SceneSession, error) {
return SceneSession{}, s.getErr
}
func (s failingSessionStore) Set(key string, session SceneSession) error {
return s.setErr
}
func (s failingSessionStore) Delete(key string) error {
return s.deleteErr
}
func TestPluginAddSceneRegistersScene(t *testing.T) {
plugin := NewPlugin[NoDB]("wizard")
scene := NewScene[NoDB]("signup")
plugin.AddScene(scene)
if got, ok := plugin.scenes["signup"]; !ok || got != scene {
t.Fatalf("scene was not registered in plugin: ok=%v got=%p want=%p", ok, got, scene)
}
if scene.PluginName != "wizard" {
t.Fatalf("unexpected plugin name on scene: got %q want %q", scene.PluginName, "wizard")
}
}
func TestBotAddPluginsPreservesScenesAndHandlesThem(t *testing.T) {
called := false
plugin := NewPlugin[NoDB]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
called = true
if ctx.Text != "hello there" {
t.Fatalf("unexpected scene text: got %q want %q", ctx.Text, "hello there")
}
return ctx.Exit(), nil
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
sceneMeta, ok := bot.findScene("signup")
if !ok {
t.Fatal("expected scene metadata to be available after plugin registration")
}
if sceneMeta.Entry != "start" {
t.Fatalf("unexpected scene entry: got %q want %q", sceneMeta.Entry, "start")
}
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 1,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 7,
Text: "hello there",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !called {
t.Fatal("expected scene step handler to be called")
}
lookupCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
}
if _, session, err := bot.findSceneSession(lookupCtx); err == nil && session.Scene != "" {
t.Fatalf("expected scene session to be removed after exit, got %#v", session)
}
}
func TestBuildSceneKeyRejectsMissingContextFields(t *testing.T) {
tests := []struct {
name string
scope SceneScope
ctx *MsgContext
}{
{
name: "nil context",
scope: SceneScopeUserChat,
ctx: nil,
},
{
name: "missing message for chat scope",
scope: SceneScopeChat,
ctx: &MsgContext{},
},
{
name: "missing from id for user scope",
scope: SceneScopeUser,
ctx: &MsgContext{},
},
{
name: "missing from id for user chat scope",
scope: SceneScopeUserChat,
ctx: &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if key, ok := buildSceneKey(tt.scope, tt.ctx); ok || key != "" {
t.Fatalf("expected invalid scene key, got key=%q ok=%v", key, ok)
}
})
}
}
func TestEnterSceneRejectsMissingEntryConfiguration(t *testing.T) {
t.Run("empty entry", func(t *testing.T) {
plugin := NewPlugin[NoDB]("wizard")
plugin.NewScene("signup")
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
err := ctx.EnterScene("signup")
if !errors.Is(err, ErrSceneEntryNotSet) {
t.Fatalf("expected ErrSceneEntryNotSet, got %v", err)
}
})
t.Run("missing entry step", func(t *testing.T) {
plugin := NewPlugin[NoDB]("wizard")
plugin.NewScene("signup").SetEntry("start")
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
err := ctx.EnterScene("signup")
if !errors.Is(err, ErrSceneStepNotFound) {
t.Fatalf("expected ErrSceneStepNotFound, got %v", err)
}
})
}
func TestSceneContextMethodsRequireRuntime(t *testing.T) {
ctx := &MsgContext{}
if err := ctx.EnterScene("signup"); !errors.Is(err, ErrSceneRuntimeNil) {
t.Fatalf("expected ErrSceneRuntimeNil from EnterScene, got %v", err)
}
if err := ctx.EnterSceneStep("signup", "start"); !errors.Is(err, ErrSceneRuntimeNil) {
t.Fatalf("expected ErrSceneRuntimeNil from EnterSceneStep, got %v", err)
}
if err := ctx.ExitScene(); !errors.Is(err, ErrSceneRuntimeNil) {
t.Fatalf("expected ErrSceneRuntimeNil from ExitScene, got %v", err)
}
}
func TestSceneCommandHandlerRunsBeforeStep(t *testing.T) {
sceneCommandCalled := false
stepCalled := false
plugin := NewPlugin[NoDB]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
stepCalled = true
return ctx.Stay(), nil
}).
OnCommand("cancel", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
sceneCommandCalled = true
if ctx.Prefix != "/" {
t.Fatalf("unexpected prefix: got %q want /", ctx.Prefix)
}
if ctx.Text != "right now" {
t.Fatalf("unexpected scene command text: got %q want %q", ctx.Text, "right now")
}
if len(ctx.Args) != 2 || ctx.Args[0] != "right" || ctx.Args[1] != "now" {
t.Fatalf("unexpected scene command args: %#v", ctx.Args)
}
return ctx.Exit(), nil
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 2,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 8,
Text: "/cancel right now",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !sceneCommandCalled {
t.Fatal("expected scene command handler to be called")
}
if stepCalled {
t.Fatal("expected scene command to short-circuit the scene step")
}
}
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
commandCalled := false
plugin := NewPlugin[NoDB]("wizard")
plugin.NewCommand(func(ctx *MsgContext, db NoDB) error {
commandCalled = true
return nil
}, "ping")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
if err := ctx.SaveData(struct {
Value string `json:"value"`
}{Value: "changed"}); err != nil {
t.Fatalf("SaveData returned error: %v", err)
}
return ctx.Pass(), nil
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
})
if !ok {
t.Fatal("expected scene key to be built")
}
before, err := bot.sessionStore.Get(key)
if err != nil {
t.Fatalf("Get before handle returned error: %v", err)
}
if before.HasData() {
t.Fatalf("expected empty session data before handle, got %#v", before)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 3,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 9,
Text: "/ping",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !commandCalled {
t.Fatal("expected normal command routing to continue after SceneActionPass")
}
after, err := bot.sessionStore.Get(key)
if err != nil {
t.Fatalf("Get after handle returned error: %v", err)
}
if after.Scene != "signup" || after.Step != "start" {
t.Fatalf("unexpected session after pass: %#v", after)
}
if after.HasData() {
t.Fatalf("expected SceneActionPass to leave session data unchanged, got %#v", after)
}
}
func TestSceneMessageFallbackRunsWhenNoCommandOrStepMatch(t *testing.T) {
fallbackCalled := false
plugin := NewPlugin[NoDB]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnMessage(func(ctx *SceneContext, db NoDB) (SceneResult, error) {
fallbackCalled = true
if ctx.Text != "hello fallback" {
t.Fatalf("unexpected fallback text: got %q want %q", ctx.Text, "hello fallback")
}
return ctx.Exit(), nil
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
sceneRuntime: bot,
}
if err := enterCtx.EnterScene("signup"); err != nil {
t.Fatalf("EnterScene returned error: %v", err)
}
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)}},
FromID: 42,
})
if !ok {
t.Fatal("expected scene key to be built")
}
if err := bot.sessionStore.Set(key, SceneSession{Scene: "signup", Step: "unknown"}); err != nil {
t.Fatalf("Set returned error: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 4,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 10,
Text: "hello fallback",
Chat: &tgapi.Chat{ID: 100, Type: string(tgapi.ChatTypePrivate)},
From: &tgapi.User{ID: 42},
},
})
if !fallbackCalled {
t.Fatal("expected scene fallback handler to be called")
}
}
func TestFindSceneSessionSupportsUserScopeWithoutMessage(t *testing.T) {
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUser, SceneScopeChat, SceneScopeUserChat},
}
if err := bot.sessionStore.Set("user_id:42", SceneSession{Scene: "signup", Step: "start"}); err != nil {
t.Fatalf("Set returned error: %v", err)
}
key, session, err := bot.findSceneSession(&MsgContext{FromID: 42})
if err != nil {
t.Fatalf("findSceneSession returned error: %v", err)
}
if key != "user_id:42" {
t.Fatalf("unexpected session key: got %q want %q", key, "user_id:42")
}
if session.Scene != "signup" || session.Step != "start" {
t.Fatalf("unexpected session: %#v", session)
}
}
func TestSceneStoreErrorsPropagate(t *testing.T) {
getErr := errors.New("get failed")
setErr := errors.New("set failed")
t.Run("find scene session get error", func(t *testing.T) {
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
sessionStore: failingSessionStore{getErr: getErr},
sceneScopePriority: []SceneScope{SceneScopeUser},
}
_, _, err := bot.findSceneSession(&MsgContext{FromID: 42})
if !errors.Is(err, getErr) {
t.Fatalf("expected getErr, got %v", err)
}
})
t.Run("apply scene result set error", func(t *testing.T) {
scene := NewScene[NoDB]("signup").OnStep("start", func(ctx *SceneContext, db NoDB) (SceneResult, error) {
return ctx.Stay(), nil
})
bot := &Bot[NoDB]{
logger: slog.CreateLogger(),
sessionStore: failingSessionStore{setErr: setErr},
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
_, err := bot.applySceneResult(scene, &SceneContext{
MsgContext: &MsgContext{},
sess: SceneSession{Scene: "signup", Step: "start"},
key: "user_id:42:chat_id:100",
}, SceneResult{Action: SceneActionStay})
if !errors.Is(err, setErr) {
t.Fatalf("expected setErr, got %v", err)
}
})
}
+44
View File
@@ -0,0 +1,44 @@
package laniakea
// SplitMessageText splits plain text into Telegram-safe message chunks.
//
// The function preserves the original text exactly: concatenating all returned
// chunks reconstructs text byte-for-byte. It prefers splitting at newlines or
// spaces within the Telegram message limit and falls back to hard rune-based
// splits when no separator is available.
func SplitMessageText(text string) []string {
return splitTextByLimit(text, maxMessageTextLen)
}
func splitTextByLimit(text string, limit int) []string {
if text == "" {
return nil
}
runes := []rune(text)
chunks := make([]string, 0, len(runes)/limit+1)
for start := 0; start < len(runes); {
end := start + limit
if end >= len(runes) {
chunks = append(chunks, string(runes[start:]))
break
}
splitAt := -1
for i := end - 1; i > start; i-- {
if runes[i] == '\n' || runes[i] == ' ' {
splitAt = i + 1
break
}
}
if splitAt == -1 {
splitAt = end
}
chunks = append(chunks, string(runes[start:splitAt]))
start = splitAt
}
return chunks
}
+18 -26
View File
@@ -9,8 +9,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// APIOpts holds configuration options for initializing the Telegram API client. // APIOpts holds configuration options for initializing the Telegram API client.
@@ -95,10 +95,9 @@ type API struct {
} }
// NewAPI creates a new API client from options. // NewAPI creates a new API client from options.
// Always call CloseApi() when done to release resources. // Always call Close() when done to release resources.
func NewAPI(opts *APIOpts) *API { func NewAPI(opts *APIOpts) *API {
l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API") l := utils.CreateLogger("API", utils.GetLoggerLevel())
l.AddWriter(l.CreateJsonStdoutWriter())
client := opts.client client := opts.client
if client == nil { if client == nil {
@@ -120,11 +119,14 @@ func NewAPI(opts *APIOpts) *API {
} }
} }
// CloseApi shuts down the internal worker pool and closes the logger. // Close shuts down the internal worker pool and closes the logger.
// Must be called to avoid resource leaks. // Must be called to avoid resource leaks.
// See https://core.telegram.org/bots/api // See https://core.telegram.org/bots/api
func (api *API) CloseApi() error { func (api *API) Close() error {
api.pool.stop() api.pool.stop()
if api.client != nil {
api.client.CloseIdleConnections()
}
return api.logger.Close() return api.logger.Close()
} }
@@ -150,37 +152,29 @@ type ApiResponse[R any] struct {
Parameters *ResponseParameters `json:"parameters,omitempty"` Parameters *ResponseParameters `json:"parameters,omitempty"`
} }
// TelegramRequest is an internal helper struct. // TelegramRequest is a low-level Telegram API request wrapper.
// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged.
// Instead, use explicit methods like SendMessage, GetUpdates, etc.
// //
// Why? Because using generics with arbitrary types P and R leads to: // Prefer method-specific helpers such as SendMessage or GetUpdates. TelegramRequest
// - No compile-time validation of parameters // bypasses method-specific parameter types and convenience helpers, so callers are
// - No IDE autocompletion // responsible for using the correct method name and compatible request and response types.
// - Runtime panics on malformed JSON // In that sense it is an unsafe escape hatch compared with the typed API surface.
// - Hard-to-debug errors
//
// Recommended: Define specific methods for each Telegram method (see below).
type TelegramRequest[R, P any] struct { type TelegramRequest[R, P any] struct {
method string method string
params P params P
chatId int64 chatId int64
} }
// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID. // NewRequest creates a low-level TelegramRequest with no associated chat ID.
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, 0} return TelegramRequest[R, P]{method, params, 0}
} }
// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID. // NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID.
// The chat ID is used for per-chat rate limiting. // The chat ID is used for per-chat rate limiting.
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, chatId} return TelegramRequest[R, P]{method, params, chatId}
} }
// doRequest performs a single HTTP request to Telegram API.
// Handles rate limiting, retries on 429, and parses responses.
// Must be called within a worker pool context if using DoWithContext.
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
var zero R var zero R
reqData, err := json.Marshal(r.params) reqData, err := json.Marshal(r.params)
@@ -297,15 +291,13 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
return r.DoWithContext(context.Background(), api) return r.DoWithContext(context.Background(), api)
} }
// readBody reads and limits response body to prevent memory exhaustion. // Internal helper that reads and caps a Telegram response body.
// Telegram responses are typically small (<1MB), but we cap at 10MB.
func readBody(body io.ReadCloser) ([]byte, error) { func readBody(body io.ReadCloser) ([]byte, error) {
reader := io.LimitReader(body, 10<<20) // 10 MB reader := io.LimitReader(body, 10<<20) // 10 MB
return io.ReadAll(reader) return io.ReadAll(reader)
} }
// parseBody unmarshals a Telegram API response into a typed ApiResponse. // Internal helper that parses a typed Telegram API response body.
// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle.
func parseBody[R any](data []byte) (ApiResponse[R], error) { func parseBody[R any](data []byte) (ApiResponse[R], error) {
var resp ApiResponse[R] var resp ApiResponse[R]
err := json.Unmarshal(data, &resp) err := json.Unmarshal(data, &resp)
+36 -2
View File
@@ -13,6 +13,15 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req) return fn(req)
} }
type closingTransport struct {
roundTripFunc
closed bool
}
func (t *closingTransport) CloseIdleConnections() {
t.closed = true
}
func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
var gotPath string var gotPath string
var gotAcceptEncoding string var gotAcceptEncoding string
@@ -35,8 +44,8 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -54,3 +63,28 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding) t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
} }
} }
func TestAPICloseClosesIdleConnections(t *testing.T) {
transport := &closingTransport{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)),
}, nil
},
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(&http.Client{Transport: transport}),
)
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
if !transport.closed {
t.Fatal("expected Close to close idle HTTP connections")
}
}
+90 -10
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// SendPhotoP holds parameters for the sendPhoto method. // SendPhotoP holds parameters for the sendPhoto method.
// See https://core.telegram.org/bots/api#sendphoto // See https://core.telegram.org/bots/api#sendphoto
type SendPhotoP struct { type SendPhotoP struct {
@@ -32,6 +34,14 @@ func (api *API) SendPhoto(params SendPhotoP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendphoto
func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhotoP) (Message, error) {
req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAudioP holds parameters for the sendAudio method. // SendAudioP holds parameters for the sendAudio method.
// See https://core.telegram.org/bots/api#sendaudio // See https://core.telegram.org/bots/api#sendaudio
type SendAudioP struct { type SendAudioP struct {
@@ -47,6 +57,7 @@ type SendAudioP struct {
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Performer string `json:"performer,omitempty"` Performer string `json:"performer,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
@@ -65,6 +76,14 @@ func (api *API) SendAudio(params SendAudioP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendaudio
func (api *API) SendAudioWithContext(ctx context.Context, params SendAudioP) (Message, error) {
req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDocumentP holds parameters for the sendDocument method. // SendDocumentP holds parameters for the sendDocument method.
// See https://core.telegram.org/bots/api#senddocument // See https://core.telegram.org/bots/api#senddocument
type SendDocumentP struct { type SendDocumentP struct {
@@ -73,10 +92,12 @@ type SendDocumentP struct {
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Document string `json:"document"` Document string `json:"document"`
Caption string `json:"caption,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"` Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
DisableContentTypeDetection bool `json:"disable_content_type_detection,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
@@ -95,6 +116,14 @@ func (api *API) SendDocument(params SendDocumentP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#senddocument
func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocumentP) (Message, error) {
req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideoP holds parameters for the sendVideo method. // SendVideoP holds parameters for the sendVideo method.
// See https://core.telegram.org/bots/api#sendvideo // See https://core.telegram.org/bots/api#sendvideo
type SendVideoP struct { type SendVideoP struct {
@@ -103,11 +132,12 @@ type SendVideoP struct {
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Video string `json:"video"` Video string `json:"video"`
Duration int `json:"duration,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
Width int `json:"width,omitempty"` Duration int `json:"duration,omitempty"`
Height int `json:"height,omitempty"` Width int `json:"width,omitempty"`
Cover string `json:"cover,omitempty"` Height int `json:"height,omitempty"`
Cover string `json:"cover,omitempty"`
StartTimestamp int `json:"start_timestamp,omitempty"` StartTimestamp int `json:"start_timestamp,omitempty"`
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
@@ -134,6 +164,14 @@ func (api *API) SendVideo(params SendVideoP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideo
func (api *API) SendVideoWithContext(ctx context.Context, params SendVideoP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAnimationP holds parameters for the sendAnimation method. // SendAnimationP holds parameters for the sendAnimation method.
// See https://core.telegram.org/bots/api#sendanimation // See https://core.telegram.org/bots/api#sendanimation
type SendAnimationP struct { type SendAnimationP struct {
@@ -143,6 +181,7 @@ type SendAnimationP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Animation string `json:"animation"` Animation string `json:"animation"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Width int `json:"width,omitempty"` Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"` Height int `json:"height,omitempty"`
@@ -169,6 +208,14 @@ func (api *API) SendAnimation(params SendAnimationP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendanimation
func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimationP) (Message, error) {
req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVoiceP holds parameters for the sendVoice method. // SendVoiceP holds parameters for the sendVoice method.
// See https://core.telegram.org/bots/api#sendvoice // See https://core.telegram.org/bots/api#sendvoice
type SendVoiceP struct { type SendVoiceP struct {
@@ -194,11 +241,19 @@ type SendVoiceP struct {
// SendVoice sends a voice note. // SendVoice sends a voice note.
// See https://core.telegram.org/bots/api#sendvoice // See https://core.telegram.org/bots/api#sendvoice
func (api *API) SendVoice(params *SendVoiceP) (Message, error) { func (api *API) SendVoice(params SendVoiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvoice
func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideoNoteP holds parameters for the sendVideoNote method. // SendVideoNoteP holds parameters for the sendVideoNote method.
// See https://core.telegram.org/bots/api#sendvideonote // See https://core.telegram.org/bots/api#sendvideonote
type SendVideoNoteP struct { type SendVideoNoteP struct {
@@ -208,6 +263,7 @@ type SendVideoNoteP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
VideoNote string `json:"video_note"` VideoNote string `json:"video_note"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Length int `json:"length,omitempty"` Length int `json:"length,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"`
@@ -227,6 +283,14 @@ func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideonote
func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNoteP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPaidMediaP holds parameters for the sendPaidMedia method. // SendPaidMediaP holds parameters for the sendPaidMedia method.
// See https://core.telegram.org/bots/api#sendpaidmedia // See https://core.telegram.org/bots/api#sendpaidmedia
type SendPaidMediaP struct { type SendPaidMediaP struct {
@@ -258,6 +322,14 @@ func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendPaidMediaWithContext is the context-aware variant of SendPaidMedia.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendpaidmedia
func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMediaP) (Message, error) {
req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMediaGroupP holds parameters for the sendMediaGroup method. // SendMediaGroupP holds parameters for the sendMediaGroup method.
// See https://core.telegram.org/bots/api#sendmediagroup // See https://core.telegram.org/bots/api#sendmediagroup
type SendMediaGroupP struct { type SendMediaGroupP struct {
@@ -280,3 +352,11 @@ func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) {
req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID) req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendMediaGroupWithContext is the context-aware variant of SendMediaGroup.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendmediagroup
func (api *API) SendMediaGroupWithContext(ctx context.Context, params SendMediaGroupP) ([]Message, error) {
req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+6 -6
View File
@@ -55,12 +55,12 @@ type InputPaidMedia struct {
Type InputPaidMediaType `json:"type"` Type InputPaidMediaType `json:"type"`
Media string `json:"media"` Media string `json:"media"`
Cover string `json:"cover"` Cover *string `json:"cover,omitempty"`
StartTimestamp int64 `json:"start_timestamp"` StartTimestamp *int64 `json:"start_timestamp,omitempty"`
Width int `json:"width"` Width *int `json:"width,omitempty"`
Height int `json:"height"` Height *int `json:"height,omitempty"`
Duration int `json:"duration"` Duration *int `json:"duration,omitempty"`
SupportsStreaming bool `json:"supports_streaming"` SupportsStreaming *bool `json:"supports_streaming,omitempty"`
} }
// PhotoSize represents one size of a photo or a file/sticker thumbnail. // PhotoSize represents one size of a photo or a file/sticker thumbnail.
+150 -4
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// SetMyCommandsP holds parameters for the setMyCommands method. // SetMyCommandsP holds parameters for the setMyCommands method.
// See https://core.telegram.org/bots/api#setmycommands // See https://core.telegram.org/bots/api#setmycommands
type SetMyCommandsP struct { type SetMyCommandsP struct {
@@ -16,6 +18,14 @@ func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetMyCommandsWithContext is the context-aware variant of SetMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmycommands
func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyCommandsP) (bool, error) {
req := NewRequest[bool]("setMyCommands", params)
return req.DoWithContext(ctx, api)
}
// DeleteMyCommandsP holds parameters for the deleteMyCommands method. // DeleteMyCommandsP holds parameters for the deleteMyCommands method.
// See https://core.telegram.org/bots/api#deletemycommands // See https://core.telegram.org/bots/api#deletemycommands
type DeleteMyCommandsP struct { type DeleteMyCommandsP struct {
@@ -31,6 +41,14 @@ func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// DeleteMyCommandsWithContext is the context-aware variant of DeleteMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletemycommands
func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommandsP) (bool, error) {
req := NewRequest[bool]("deleteMyCommands", params)
return req.DoWithContext(ctx, api)
}
// GetMyCommands holds parameters for the getMyCommands method. // GetMyCommands holds parameters for the getMyCommands method.
// See https://core.telegram.org/bots/api#getmycommands // See https://core.telegram.org/bots/api#getmycommands
type GetMyCommands struct { type GetMyCommands struct {
@@ -45,6 +63,14 @@ func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) {
return req.Do(api) return req.Do(api)
} }
// GetMyCommandsWithContext is the context-aware variant of GetMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmycommands
func (api *API) GetMyCommandsWithContext(ctx context.Context, params GetMyCommands) ([]BotCommand, error) {
req := NewRequest[[]BotCommand]("getMyCommands", params)
return req.DoWithContext(ctx, api)
}
// SetMyName holds parameters for the setMyName method. // SetMyName holds parameters for the setMyName method.
// See https://core.telegram.org/bots/api#setmyname // See https://core.telegram.org/bots/api#setmyname
type SetMyName struct { type SetMyName struct {
@@ -60,6 +86,14 @@ func (api *API) SetMyName(params SetMyName) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetMyNameWithContext is the context-aware variant of SetMyName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyname
func (api *API) SetMyNameWithContext(ctx context.Context, params SetMyName) (bool, error) {
req := NewRequest[bool]("setMyName", params)
return req.DoWithContext(ctx, api)
}
// GetMyName holds parameters for the getMyName method. // GetMyName holds parameters for the getMyName method.
// See https://core.telegram.org/bots/api#getmyname // See https://core.telegram.org/bots/api#getmyname
type GetMyName struct { type GetMyName struct {
@@ -73,6 +107,14 @@ func (api *API) GetMyName(params GetMyName) (BotName, error) {
return req.Do(api) return req.Do(api)
} }
// GetMyNameWithContext is the context-aware variant of GetMyName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmyname
func (api *API) GetMyNameWithContext(ctx context.Context, params GetMyName) (BotName, error) {
req := NewRequest[BotName]("getMyName", params)
return req.DoWithContext(ctx, api)
}
// SetMyDescription holds parameters for the setMyDescription method. // SetMyDescription holds parameters for the setMyDescription method.
// See https://core.telegram.org/bots/api#setmydescription // See https://core.telegram.org/bots/api#setmydescription
type SetMyDescription struct { type SetMyDescription struct {
@@ -88,6 +130,14 @@ func (api *API) SetMyDescription(params SetMyDescription) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetMyDescriptionWithContext is the context-aware variant of SetMyDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmydescription
func (api *API) SetMyDescriptionWithContext(ctx context.Context, params SetMyDescription) (bool, error) {
req := NewRequest[bool]("setMyDescription", params)
return req.DoWithContext(ctx, api)
}
// GetMyDescription holds parameters for the getMyDescription method. // GetMyDescription holds parameters for the getMyDescription method.
// See https://core.telegram.org/bots/api#getmydescription // See https://core.telegram.org/bots/api#getmydescription
type GetMyDescription struct { type GetMyDescription struct {
@@ -101,6 +151,14 @@ func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error
return req.Do(api) return req.Do(api)
} }
// GetMyDescriptionWithContext is the context-aware variant of GetMyDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmydescription
func (api *API) GetMyDescriptionWithContext(ctx context.Context, params GetMyDescription) (BotDescription, error) {
req := NewRequest[BotDescription]("getMyDescription", params)
return req.DoWithContext(ctx, api)
}
// SetMyShortDescription holds parameters for the setMyShortDescription method. // SetMyShortDescription holds parameters for the setMyShortDescription method.
// See https://core.telegram.org/bots/api#setmyshortdescription // See https://core.telegram.org/bots/api#setmyshortdescription
type SetMyShortDescription struct { type SetMyShortDescription struct {
@@ -116,6 +174,14 @@ func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error
return req.Do(api) return req.Do(api)
} }
// SetMyShortDescriptionWithContext is the context-aware variant of SetMyShortDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyshortdescription
func (api *API) SetMyShortDescriptionWithContext(ctx context.Context, params SetMyShortDescription) (bool, error) {
req := NewRequest[bool]("setMyShortDescription", params)
return req.DoWithContext(ctx, api)
}
// GetMyShortDescription holds parameters for the getMyShortDescription method. // GetMyShortDescription holds parameters for the getMyShortDescription method.
// See https://core.telegram.org/bots/api#getmyshortdescription // See https://core.telegram.org/bots/api#getmyshortdescription
type GetMyShortDescription struct { type GetMyShortDescription struct {
@@ -129,6 +195,14 @@ func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDes
return req.Do(api) return req.Do(api)
} }
// GetMyShortDescriptionWithContext is the context-aware variant of GetMyShortDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmyshortdescription
func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params GetMyShortDescription) (BotShortDescription, error) {
req := NewRequest[BotShortDescription]("getMyShortDescription", params)
return req.DoWithContext(ctx, api)
}
// SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method. // SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method.
// See https://core.telegram.org/bots/api#setmyprofilephoto // See https://core.telegram.org/bots/api#setmyprofilephoto
type SetMyProfilePhotoP struct { type SetMyProfilePhotoP struct {
@@ -143,6 +217,14 @@ func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetMyProfilePhotoWithContext is the context-aware variant of SetMyProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyprofilephoto
func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhotoP) (bool, error) {
req := NewRequest[bool]("setMyProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// RemoveMyProfilePhoto removes the bot's profile photo. // RemoveMyProfilePhoto removes the bot's profile photo.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#removemyprofilephoto // See https://core.telegram.org/bots/api#removemyprofilephoto
@@ -151,10 +233,18 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) {
return req.Do(api) return req.Do(api)
} }
// RemoveMyProfilePhotoWithContext is the context-aware variant of RemoveMyProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removemyprofilephoto
func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool]("removeMyProfilePhoto", NoParams)
return req.DoWithContext(ctx, api)
}
// SetChatMenuButtonP holds parameters for the setChatMenuButton method. // SetChatMenuButtonP holds parameters for the setChatMenuButton method.
// See https://core.telegram.org/bots/api#setchatmenubutton // See https://core.telegram.org/bots/api#setchatmenubutton
type SetChatMenuButtonP struct { type SetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id,omitempty"`
MenuButton MenuButtonType `json:"menu_button"` MenuButton MenuButtonType `json:"menu_button"`
} }
@@ -166,19 +256,35 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatMenuButtonWithContext is the context-aware variant of SetChatMenuButton.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatmenubutton
func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChatMenuButtonP) (bool, error) {
req := NewRequest[bool]("setChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// GetChatMenuButtonP holds parameters for the getChatMenuButton method. // GetChatMenuButtonP holds parameters for the getChatMenuButton method.
// See https://core.telegram.org/bots/api#getchatmenubutton // See https://core.telegram.org/bots/api#getchatmenubutton
type GetChatMenuButtonP struct { type GetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id,omitempty"`
} }
// GetChatMenuButton returns the current menu button for the given chat. // GetChatMenuButton returns the current menu button for the given chat.
// See https://core.telegram.org/bots/api#getchatmenubutton // See https://core.telegram.org/bots/api#getchatmenubutton
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) { func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) {
req := NewRequest[BaseMenuButton]("getChatMenuButton", params) req := NewRequest[MenuButton]("getChatMenuButton", params)
return req.Do(api) return req.Do(api)
} }
// GetChatMenuButtonWithContext is the context-aware variant of GetChatMenuButton.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatmenubutton
func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButtonP) (MenuButton, error) {
req := NewRequest[MenuButton]("getChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method. // SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights // See https://core.telegram.org/bots/api#setmydefaultadministratorrights
type SetMyDefaultAdministratorRightsP struct { type SetMyDefaultAdministratorRightsP struct {
@@ -194,6 +300,14 @@ func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministrator
return req.Do(api) return req.Do(api)
} }
// SetMyDefaultAdministratorRightsWithContext is the context-aware variant of SetMyDefaultAdministratorRights.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights
func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, params SetMyDefaultAdministratorRightsP) (bool, error) {
req := NewRequest[bool]("setMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
// GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method. // GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights // See https://core.telegram.org/bots/api#getmydefaultadministratorrights
type GetMyDefaultAdministratorRightsP struct { type GetMyDefaultAdministratorRightsP struct {
@@ -207,6 +321,14 @@ func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministrator
return req.Do(api) return req.Do(api)
} }
// GetMyDefaultAdministratorRightsWithContext is the context-aware variant of GetMyDefaultAdministratorRights.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights
func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) {
req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
// GetAvailableGifts returns the list of gifts that can be sent by the bot. // GetAvailableGifts returns the list of gifts that can be sent by the bot.
// See https://core.telegram.org/bots/api#getavailablegifts // See https://core.telegram.org/bots/api#getavailablegifts
func (api *API) GetAvailableGifts() (Gifts, error) { func (api *API) GetAvailableGifts() (Gifts, error) {
@@ -214,6 +336,14 @@ func (api *API) GetAvailableGifts() (Gifts, error) {
return req.Do(api) return req.Do(api)
} }
// GetAvailableGiftsWithContext is the context-aware variant of GetAvailableGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getavailablegifts
func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error) {
req := NewRequest[Gifts]("getAvailableGifts", NoParams)
return req.DoWithContext(ctx, api)
}
// SendGiftP holds parameters for the sendGift method. // SendGiftP holds parameters for the sendGift method.
// See https://core.telegram.org/bots/api#sendgift // See https://core.telegram.org/bots/api#sendgift
type SendGiftP struct { type SendGiftP struct {
@@ -234,6 +364,14 @@ func (api *API) SendGift(params SendGiftP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SendGiftWithContext is the context-aware variant of SendGift.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendgift
func (api *API) SendGiftWithContext(ctx context.Context, params SendGiftP) (bool, error) {
req := NewRequest[bool]("sendGift", params)
return req.DoWithContext(ctx, api)
}
// GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method. // GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method.
// See https://core.telegram.org/bots/api#giftpremiumsubscription // See https://core.telegram.org/bots/api#giftpremiumsubscription
type GiftPremiumSubscriptionP struct { type GiftPremiumSubscriptionP struct {
@@ -252,3 +390,11 @@ func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool,
req := NewRequest[bool]("giftPremiumSubscription", params) req := NewRequest[bool]("giftPremiumSubscription", params)
return req.Do(api) return req.Do(api)
} }
// GiftPremiumSubscriptionWithContext is the context-aware variant of GiftPremiumSubscription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#giftpremiumsubscription
func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params GiftPremiumSubscriptionP) (bool, error) {
req := NewRequest[bool]("giftPremiumSubscription", params)
return req.DoWithContext(ctx, api)
}
+12 -7
View File
@@ -54,7 +54,9 @@ type BotShortDescription struct {
type InputProfilePhotoType string type InputProfilePhotoType string
const ( const (
InputProfilePhotoStaticType InputProfilePhotoType = "static" // InputProfilePhotoStaticType identifies a static profile photo input.
InputProfilePhotoStaticType InputProfilePhotoType = "static"
// InputProfilePhotoAnimatedType identifies an animated profile photo input.
InputProfilePhotoAnimatedType InputProfilePhotoType = "animated" InputProfilePhotoAnimatedType InputProfilePhotoType = "animated"
) )
@@ -75,17 +77,20 @@ type InputProfilePhoto struct {
type MenuButtonType string type MenuButtonType string
const ( const (
// MenuButtonCommandsType identifies a commands menu button.
MenuButtonCommandsType MenuButtonType = "commands" MenuButtonCommandsType MenuButtonType = "commands"
MenuButtonWebAppType MenuButtonType = "web_app" // MenuButtonWebAppType identifies a web app menu button.
MenuButtonDefaultType MenuButtonType = "default" MenuButtonWebAppType MenuButtonType = "web_app"
// MenuButtonDefaultType identifies Telegram's default menu button.
MenuButtonDefaultType MenuButtonType = "default"
) )
// BaseMenuButton represents a menu button. // MenuButton represents a menu button.
// See https://core.telegram.org/bots/api#menubutton // See https://core.telegram.org/bots/api#menubutton
type BaseMenuButton struct { type MenuButton struct {
Type MenuButtonType `json:"type"` Type MenuButtonType `json:"type"`
// WebApp fields (for web_app button) // WebApp fields (for web_app button)
Text string `json:"text"` Text *string `json:"text"`
WebApp WebAppInfo `json:"web_app"` WebApp *WebAppInfo `json:"web_app"`
} }
+194
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// VerifyUserP holds parameters for the verifyUser method. // VerifyUserP holds parameters for the verifyUser method.
// See https://core.telegram.org/bots/api#verifyuser // See https://core.telegram.org/bots/api#verifyuser
type VerifyUserP struct { type VerifyUserP struct {
@@ -15,6 +17,14 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// VerifyUserWithContext is the context-aware variant of VerifyUser.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#verifyuser
func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUserP) (bool, error) {
req := NewRequest[bool]("verifyUser", params)
return req.DoWithContext(ctx, api)
}
// VerifyChatP holds parameters for the verifyChat method. // VerifyChatP holds parameters for the verifyChat method.
// See https://core.telegram.org/bots/api#verifychat // See https://core.telegram.org/bots/api#verifychat
type VerifyChatP struct { type VerifyChatP struct {
@@ -30,6 +40,14 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// VerifyChatWithContext is the context-aware variant of VerifyChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#verifychat
func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChatP) (bool, error) {
req := NewRequest[bool]("verifyChat", params)
return req.DoWithContext(ctx, api)
}
// RemoveUserVerificationP holds parameters for the removeUserVerification method. // RemoveUserVerificationP holds parameters for the removeUserVerification method.
// See https://core.telegram.org/bots/api#removeuserverification // See https://core.telegram.org/bots/api#removeuserverification
type RemoveUserVerificationP struct { type RemoveUserVerificationP struct {
@@ -44,6 +62,14 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er
return req.Do(api) return req.Do(api)
} }
// RemoveUserVerificationWithContext is the context-aware variant of RemoveUserVerification.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removeuserverification
func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params RemoveUserVerificationP) (bool, error) {
req := NewRequest[bool]("removeUserVerification", params)
return req.DoWithContext(ctx, api)
}
// RemoveChatVerificationP holds parameters for the removeChatVerification method. // RemoveChatVerificationP holds parameters for the removeChatVerification method.
// See https://core.telegram.org/bots/api#removechatverification // See https://core.telegram.org/bots/api#removechatverification
type RemoveChatVerificationP struct { type RemoveChatVerificationP struct {
@@ -58,6 +84,14 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er
return req.Do(api) return req.Do(api)
} }
// RemoveChatVerificationWithContext is the context-aware variant of RemoveChatVerification.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removechatverification
func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params RemoveChatVerificationP) (bool, error) {
req := NewRequest[bool]("removeChatVerification", params)
return req.DoWithContext(ctx, api)
}
// ReadBusinessMessageP holds parameters for the readBusinessMessage method. // ReadBusinessMessageP holds parameters for the readBusinessMessage method.
// See https://core.telegram.org/bots/api#readbusinessmessage // See https://core.telegram.org/bots/api#readbusinessmessage
type ReadBusinessMessageP struct { type ReadBusinessMessageP struct {
@@ -74,6 +108,14 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// ReadBusinessMessageWithContext is the context-aware variant of ReadBusinessMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#readbusinessmessage
func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadBusinessMessageP) (bool, error) {
req := NewRequest[bool]("readBusinessMessage", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessConnectionP holds parameters for the getBusinessConnection method. // GetBusinessConnectionP holds parameters for the getBusinessConnection method.
// See https://core.telegram.org/bots/api#getbusinessconnection // See https://core.telegram.org/bots/api#getbusinessconnection
type GetBusinessConnectionP struct { type GetBusinessConnectionP struct {
@@ -87,6 +129,14 @@ func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessCo
return req.Do(api) return req.Do(api)
} }
// GetBusinessConnectionWithContext is the context-aware variant of GetBusinessConnection.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getbusinessconnection
func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params GetBusinessConnectionP) (BusinessConnection, error) {
req := NewRequest[BusinessConnection]("getBusinessConnection", params)
return req.DoWithContext(ctx, api)
}
// DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method. // DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method.
// See https://core.telegram.org/bots/api#deletebusinessmessages // See https://core.telegram.org/bots/api#deletebusinessmessages
type DeleteBusinessMessagesP struct { type DeleteBusinessMessagesP struct {
@@ -102,6 +152,14 @@ func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, er
return req.Do(api) return req.Do(api)
} }
// DeleteBusinessMessagesWithContext is the context-aware variant of DeleteBusinessMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletebusinessmessages
func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params DeleteBusinessMessagesP) (bool, error) {
req := NewRequest[bool]("deleteBusinessMessages", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountNameP holds parameters for the setBusinessAccountName method. // SetBusinessAccountNameP holds parameters for the setBusinessAccountName method.
// See https://core.telegram.org/bots/api#setbusinessaccountname // See https://core.telegram.org/bots/api#setbusinessaccountname
type SetBusinessAccountNameP struct { type SetBusinessAccountNameP struct {
@@ -118,6 +176,14 @@ func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, er
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountNameWithContext is the context-aware variant of SetBusinessAccountName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountname
func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params SetBusinessAccountNameP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountName", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method. // SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method.
// See https://core.telegram.org/bots/api#setbusinessaccountusername // See https://core.telegram.org/bots/api#setbusinessaccountusername
type SetBusinessAccountUsernameP struct { type SetBusinessAccountUsernameP struct {
@@ -133,6 +199,14 @@ func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountUsernameWithContext is the context-aware variant of SetBusinessAccountUsername.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountusername
func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, params SetBusinessAccountUsernameP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountUsername", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method. // SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method.
// See https://core.telegram.org/bots/api#setbusinessaccountbio // See https://core.telegram.org/bots/api#setbusinessaccountbio
type SetBusinessAccountBioP struct { type SetBusinessAccountBioP struct {
@@ -148,6 +222,14 @@ func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, erro
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountBioWithContext is the context-aware variant of SetBusinessAccountBio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountbio
func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBioP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountBio", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method. // SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method.
// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto // See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto
type SetBusinessAccountProfilePhoto struct { type SetBusinessAccountProfilePhoto struct {
@@ -164,6 +246,14 @@ func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfileP
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountProfilePhotoWithContext is the context-aware variant of SetBusinessAccountProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto
func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, params SetBusinessAccountProfilePhoto) (bool, error) {
req := NewRequest[bool]("setBusinessAccountProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method. // RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
type RemoveBusinessAccountProfilePhotoP struct { type RemoveBusinessAccountProfilePhotoP struct {
@@ -179,6 +269,14 @@ func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountPr
return req.Do(api) return req.Do(api)
} }
// RemoveBusinessAccountProfilePhotoWithContext is the context-aware variant of RemoveBusinessAccountProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context, params RemoveBusinessAccountProfilePhotoP) (bool, error) {
req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method. // SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
type SetBusinessAccountGiftSettingsP struct { type SetBusinessAccountGiftSettingsP struct {
@@ -195,6 +293,14 @@ func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSett
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountGiftSettingsWithContext is the context-aware variant of SetBusinessAccountGiftSettings.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, params SetBusinessAccountGiftSettingsP) (bool, error) {
req := NewRequest[bool]("setBusinessAccountGiftSettings", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method. // GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
type GetBusinessAccountStarBalanceP struct { type GetBusinessAccountStarBalanceP struct {
@@ -208,6 +314,14 @@ func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalan
return req.Do(api) return req.Do(api)
} }
// GetBusinessAccountStarBalanceWithContext is the context-aware variant of GetBusinessAccountStarBalance.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, params GetBusinessAccountStarBalanceP) (StarAmount, error) {
req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params)
return req.DoWithContext(ctx, api)
}
// TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method. // TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars // See https://core.telegram.org/bots/api#transferbusinessaccountstars
type TransferBusinessAccountStarsP struct { type TransferBusinessAccountStarsP struct {
@@ -223,6 +337,14 @@ func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars
return req.Do(api) return req.Do(api)
} }
// TransferBusinessAccountStarsWithContext is the context-aware variant of TransferBusinessAccountStars.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars
func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, params TransferBusinessAccountStarsP) (bool, error) {
req := NewRequest[bool]("transferBusinessAccountStars", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method. // GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts // See https://core.telegram.org/bots/api#getbusinessaccountgifts
type GetBusinessAccountGiftsP struct { type GetBusinessAccountGiftsP struct {
@@ -246,6 +368,14 @@ func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedG
return req.Do(api) return req.Do(api)
} }
// GetBusinessAccountGiftsWithContext is the context-aware variant of GetBusinessAccountGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts
func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params GetBusinessAccountGiftsP) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params)
return req.DoWithContext(ctx, api)
}
// ConvertGiftToStarsP holds parameters for the convertGiftToStars method. // ConvertGiftToStarsP holds parameters for the convertGiftToStars method.
// See https://core.telegram.org/bots/api#convertgifttostars // See https://core.telegram.org/bots/api#convertgifttostars
type ConvertGiftToStarsP struct { type ConvertGiftToStarsP struct {
@@ -261,6 +391,14 @@ func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// ConvertGiftToStarsWithContext is the context-aware variant of ConvertGiftToStars.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#convertgifttostars
func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params ConvertGiftToStarsP) (bool, error) {
req := NewRequest[bool]("convertGiftToStars", params)
return req.DoWithContext(ctx, api)
}
// UpgradeGiftP holds parameters for the upgradeGift method. // UpgradeGiftP holds parameters for the upgradeGift method.
// See https://core.telegram.org/bots/api#upgradegift // See https://core.telegram.org/bots/api#upgradegift
type UpgradeGiftP struct { type UpgradeGiftP struct {
@@ -278,6 +416,14 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// UpgradeGiftWithContext is the context-aware variant of UpgradeGift.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#upgradegift
func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGiftP) (bool, error) {
req := NewRequest[bool]("upgradeGift", params)
return req.DoWithContext(ctx, api)
}
// TransferGiftP holds parameters for the transferGift method. // TransferGiftP holds parameters for the transferGift method.
// See https://core.telegram.org/bots/api#transfergift // See https://core.telegram.org/bots/api#transfergift
type TransferGiftP struct { type TransferGiftP struct {
@@ -295,6 +441,14 @@ func (api *API) TransferGift(params TransferGiftP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// TransferGiftWithContext is the context-aware variant of TransferGift.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#transfergift
func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGiftP) (bool, error) {
req := NewRequest[bool]("transferGift", params)
return req.DoWithContext(ctx, api)
}
// PostStoryP holds parameters for the postStory method. // PostStoryP holds parameters for the postStory method.
// See https://core.telegram.org/bots/api#poststory // See https://core.telegram.org/bots/api#poststory
type PostStoryP struct { type PostStoryP struct {
@@ -318,6 +472,14 @@ func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) {
return req.Do(api) return req.Do(api)
} }
// PostStoryPhotoWithContext is the context-aware variant of PostStoryPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryPhotoWithContext(ctx context.Context, params PostStoryP) (Story, error) {
req := NewRequest[Story]("postStory", params)
return req.DoWithContext(ctx, api)
}
// PostStoryVideo posts a story with a video. // PostStoryVideo posts a story with a video.
// See https://core.telegram.org/bots/api#poststory // See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { func (api *API) PostStoryVideo(params PostStoryP) (Story, error) {
@@ -325,6 +487,14 @@ func (api *API) PostStoryVideo(params PostStoryP) (Story, error) {
return req.Do(api) return req.Do(api)
} }
// PostStoryVideoWithContext is the context-aware variant of PostStoryVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryVideoWithContext(ctx context.Context, params PostStoryP) (Story, error) {
req := NewRequest[Story]("postStory", params)
return req.DoWithContext(ctx, api)
}
// RepostStoryP holds parameters for the repostStory method. // RepostStoryP holds parameters for the repostStory method.
// See https://core.telegram.org/bots/api#repoststory // See https://core.telegram.org/bots/api#repoststory
type RepostStoryP struct { type RepostStoryP struct {
@@ -344,6 +514,14 @@ func (api *API) RepostStory(params RepostStoryP) (Story, error) {
return req.Do(api) return req.Do(api)
} }
// RepostStoryWithContext is the context-aware variant of RepostStory.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#repoststory
func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStoryP) (Story, error) {
req := NewRequest[Story]("repostStory", params)
return req.DoWithContext(ctx, api)
}
// EditStoryP holds parameters for the editStory method. // EditStoryP holds parameters for the editStory method.
// See https://core.telegram.org/bots/api#editstory // See https://core.telegram.org/bots/api#editstory
type EditStoryP struct { type EditStoryP struct {
@@ -365,6 +543,14 @@ func (api *API) EditStory(params EditStoryP) (Story, error) {
return req.Do(api) return req.Do(api)
} }
// EditStoryWithContext is the context-aware variant of EditStory.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editstory
func (api *API) EditStoryWithContext(ctx context.Context, params EditStoryP) (Story, error) {
req := NewRequest[Story]("editStory", params)
return req.DoWithContext(ctx, api)
}
// DeleteStoryP holds parameters for the deleteStory method. // DeleteStoryP holds parameters for the deleteStory method.
// See https://core.telegram.org/bots/api#deletestory // See https://core.telegram.org/bots/api#deletestory
type DeleteStoryP struct { type DeleteStoryP struct {
@@ -379,3 +565,11 @@ func (api *API) DeleteStory(params DeleteStoryP) (bool, error) {
req := NewRequest[bool]("deleteStory", params) req := NewRequest[bool]("deleteStory", params)
return req.Do(api) return req.Do(api)
} }
// DeleteStoryWithContext is the context-aware variant of DeleteStory.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestory
func (api *API) DeleteStoryWithContext(ctx context.Context, params DeleteStoryP) (bool, error) {
req := NewRequest[bool]("deleteStory", params)
return req.DoWithContext(ctx, api)
}
+11 -4
View File
@@ -72,7 +72,9 @@ type BusinessMessagesDeleted struct {
type InputStoryContentType string type InputStoryContentType string
const ( const (
// InputStoryContentPhotoType identifies photo story content.
InputStoryContentPhotoType InputStoryContentType = "photo" InputStoryContentPhotoType InputStoryContentType = "photo"
// InputStoryContentVideoType identifies video story content.
InputStoryContentVideoType InputStoryContentType = "video" InputStoryContentVideoType InputStoryContentType = "video"
) )
@@ -106,10 +108,15 @@ type StoryAreaPosition struct {
type StoryAreaTypeType string type StoryAreaTypeType string
const ( const (
StoryAreaTypeLocationType StoryAreaTypeType = "location" // StoryAreaTypeLocationType identifies a location story area.
StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction" StoryAreaTypeLocationType StoryAreaTypeType = "location"
StoryAreaTypeLinkType StoryAreaTypeType = "link" // StoryAreaTypeReactionType identifies a suggested reaction story area.
StoryAreaTypeWeatherType StoryAreaTypeType = "weather" StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction"
// StoryAreaTypeLinkType identifies a link story area.
StoryAreaTypeLinkType StoryAreaTypeType = "link"
// StoryAreaTypeWeatherType identifies a weather story area.
StoryAreaTypeWeatherType StoryAreaTypeType = "weather"
// StoryAreaTypeUniqueGiftType identifies a unique gift story area.
StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift" StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift"
) )
+258
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// BanChatMemberP holds parameters for the banChatMember method. // BanChatMemberP holds parameters for the banChatMember method.
// See https://core.telegram.org/bots/api#banchatmember // See https://core.telegram.org/bots/api#banchatmember
type BanChatMemberP struct { type BanChatMemberP struct {
@@ -17,6 +19,14 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// BanChatMemberWithContext is the context-aware variant of BanChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#banchatmember
func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMemberP) (bool, error) {
req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatMemberP holds parameters for the unbanChatMember method. // UnbanChatMemberP holds parameters for the unbanChatMember method.
// See https://core.telegram.org/bots/api#unbanchatmember // See https://core.telegram.org/bots/api#unbanchatmember
type UnbanChatMemberP struct { type UnbanChatMemberP struct {
@@ -33,6 +43,14 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// UnbanChatMemberWithContext is the context-aware variant of UnbanChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unbanchatmember
func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChatMemberP) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RestrictChatMemberP holds parameters for the restrictChatMember method. // RestrictChatMemberP holds parameters for the restrictChatMember method.
// See https://core.telegram.org/bots/api#restrictchatmember // See https://core.telegram.org/bots/api#restrictchatmember
type RestrictChatMemberP struct { type RestrictChatMemberP struct {
@@ -51,6 +69,14 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// RestrictChatMemberWithContext is the context-aware variant of RestrictChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#restrictchatmember
func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMemberP) (bool, error) {
req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// PromoteChatMember holds parameters for the promoteChatMember method. // PromoteChatMember holds parameters for the promoteChatMember method.
// See https://core.telegram.org/bots/api#promotechatmember // See https://core.telegram.org/bots/api#promotechatmember
type PromoteChatMember struct { type PromoteChatMember struct {
@@ -84,6 +110,14 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// PromoteChatMemberWithContext is the context-aware variant of PromoteChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#promotechatmember
func (api *API) PromoteChatMemberWithContext(ctx context.Context, params PromoteChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("promoteChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method. // SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
type SetChatAdministratorCustomTitleP struct { type SetChatAdministratorCustomTitleP struct {
@@ -100,6 +134,14 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto
return req.Do(api) return req.Do(api)
} }
// SetChatAdministratorCustomTitleWithContext is the context-aware variant of SetChatAdministratorCustomTitle.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, params SetChatAdministratorCustomTitleP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatMemberTagP holds parameters for the setChatMemberTag method. // SetChatMemberTagP holds parameters for the setChatMemberTag method.
// See https://core.telegram.org/bots/api#setchatmembertag // See https://core.telegram.org/bots/api#setchatmembertag
type SetChatMemberTagP struct { type SetChatMemberTagP struct {
@@ -116,6 +158,14 @@ func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatMemberTagWithContext is the context-aware variant of SetChatMemberTag.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatmembertag
func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatMemberTagP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// BanChatSenderChatP holds parameters for the banChatSenderChat method. // BanChatSenderChatP holds parameters for the banChatSenderChat method.
// See https://core.telegram.org/bots/api#banchatsenderchat // See https://core.telegram.org/bots/api#banchatsenderchat
type BanChatSenderChatP struct { type BanChatSenderChatP struct {
@@ -131,6 +181,14 @@ func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// BanChatSenderChatWithContext is the context-aware variant of BanChatSenderChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#banchatsenderchat
func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChatSenderChatP) (bool, error) {
req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method. // UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method.
// See https://core.telegram.org/bots/api#unbanchatsenderchat // See https://core.telegram.org/bots/api#unbanchatsenderchat
type UnbanChatSenderChatP struct { type UnbanChatSenderChatP struct {
@@ -146,6 +204,14 @@ func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// UnbanChatSenderChatWithContext is the context-aware variant of UnbanChatSenderChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unbanchatsenderchat
func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params UnbanChatSenderChatP) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPermissionsP holds parameters for the setChatPermissions method. // SetChatPermissionsP holds parameters for the setChatPermissions method.
// See https://core.telegram.org/bots/api#setchatpermissions // See https://core.telegram.org/bots/api#setchatpermissions
type SetChatPermissionsP struct { type SetChatPermissionsP struct {
@@ -162,6 +228,14 @@ func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatPermissionsWithContext is the context-aware variant of SetChatPermissions.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatpermissions
func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetChatPermissionsP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ExportChatInviteLinkP holds parameters for the exportChatInviteLink method. // ExportChatInviteLinkP holds parameters for the exportChatInviteLink method.
// See https://core.telegram.org/bots/api#exportchatinvitelink // See https://core.telegram.org/bots/api#exportchatinvitelink
type ExportChatInviteLinkP struct { type ExportChatInviteLinkP struct {
@@ -176,6 +250,14 @@ func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, erro
return req.Do(api) return req.Do(api)
} }
// ExportChatInviteLinkWithContext is the context-aware variant of ExportChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#exportchatinvitelink
func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params ExportChatInviteLinkP) (string, error) {
req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatInviteLinkP holds parameters for the createChatInviteLink method. // CreateChatInviteLinkP holds parameters for the createChatInviteLink method.
// See https://core.telegram.org/bots/api#createchatinvitelink // See https://core.telegram.org/bots/api#createchatinvitelink
type CreateChatInviteLinkP struct { type CreateChatInviteLinkP struct {
@@ -194,6 +276,14 @@ func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLi
return req.Do(api) return req.Do(api)
} }
// CreateChatInviteLinkWithContext is the context-aware variant of CreateChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createchatinvitelink
func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params CreateChatInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatInviteLinkP holds parameters for the editChatInviteLink method. // EditChatInviteLinkP holds parameters for the editChatInviteLink method.
// See https://core.telegram.org/bots/api#editchatinvitelink // See https://core.telegram.org/bots/api#editchatinvitelink
type EditChatInviteLinkP struct { type EditChatInviteLinkP struct {
@@ -214,6 +304,14 @@ func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink,
return req.Do(api) return req.Do(api)
} }
// EditChatInviteLinkWithContext is the context-aware variant of EditChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editchatinvitelink
func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditChatInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method. // CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
type CreateChatSubscriptionInviteLinkP struct { type CreateChatSubscriptionInviteLinkP struct {
@@ -231,6 +329,14 @@ func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionIn
return req.Do(api) return req.Do(api)
} }
// CreateChatSubscriptionInviteLinkWithContext is the context-aware variant of CreateChatSubscriptionInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method. // EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
type EditChatSubscriptionInviteLinkP struct { type EditChatSubscriptionInviteLinkP struct {
@@ -247,6 +353,14 @@ func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInvite
return req.Do(api) return req.Do(api)
} }
// EditChatSubscriptionInviteLinkWithContext is the context-aware variant of EditChatSubscriptionInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method. // RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method.
// See https://core.telegram.org/bots/api#revokechatinvitelink // See https://core.telegram.org/bots/api#revokechatinvitelink
type RevokeChatInviteLinkP struct { type RevokeChatInviteLinkP struct {
@@ -262,6 +376,14 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi
return req.Do(api) return req.Do(api)
} }
// RevokeChatInviteLinkWithContext is the context-aware variant of RevokeChatInviteLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#revokechatinvitelink
func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params RevokeChatInviteLinkP) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method. // ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method.
// See https://core.telegram.org/bots/api#approvechatjoinrequest // See https://core.telegram.org/bots/api#approvechatjoinrequest
type ApproveChatJoinRequestP struct { type ApproveChatJoinRequestP struct {
@@ -277,6 +399,14 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er
return req.Do(api) return req.Do(api)
} }
// ApproveChatJoinRequestWithContext is the context-aware variant of ApproveChatJoinRequest.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#approvechatjoinrequest
func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params ApproveChatJoinRequestP) (bool, error) {
req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method. // DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method.
// See https://core.telegram.org/bots/api#declinechatjoinrequest // See https://core.telegram.org/bots/api#declinechatjoinrequest
type DeclineChatJoinRequestP struct { type DeclineChatJoinRequestP struct {
@@ -292,6 +422,14 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er
return req.Do(api) return req.Do(api)
} }
// DeclineChatJoinRequestWithContext is the context-aware variant of DeclineChatJoinRequest.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#declinechatjoinrequest
func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params DeclineChatJoinRequestP) (bool, error) {
req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPhotoP holds parameters for the setChatPhoto method. // SetChatPhotoP holds parameters for the setChatPhoto method.
// See https://core.telegram.org/bots/api#setchatphoto // See https://core.telegram.org/bots/api#setchatphoto
type SetChatPhotoP struct { type SetChatPhotoP struct {
@@ -325,6 +463,14 @@ func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// DeleteChatPhotoWithContext is the context-aware variant of DeleteChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletechatphoto
func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteChatPhotoP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatTitleP holds parameters for the setChatTitle method. // SetChatTitleP holds parameters for the setChatTitle method.
// See https://core.telegram.org/bots/api#setchattitle // See https://core.telegram.org/bots/api#setchattitle
type SetChatTitleP struct { type SetChatTitleP struct {
@@ -340,6 +486,14 @@ func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatTitleWithContext is the context-aware variant of SetChatTitle.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchattitle
func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitleP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatDescriptionP holds parameters for the setChatDescription method. // SetChatDescriptionP holds parameters for the setChatDescription method.
// See https://core.telegram.org/bots/api#setchatdescription // See https://core.telegram.org/bots/api#setchatdescription
type SetChatDescriptionP struct { type SetChatDescriptionP struct {
@@ -355,6 +509,14 @@ func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatDescriptionWithContext is the context-aware variant of SetChatDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatdescription
func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetChatDescriptionP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// PinChatMessageP holds parameters for the pinChatMessage method. // PinChatMessageP holds parameters for the pinChatMessage method.
// See https://core.telegram.org/bots/api#pinchatmessage // See https://core.telegram.org/bots/api#pinchatmessage
type PinChatMessageP struct { type PinChatMessageP struct {
@@ -372,6 +534,14 @@ func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// PinChatMessageWithContext is the context-aware variant of PinChatMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#pinchatmessage
func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMessageP) (bool, error) {
req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinChatMessageP holds parameters for the unpinChatMessage method. // UnpinChatMessageP holds parameters for the unpinChatMessage method.
// See https://core.telegram.org/bots/api#unpinchatmessage // See https://core.telegram.org/bots/api#unpinchatmessage
type UnpinChatMessageP struct { type UnpinChatMessageP struct {
@@ -388,6 +558,14 @@ func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// UnpinChatMessageWithContext is the context-aware variant of UnpinChatMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinchatmessage
func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinChatMessageP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method. // UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method.
// See https://core.telegram.org/bots/api#unpinallchatmessages // See https://core.telegram.org/bots/api#unpinallchatmessages
type UnpinAllChatMessagesP struct { type UnpinAllChatMessagesP struct {
@@ -402,6 +580,14 @@ func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error)
return req.Do(api) return req.Do(api)
} }
// UnpinAllChatMessagesWithContext is the context-aware variant of UnpinAllChatMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinallchatmessages
func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params UnpinAllChatMessagesP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// LeaveChatP holds parameters for the leaveChat method. // LeaveChatP holds parameters for the leaveChat method.
// See https://core.telegram.org/bots/api#leavechat // See https://core.telegram.org/bots/api#leavechat
type LeaveChatP struct { type LeaveChatP struct {
@@ -416,6 +602,14 @@ func (api *API) LeaveChat(params LeaveChatP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// LeaveChatWithContext is the context-aware variant of LeaveChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#leavechat
func (api *API) LeaveChatWithContext(ctx context.Context, params LeaveChatP) (bool, error) {
req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChatP holds parameters for the getChat method. // GetChatP holds parameters for the getChat method.
// See https://core.telegram.org/bots/api#getchat // See https://core.telegram.org/bots/api#getchat
type GetChatP struct { type GetChatP struct {
@@ -429,6 +623,14 @@ func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) {
return req.Do(api) return req.Do(api)
} }
// GetChatWithContext is the context-aware variant of GetChat.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchat
func (api *API) GetChatWithContext(ctx context.Context, params GetChatP) (ChatFullInfo, error) {
req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChatAdministratorsP holds parameters for the getChatAdministrators method. // GetChatAdministratorsP holds parameters for the getChatAdministrators method.
// See https://core.telegram.org/bots/api#getchatadministrators // See https://core.telegram.org/bots/api#getchatadministrators
type GetChatAdministratorsP struct { type GetChatAdministratorsP struct {
@@ -442,6 +644,14 @@ func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMemb
return req.Do(api) return req.Do(api)
} }
// GetChatAdministratorsWithContext is the context-aware variant of GetChatAdministrators.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatadministrators
func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params GetChatAdministratorsP) ([]ChatMember, error) {
req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMembersCountP holds parameters for the getChatMemberCount method. // GetChatMembersCountP holds parameters for the getChatMemberCount method.
// See https://core.telegram.org/bots/api#getchatmembercount // See https://core.telegram.org/bots/api#getchatmembercount
type GetChatMembersCountP struct { type GetChatMembersCountP struct {
@@ -455,6 +665,14 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) {
return req.Do(api) return req.Do(api)
} }
// GetChatMemberCountWithContext is the context-aware variant of GetChatMemberCount.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatmembercount
func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCountP) (int, error) {
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMemberP holds parameters for the getChatMember method. // GetChatMemberP holds parameters for the getChatMember method.
// See https://core.telegram.org/bots/api#getchatmember // See https://core.telegram.org/bots/api#getchatmember
type GetChatMemberP struct { type GetChatMemberP struct {
@@ -469,6 +687,14 @@ func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) {
return req.Do(api) return req.Do(api)
} }
// GetChatMemberWithContext is the context-aware variant of GetChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatmember
func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMemberP) (ChatMember, error) {
req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatStickerSetP holds parameters for the setChatStickerSet method. // SetChatStickerSetP holds parameters for the setChatStickerSet method.
// See https://core.telegram.org/bots/api#setchatstickerset // See https://core.telegram.org/bots/api#setchatstickerset
type SetChatStickerSetP struct { type SetChatStickerSetP struct {
@@ -484,6 +710,14 @@ func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatStickerSetWithContext is the context-aware variant of SetChatStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatstickerset
func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChatStickerSetP) (bool, error) {
req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method. // DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method.
// See https://core.telegram.org/bots/api#deletechatstickerset // See https://core.telegram.org/bots/api#deletechatstickerset
type DeleteChatStickerSetP struct { type DeleteChatStickerSetP struct {
@@ -498,6 +732,14 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error)
return req.Do(api) return req.Do(api)
} }
// DeleteChatStickerSetWithContext is the context-aware variant of DeleteChatStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletechatstickerset
func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params DeleteChatStickerSetP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetUserChatBoostsP holds parameters for the getUserChatBoosts method. // GetUserChatBoostsP holds parameters for the getUserChatBoosts method.
// See https://core.telegram.org/bots/api#getuserchatboosts // See https://core.telegram.org/bots/api#getuserchatboosts
type GetUserChatBoostsP struct { type GetUserChatBoostsP struct {
@@ -512,6 +754,14 @@ func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, er
return req.Do(api) return req.Do(api)
} }
// GetUserChatBoostsWithContext is the context-aware variant of GetUserChatBoosts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getuserchatboosts
func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUserChatBoostsP) (UserChatBoosts, error) {
req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatGiftsP holds parameters for the getChatGifts method. // GetChatGiftsP holds parameters for the getChatGifts method.
// See https://core.telegram.org/bots/api#getchatgifts // See https://core.telegram.org/bots/api#getchatgifts
type GetChatGiftsP struct { type GetChatGiftsP struct {
@@ -534,3 +784,11 @@ func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) {
req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID) req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetChatGiftsWithContext is the context-aware variant of GetChatGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatgifts
func (api *API) GetChatGiftsWithContext(ctx context.Context, params GetChatGiftsP) (OwnedGifts, error) {
req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+19 -9
View File
@@ -17,10 +17,14 @@ type Chat struct {
type ChatType string type ChatType string
const ( const (
ChatTypePrivate ChatType = "private" // ChatTypePrivate identifies a private chat.
ChatTypeGroup ChatType = "group" ChatTypePrivate ChatType = "private"
// ChatTypeGroup identifies a basic group chat.
ChatTypeGroup ChatType = "group"
// ChatTypeSupergroup identifies a supergroup chat.
ChatTypeSupergroup ChatType = "supergroup" ChatTypeSupergroup ChatType = "supergroup"
ChatTypeChannel ChatType = "channel" // ChatTypeChannel identifies a channel chat.
ChatTypeChannel ChatType = "channel"
) )
// ChatFullInfo contains full information about a chat. // ChatFullInfo contains full information about a chat.
@@ -143,12 +147,18 @@ type ChatInviteLink struct {
type ChatMemberStatusType string type ChatMemberStatusType string
const ( const (
ChatMemberStatusOwner ChatMemberStatusType = "owner" // ChatMemberStatusOwner identifies a chat owner.
ChatMemberStatusOwner ChatMemberStatusType = "owner"
// ChatMemberStatusAdministrator identifies a chat administrator.
ChatMemberStatusAdministrator ChatMemberStatusType = "administrator" ChatMemberStatusAdministrator ChatMemberStatusType = "administrator"
ChatMemberStatusMember ChatMemberStatusType = "member" // ChatMemberStatusMember identifies a regular member.
ChatMemberStatusRestricted ChatMemberStatusType = "restricted" ChatMemberStatusMember ChatMemberStatusType = "member"
ChatMemberStatusLeft ChatMemberStatusType = "left" // ChatMemberStatusRestricted identifies a restricted member.
ChatMemberStatusBanned ChatMemberStatusType = "kicked" ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
// ChatMemberStatusLeft identifies a user who left the chat.
ChatMemberStatusLeft ChatMemberStatusType = "left"
// ChatMemberStatusBanned identifies a banned user.
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
) )
// ChatMember contains information about one member of a chat. // ChatMember contains information about one member of a chat.
@@ -214,7 +224,7 @@ type ChatBoostSource struct {
// ChatBoost represents a boost added to a chat. // ChatBoost represents a boost added to a chat.
// See https://core.telegram.org/bots/api#chatboost // See https://core.telegram.org/bots/api#chatboost
type ChatBoost struct { type ChatBoost struct {
BoostID int `json:"boost_id"` BoostID string `json:"boost_id"`
AddDate int `json:"add_date"` AddDate int `json:"add_date"`
ExpirationDate int `json:"expiration_date"` ExpirationDate int `json:"expiration_date"`
Source ChatBoostSource `json:"source"` Source ChatBoostSource `json:"source"`
+7
View File
@@ -2,7 +2,14 @@ package tgapi
import "errors" import "errors"
// ErrRateLimit reports that a request exceeded the configured rate limiter.
var ErrRateLimit = errors.New("rate limit exceeded") var ErrRateLimit = errors.New("rate limit exceeded")
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
var ErrPoolUnexpected = errors.New("unexpected response from pool") var ErrPoolUnexpected = errors.New("unexpected response from pool")
// ErrPoolQueueFull reports that the internal request queue is full.
var ErrPoolQueueFull = errors.New("worker pool queue full") var ErrPoolQueueFull = errors.New("worker pool queue full")
// ErrPoolStopped reports that a request was submitted after the worker pool stopped.
var ErrPoolStopped = errors.New("worker pool stopped") var ErrPoolStopped = errors.New("worker pool stopped")
+106
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID. // BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID.
type BaseForumTopicP struct { type BaseForumTopicP struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
@@ -13,6 +15,14 @@ func (api *API) GetForumTopicIconStickers() ([]Sticker, error) {
return req.Do(api) return req.Do(api)
} }
// GetForumTopicIconStickersWithContext is the context-aware variant of GetForumTopicIconStickers.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getforumtopiciconstickers
func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getForumTopicIconStickers", NoParams)
return req.DoWithContext(ctx, api)
}
// CreateForumTopicP holds parameters for the createForumTopic method. // CreateForumTopicP holds parameters for the createForumTopic method.
// See https://core.telegram.org/bots/api#createforumtopic // See https://core.telegram.org/bots/api#createforumtopic
type CreateForumTopicP struct { type CreateForumTopicP struct {
@@ -30,6 +40,14 @@ func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) {
return req.Do(api) return req.Do(api)
} }
// CreateForumTopicWithContext is the context-aware variant of CreateForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createforumtopic
func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateForumTopicP) (ForumTopic, error) {
req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditForumTopicP holds parameters for the editForumTopic method. // EditForumTopicP holds parameters for the editForumTopic method.
// See https://core.telegram.org/bots/api#editforumtopic // See https://core.telegram.org/bots/api#editforumtopic
type EditForumTopicP struct { type EditForumTopicP struct {
@@ -46,6 +64,14 @@ func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// EditForumTopicWithContext is the context-aware variant of EditForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editforumtopic
func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CloseForumTopic closes an open forum topic. // CloseForumTopic closes an open forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#closeforumtopic // See https://core.telegram.org/bots/api#closeforumtopic
@@ -54,6 +80,14 @@ func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// CloseForumTopicWithContext is the context-aware variant of CloseForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#closeforumtopic
func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ReopenForumTopic reopens a closed forum topic. // ReopenForumTopic reopens a closed forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#reopenforumtopic // See https://core.telegram.org/bots/api#reopenforumtopic
@@ -62,6 +96,14 @@ func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// ReopenForumTopicWithContext is the context-aware variant of ReopenForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#reopenforumtopic
func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteForumTopic deletes a forum topic. // DeleteForumTopic deletes a forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deleteforumtopic // See https://core.telegram.org/bots/api#deleteforumtopic
@@ -70,6 +112,14 @@ func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// DeleteForumTopicWithContext is the context-aware variant of DeleteForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deleteforumtopic
func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic. // UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unpinallforumtopicmessages // See https://core.telegram.org/bots/api#unpinallforumtopicmessages
@@ -78,6 +128,14 @@ func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error)
return req.Do(api) return req.Do(api)
} }
// UnpinAllForumTopicMessagesWithContext is the context-aware variant of UnpinAllForumTopicMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinallforumtopicmessages
func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, params BaseForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// BaseGeneralForumTopicP contains common fields for general forum topic operations that require a chat ID. // BaseGeneralForumTopicP contains common fields for general forum topic operations that require a chat ID.
type BaseGeneralForumTopicP struct { type BaseGeneralForumTopicP struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
@@ -98,6 +156,14 @@ func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, erro
return req.Do(api) return req.Do(api)
} }
// EditGeneralForumTopicWithContext is the context-aware variant of EditGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editgeneralforumtopic
func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CloseGeneralForumTopic closes the 'General' topic in a forum supergroup. // CloseGeneralForumTopic closes the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#closegeneralforumtopic // See https://core.telegram.org/bots/api#closegeneralforumtopic
@@ -106,6 +172,14 @@ func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, err
return req.Do(api) return req.Do(api)
} }
// CloseGeneralForumTopicWithContext is the context-aware variant of CloseGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#closegeneralforumtopic
func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup. // ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic // See https://core.telegram.org/bots/api#reopengeneralforumtopic
@@ -114,6 +188,14 @@ func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
return req.Do(api) return req.Do(api)
} }
// ReopenGeneralForumTopicWithContext is the context-aware variant of ReopenGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic
func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// HideGeneralForumTopic hides the 'General' topic in a forum supergroup. // HideGeneralForumTopic hides the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic // See https://core.telegram.org/bots/api#hidegeneralforumtopic
@@ -122,6 +204,14 @@ func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, erro
return req.Do(api) return req.Do(api)
} }
// HideGeneralForumTopicWithContext is the context-aware variant of HideGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic
func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup. // UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic // See https://core.telegram.org/bots/api#unhidegeneralforumtopic
@@ -130,6 +220,14 @@ func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
return req.Do(api) return req.Do(api)
} }
// UnhideGeneralForumTopicWithContext is the context-aware variant of UnhideGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic
func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic. // UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages // See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages
@@ -137,3 +235,11 @@ func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP)
req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// UnpinAllGeneralForumTopicMessagesWithContext is the context-aware variant of UnpinAllGeneralForumTopicMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages
func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params BaseGeneralForumTopicP) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+33
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// SendGameP holds parameters for the sendGame method. // SendGameP holds parameters for the sendGame method.
// See https://core.telegram.org/bots/api#sendgame // See https://core.telegram.org/bots/api#sendgame
type SendGameP struct { type SendGameP struct {
@@ -24,6 +26,14 @@ func (api *API) SendGame(params SendGameP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendGameWithContext is the context-aware variant of SendGame.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendgame
func (api *API) SendGameWithContext(ctx context.Context, params SendGameP) (Message, error) {
req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetGameScoreP holds parameters for the setGameScore method. // SetGameScoreP holds parameters for the setGameScore method.
// See https://core.telegram.org/bots/api#setgamescore // See https://core.telegram.org/bots/api#setgamescore
type SetGameScoreP struct { type SetGameScoreP struct {
@@ -52,6 +62,21 @@ func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) {
return res, false, err return res, false, err
} }
// SetGameScoreWithContext is the context-aware variant of SetGameScore.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setgamescore
func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScoreP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("setGameScore", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// GetGameHighScoresP holds parameters for the getGameHighScores method. // GetGameHighScoresP holds parameters for the getGameHighScores method.
// See https://core.telegram.org/bots/api#getgamehighscores // See https://core.telegram.org/bots/api#getgamehighscores
type GetGameHighScoresP struct { type GetGameHighScoresP struct {
@@ -67,3 +92,11 @@ func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, e
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID) req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetGameHighScoresWithContext is the context-aware variant of GetGameHighScores.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getgamehighscores
func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScoresP) ([]GameHighScore, error) {
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+26
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// AnswerInlineQueryP holds parameters for the answerInlineQuery method. // AnswerInlineQueryP holds parameters for the answerInlineQuery method.
// See https://core.telegram.org/bots/api#answerinlinequery // See https://core.telegram.org/bots/api#answerinlinequery
type AnswerInlineQueryP struct { type AnswerInlineQueryP struct {
@@ -19,6 +21,14 @@ func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// AnswerInlineQueryWithContext is the context-aware variant of AnswerInlineQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answerinlinequery
func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerInlineQueryP) (bool, error) {
req := NewRequest[bool]("answerInlineQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerWebAppQueryP holds parameters for the answerWebAppQuery method. // AnswerWebAppQueryP holds parameters for the answerWebAppQuery method.
// See https://core.telegram.org/bots/api#answerwebappquery // See https://core.telegram.org/bots/api#answerwebappquery
type AnswerWebAppQueryP struct { type AnswerWebAppQueryP struct {
@@ -33,6 +43,14 @@ func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage,
return req.Do(api) return req.Do(api)
} }
// AnswerWebAppQueryWithContext is the context-aware variant of AnswerWebAppQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answerwebappquery
func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerWebAppQueryP) (SentWebAppMessage, error) {
req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params)
return req.DoWithContext(ctx, api)
}
// SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method. // SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage // See https://core.telegram.org/bots/api#savepreparedinlinemessage
type SavePreparedInlineMessageP struct { type SavePreparedInlineMessageP struct {
@@ -50,3 +68,11 @@ func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (Pr
req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params) req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
return req.Do(api) return req.Do(api)
} }
// SavePreparedInlineMessageWithContext is the context-aware variant of SavePreparedInlineMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage
func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params SavePreparedInlineMessageP) (PreparedInlineMessage, error) {
req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
return req.DoWithContext(ctx, api)
}
+285 -18
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// SendMessageP holds parameters for the sendMessage method. // SendMessageP holds parameters for the sendMessage method.
// See https://core.telegram.org/bots/api#sendmessage // See https://core.telegram.org/bots/api#sendmessage
type SendMessageP struct { type SendMessageP struct {
@@ -29,6 +31,14 @@ func (api *API) SendMessage(params SendMessageP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendMessageWithContext is the context-aware variant of SendMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendmessage
func (api *API) SendMessageWithContext(ctx context.Context, params SendMessageP) (Message, error) {
req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessageP holds parameters for the forwardMessage method. // ForwardMessageP holds parameters for the forwardMessage method.
// See https://core.telegram.org/bots/api#forwardmessage // See https://core.telegram.org/bots/api#forwardmessage
type ForwardMessageP struct { type ForwardMessageP struct {
@@ -53,6 +63,14 @@ func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// ForwardMessageWithContext is the context-aware variant of ForwardMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#forwardmessage
func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMessageP) (Message, error) {
req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessagesP holds parameters for the forwardMessages method. // ForwardMessagesP holds parameters for the forwardMessages method.
// See https://core.telegram.org/bots/api#forwardmessages // See https://core.telegram.org/bots/api#forwardmessages
type ForwardMessagesP struct { type ForwardMessagesP struct {
@@ -74,6 +92,14 @@ func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) {
return req.Do(api) return req.Do(api)
} }
// ForwardMessagesWithContext is the context-aware variant of ForwardMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#forwardmessages
func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMessagesP) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CopyMessageP holds parameters for the copyMessage method. // CopyMessageP holds parameters for the copyMessage method.
// See https://core.telegram.org/bots/api#copymessage // See https://core.telegram.org/bots/api#copymessage
type CopyMessageP struct { type CopyMessageP struct {
@@ -110,6 +136,17 @@ func (api *API) CopyMessage(params CopyMessageP) (int, error) {
return msgID.MessageID, nil return msgID.MessageID, nil
} }
// CopyMessageWithContext is the context-aware variant of CopyMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#copymessage
func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessageP) (int, error) {
msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).DoWithContext(ctx, api)
if err != nil {
return 0, err
}
return msgID.MessageID, nil
}
// CopyMessagesP holds parameters for the copyMessages method. // CopyMessagesP holds parameters for the copyMessages method.
// See https://core.telegram.org/bots/api#copymessages // See https://core.telegram.org/bots/api#copymessages
type CopyMessagesP struct { type CopyMessagesP struct {
@@ -132,6 +169,14 @@ func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) {
return req.Do(api) return req.Do(api)
} }
// CopyMessagesWithContext is the context-aware variant of CopyMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#copymessages
func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessagesP) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendLocationP holds parameters for the sendLocation method. // SendLocationP holds parameters for the sendLocation method.
// See https://core.telegram.org/bots/api#sendlocation // See https://core.telegram.org/bots/api#sendlocation
type SendLocationP struct { type SendLocationP struct {
@@ -164,6 +209,14 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendLocationWithContext is the context-aware variant of SendLocation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendlocation
func (api *API) SendLocationWithContext(ctx context.Context, params SendLocationP) (Message, error) {
req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVenueP holds parameters for the sendVenue method. // SendVenueP holds parameters for the sendVenue method.
// See https://core.telegram.org/bots/api#sendvenue // See https://core.telegram.org/bots/api#sendvenue
type SendVenueP struct { type SendVenueP struct {
@@ -198,6 +251,14 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendVenueWithContext is the context-aware variant of SendVenue.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvenue
func (api *API) SendVenueWithContext(ctx context.Context, params SendVenueP) (Message, error) {
req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendContactP holds parameters for the sendContact method. // SendContactP holds parameters for the sendContact method.
// See https://core.telegram.org/bots/api#sendcontact // See https://core.telegram.org/bots/api#sendcontact
type SendContactP struct { type SendContactP struct {
@@ -228,6 +289,14 @@ func (api *API) SendContact(params SendContactP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendContactWithContext is the context-aware variant of SendContact.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendcontact
func (api *API) SendContactWithContext(ctx context.Context, params SendContactP) (Message, error) {
req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPollP holds parameters for the sendPoll method. // SendPollP holds parameters for the sendPoll method.
// See https://core.telegram.org/bots/api#sendpoll // See https://core.telegram.org/bots/api#sendpoll
type SendPollP struct { type SendPollP struct {
@@ -266,6 +335,14 @@ func (api *API) SendPoll(params SendPollP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendPollWithContext is the context-aware variant of SendPoll.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendpoll
func (api *API) SendPollWithContext(ctx context.Context, params SendPollP) (Message, error) {
req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChecklistP holds parameters for the sendChecklist method. // SendChecklistP holds parameters for the sendChecklist method.
// See https://core.telegram.org/bots/api#sendchecklist // See https://core.telegram.org/bots/api#sendchecklist
type SendChecklistP struct { type SendChecklistP struct {
@@ -288,6 +365,14 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendChecklistWithContext is the context-aware variant of SendChecklist.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendchecklist
func (api *API) SendChecklistWithContext(ctx context.Context, params SendChecklistP) (Message, error) {
req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDiceP holds parameters for the sendDice method. // SendDiceP holds parameters for the sendDice method.
// See https://core.telegram.org/bots/api#senddice // See https://core.telegram.org/bots/api#senddice
type SendDiceP struct { type SendDiceP struct {
@@ -315,6 +400,14 @@ func (api *API) SendDice(params SendDiceP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendDiceWithContext is the context-aware variant of SendDice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#senddice
func (api *API) SendDiceWithContext(ctx context.Context, params SendDiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMessageDraftP holds parameters for the sendMessageDraft method. // SendMessageDraftP holds parameters for the sendMessageDraft method.
// See https://core.telegram.org/bots/api#sendmessagedraft // See https://core.telegram.org/bots/api#sendmessagedraft
type SendMessageDraftP struct { type SendMessageDraftP struct {
@@ -334,6 +427,14 @@ func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SendMessageDraftWithContext is the context-aware variant of SendMessageDraft.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendmessagedraft
func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMessageDraftP) (bool, error) {
req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChatActionP holds parameters for the sendChatAction method. // SendChatActionP holds parameters for the sendChatAction method.
// See https://core.telegram.org/bots/api#sendchataction // See https://core.telegram.org/bots/api#sendchataction
type SendChatActionP struct { type SendChatActionP struct {
@@ -351,6 +452,14 @@ func (api *API) SendChatAction(params SendChatActionP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SendChatActionWithContext is the context-aware variant of SendChatAction.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendchataction
func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatActionP) (bool, error) {
req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetMessageReactionP holds parameters for the setMessageReaction method. // SetMessageReactionP holds parameters for the setMessageReaction method.
// See https://core.telegram.org/bots/api#setmessagereaction // See https://core.telegram.org/bots/api#setmessagereaction
type SetMessageReactionP struct { type SetMessageReactionP struct {
@@ -368,16 +477,26 @@ func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetMessageReactionWithContext is the context-aware variant of SetMessageReaction.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmessagereaction
func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMessageReactionP) (bool, error) {
req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageTextP holds parameters for the editMessageText method. // EditMessageTextP holds parameters for the editMessageText method.
// See https://core.telegram.org/bots/api#editmessagetext // See https://core.telegram.org/bots/api#editmessagetext
type EditMessageTextP struct { type EditMessageTextP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"`
Text string `json:"text"` Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` Entities []MessageEntity `json:"entities,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
} }
// EditMessageText edits text messages. // EditMessageText edits text messages.
@@ -396,16 +515,33 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error)
return res, false, err return res, false, err
} }
// EditMessageTextWithContext is the context-aware variant of EditMessageText.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagetext
func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessageTextP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageText", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageCaptionP holds parameters for the editMessageCaption method. // EditMessageCaptionP holds parameters for the editMessageCaption method.
// See https://core.telegram.org/bots/api#editmessagecaption // See https://core.telegram.org/bots/api#editmessagecaption
type EditMessageCaptionP struct { type EditMessageCaptionP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"`
Caption string `json:"caption"` Caption string `json:"caption"`
ParseMode ParseMode `json:"parse_mode,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
} }
// EditMessageCaption edits captions of messages. // EditMessageCaption edits captions of messages.
@@ -424,6 +560,21 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e
return res, false, err return res, false, err
} }
// EditMessageCaptionWithContext is the context-aware variant of EditMessageCaption.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagecaption
func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMessageCaptionP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageCaption", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageMediaP holds parameters for the editMessageMedia method. // EditMessageMediaP holds parameters for the editMessageMedia method.
// See https://core.telegram.org/bots/api#editmessagemedia // See https://core.telegram.org/bots/api#editmessagemedia
type EditMessageMediaP struct { type EditMessageMediaP struct {
@@ -451,6 +602,21 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error
return res, false, err return res, false, err
} }
// EditMessageMediaWithContext is the context-aware variant of EditMessageMedia.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagemedia
func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMessageMediaP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageMedia", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method. // EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method.
// See https://core.telegram.org/bots/api#editmessagelivelocation // See https://core.telegram.org/bots/api#editmessagelivelocation
type EditMessageLiveLocationP struct { type EditMessageLiveLocationP struct {
@@ -484,6 +650,21 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag
return res, false, err return res, false, err
} }
// EditMessageLiveLocationWithContext is the context-aware variant of EditMessageLiveLocation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagelivelocation
func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params EditMessageLiveLocationP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method. // StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method.
// See https://core.telegram.org/bots/api#stopmessagelivelocation // See https://core.telegram.org/bots/api#stopmessagelivelocation
type StopMessageLiveLocationP struct { type StopMessageLiveLocationP struct {
@@ -510,6 +691,21 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag
return res, false, err return res, false, err
} }
// StopMessageLiveLocationWithContext is the context-aware variant of StopMessageLiveLocation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#stopmessagelivelocation
func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params StopMessageLiveLocationP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("stopMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageChecklistP holds parameters for the editMessageChecklist method. // EditMessageChecklistP holds parameters for the editMessageChecklist method.
type EditMessageChecklistP struct { type EditMessageChecklistP struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
@@ -526,6 +722,14 @@ func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, err
return req.Do(api) return req.Do(api)
} }
// EditMessageChecklistWithContext is the context-aware variant of EditMessageChecklist.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagechecklist
func (api *API) EditMessageChecklistWithContext(ctx context.Context, params EditMessageChecklistP) (Message, error) {
req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method. // EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method.
// See https://core.telegram.org/bots/api#editmessagereplymarkup // See https://core.telegram.org/bots/api#editmessagereplymarkup
type EditMessageReplyMarkupP struct { type EditMessageReplyMarkupP struct {
@@ -552,13 +756,28 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message,
return res, false, err return res, false, err
} }
// EditMessageReplyMarkupWithContext is the context-aware variant of EditMessageReplyMarkup.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editmessagereplymarkup
func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params EditMessageReplyMarkupP) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageReplyMarkup", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// StopPollP holds parameters for the stopPoll method. // StopPollP holds parameters for the stopPoll method.
// See https://core.telegram.org/bots/api#stoppoll // See https://core.telegram.org/bots/api#stoppoll
type StopPollP struct { type StopPollP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
InlineMessageID string `json:"inline_message_id,omitempty"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
} }
// StopPoll stops a poll that was sent by the bot. // StopPoll stops a poll that was sent by the bot.
@@ -569,6 +788,14 @@ func (api *API) StopPoll(params StopPollP) (Poll, error) {
return req.Do(api) return req.Do(api)
} }
// StopPollWithContext is the context-aware variant of StopPoll.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#stoppoll
func (api *API) StopPollWithContext(ctx context.Context, params StopPollP) (Poll, error) {
req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveSuggestedPostP holds parameters for the approveSuggestedPost method. // ApproveSuggestedPostP holds parameters for the approveSuggestedPost method.
// See https://core.telegram.org/bots/api#approvesuggestedpost // See https://core.telegram.org/bots/api#approvesuggestedpost
type ApproveSuggestedPostP struct { type ApproveSuggestedPostP struct {
@@ -585,6 +812,14 @@ func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error)
return req.Do(api) return req.Do(api)
} }
// ApproveSuggestedPostWithContext is the context-aware variant of ApproveSuggestedPost.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#approvesuggestedpost
func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params ApproveSuggestedPostP) (bool, error) {
req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineSuggestedPostP holds parameters for the declineSuggestedPost method. // DeclineSuggestedPostP holds parameters for the declineSuggestedPost method.
// See https://core.telegram.org/bots/api#declinesuggestedpost // See https://core.telegram.org/bots/api#declinesuggestedpost
type DeclineSuggestedPostP struct { type DeclineSuggestedPostP struct {
@@ -601,6 +836,14 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error)
return req.Do(api) return req.Do(api)
} }
// DeclineSuggestedPostWithContext is the context-aware variant of DeclineSuggestedPost.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#declinesuggestedpost
func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params DeclineSuggestedPostP) (bool, error) {
req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessageP holds parameters for the deleteMessage method. // DeleteMessageP holds parameters for the deleteMessage method.
// See https://core.telegram.org/bots/api#deletemessage // See https://core.telegram.org/bots/api#deletemessage
type DeleteMessageP struct { type DeleteMessageP struct {
@@ -616,6 +859,14 @@ func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// DeleteMessageWithContext is the context-aware variant of DeleteMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletemessage
func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessageP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessagesP holds parameters for the deleteMessages method. // DeleteMessagesP holds parameters for the deleteMessages method.
// See https://core.telegram.org/bots/api#deletemessages // See https://core.telegram.org/bots/api#deletemessages
type DeleteMessagesP struct { type DeleteMessagesP struct {
@@ -631,6 +882,14 @@ func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// DeleteMessagesWithContext is the context-aware variant of DeleteMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletemessages
func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMessagesP) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// AnswerCallbackQueryP holds parameters for the answerCallbackQuery method. // AnswerCallbackQueryP holds parameters for the answerCallbackQuery method.
// See https://core.telegram.org/bots/api#answercallbackquery // See https://core.telegram.org/bots/api#answercallbackquery
type AnswerCallbackQueryP struct { type AnswerCallbackQueryP struct {
@@ -648,3 +907,11 @@ func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params) req := NewRequest[bool]("answerCallbackQuery", params)
return req.Do(api) return req.Do(api)
} }
// AnswerCallbackQueryWithContext is the context-aware variant of AnswerCallbackQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answercallbackquery
func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQueryP) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
return req.DoWithContext(ctx, api)
}
+80 -46
View File
@@ -1,6 +1,6 @@
package tgapi package tgapi
import "git.nix13.pw/scuroneko/extypes" import "git.scuroneko.dev/scuroneko/extypes"
// MessageID represents a message identifier wrapper returned by some API methods. // MessageID represents a message identifier wrapper returned by some API methods.
type MessageID struct { type MessageID struct {
@@ -45,9 +45,9 @@ type Message struct {
Text string `json:"text"` Text string `json:"text"`
Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"` Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"`
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
Date int `json:"date"` Date int `json:"date"`
EditDate int `json:"edit_date"` EditDate int `json:"edit_date"`
@@ -77,26 +77,46 @@ type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage }
type MessageEntityType string type MessageEntityType string
const ( const (
MessageEntityMention MessageEntityType = "mention" // MessageEntityMention identifies an @mention entity.
MessageEntityHashtag MessageEntityType = "hashtag" MessageEntityMention MessageEntityType = "mention"
MessageEntityCashtag MessageEntityType = "cashtag" // MessageEntityHashtag identifies a hashtag entity.
MessageEntityBotCommand MessageEntityType = "bot_command" MessageEntityHashtag MessageEntityType = "hashtag"
MessageEntityUrl MessageEntityType = "url" // MessageEntityCashtag identifies a cashtag entity.
MessageEntityEmail MessageEntityType = "email" MessageEntityCashtag MessageEntityType = "cashtag"
MessageEntityPhoneNumber MessageEntityType = "phone_number" // MessageEntityBotCommand identifies a bot command entity.
MessageEntityBold MessageEntityType = "bold" MessageEntityBotCommand MessageEntityType = "bot_command"
MessageEntityItalic MessageEntityType = "italic" // MessageEntityUrl identifies a URL entity.
MessageEntityUnderline MessageEntityType = "underline" MessageEntityUrl MessageEntityType = "url"
MessageEntityStrike MessageEntityType = "strikethrough" // MessageEntityEmail identifies an email entity.
MessageEntitySpoiler MessageEntityType = "spoiler" MessageEntityEmail MessageEntityType = "email"
MessageEntityBlockquote MessageEntityType = "blockquote" // MessageEntityPhoneNumber identifies a phone number entity.
MessageEntityPhoneNumber MessageEntityType = "phone_number"
// MessageEntityBold identifies bold text.
MessageEntityBold MessageEntityType = "bold"
// MessageEntityItalic identifies italic text.
MessageEntityItalic MessageEntityType = "italic"
// MessageEntityUnderline identifies underlined text.
MessageEntityUnderline MessageEntityType = "underline"
// MessageEntityStrike identifies strikethrough text.
MessageEntityStrike MessageEntityType = "strikethrough"
// MessageEntitySpoiler identifies spoiler text.
MessageEntitySpoiler MessageEntityType = "spoiler"
// MessageEntityBlockquote identifies a blockquote entity.
MessageEntityBlockquote MessageEntityType = "blockquote"
// MessageEntityExpandableBlockquote identifies an expandable blockquote entity.
MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote" MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote"
MessageEntityCode MessageEntityType = "code" // MessageEntityCode identifies inline code.
MessageEntityPre MessageEntityType = "pre" MessageEntityCode MessageEntityType = "code"
MessageEntityTextLink MessageEntityType = "text_link" // MessageEntityPre identifies a preformatted block.
MessageEntityTextMention MessageEntityType = "text_mention" MessageEntityPre MessageEntityType = "pre"
MessageEntityCustomEmoji MessageEntityType = "custom_emoji" // MessageEntityTextLink identifies linked text.
MessageEntityDateTime MessageEntityType = "date_time" MessageEntityTextLink MessageEntityType = "text_link"
// MessageEntityTextMention identifies a text mention.
MessageEntityTextMention MessageEntityType = "text_mention"
// MessageEntityCustomEmoji identifies a custom emoji entity.
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
// MessageEntityDateTime identifies a date-time entity.
MessageEntityDateTime MessageEntityType = "date_time"
) )
// MessageEntity represents one special entity in a text message. // MessageEntity represents one special entity in a text message.
@@ -121,12 +141,12 @@ type ReplyParameters struct {
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
Quote string `json:"quote,omitempty"` Quote string `json:"quote,omitempty"`
QuoteParsingMode string `json:"quote_parsing_mode,omitempty"` QuoteParsingMode string `json:"quote_parsing_mode,omitempty"`
QuoteEntities []*MessageEntity `json:"quote_entities,omitempty"` QuoteEntities []MessageEntity `json:"quote_entities,omitempty"`
QuotePosition int `json:"quote_position,omitempty"` QuotePosition int `json:"quote_position,omitempty"`
ChecklistTaskID int `json:"checklist_task_id,omitempty"` ChecklistTaskID int `json:"checklist_task_id,omitempty"`
} }
// LinkPreviewOptions describes the options used for link preview generation. // LinkPreviewOptions describes the options used for link preview generation.
@@ -166,8 +186,11 @@ type InlineKeyboardMarkup struct {
type KeyboardButtonStyle string type KeyboardButtonStyle string
const ( const (
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger" // KeyboardButtonStyleDanger marks a destructive keyboard button.
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
// KeyboardButtonStyleSuccess marks a confirmatory keyboard button.
KeyboardButtonStyleSuccess KeyboardButtonStyle = "success" KeyboardButtonStyleSuccess KeyboardButtonStyle = "success"
// KeyboardButtonStylePrimary marks a primary keyboard button.
KeyboardButtonStylePrimary KeyboardButtonStyle = "primary" KeyboardButtonStylePrimary KeyboardButtonStyle = "primary"
) )
@@ -255,32 +278,34 @@ type CallbackQuery struct {
// InputPollOption contains information about one answer option in a poll to be sent. // InputPollOption contains information about one answer option in a poll to be sent.
// See https://core.telegram.org/bots/api#inputpolloption // See https://core.telegram.org/bots/api#inputpolloption
type InputPollOption struct { type InputPollOption struct {
Text string `json:"text"` Text string `json:"text"`
TextParseMode ParseMode `json:"text_parse_mode,omitempty"` TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
TextEntities []*MessageEntity `json:"text_entities,omitempty"` TextEntities []MessageEntity `json:"text_entities,omitempty"`
} }
// PollType represents the type of a poll. // PollType represents the type of a poll.
type PollType string type PollType string
const ( const (
// PollTypeRegular identifies a regular poll.
PollTypeRegular PollType = "regular" PollTypeRegular PollType = "regular"
PollTypeQuiz PollType = "quiz" // PollTypeQuiz identifies a quiz poll.
PollTypeQuiz PollType = "quiz"
) )
// InputChecklistTask describes a task in a checklist. // InputChecklistTask describes a task in a checklist.
type InputChecklistTask struct { type InputChecklistTask struct {
ID int `json:"id"` ID int `json:"id"`
Text string `json:"text"` Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
TextEntities []*MessageEntity `json:"text_entities,omitempty"` TextEntities []MessageEntity `json:"text_entities,omitempty"`
} }
// InputChecklist represents a checklist to be sent. // InputChecklist represents a checklist to be sent.
type InputChecklist struct { type InputChecklist struct {
Title string `json:"title"` Title string `json:"title"`
ParseMode ParseMode `json:"parse_mode,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
TitleEntities []*MessageEntity `json:"title_entities,omitempty"` TitleEntities []MessageEntity `json:"title_entities,omitempty"`
Tasks []InputChecklistTask `json:"tasks"` Tasks []InputChecklistTask `json:"tasks"`
OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"` OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"`
OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"` OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"`
@@ -290,14 +315,23 @@ type InputChecklist struct {
type ChatActionType string type ChatActionType string
const ( const (
ChatActionTyping ChatActionType = "typing" // ChatActionTyping tells Telegram the bot is typing.
ChatActionUploadPhoto ChatActionType = "upload_photo" ChatActionTyping ChatActionType = "typing"
ChatActionUploadVideo ChatActionType = "upload_video" // ChatActionUploadPhoto tells Telegram the bot is uploading a photo.
ChatActionUploadVoice ChatActionType = "upload_voice" ChatActionUploadPhoto ChatActionType = "upload_photo"
ChatActionUploadDocument ChatActionType = "upload_document" // ChatActionUploadVideo tells Telegram the bot is uploading a video.
ChatActionChooseSticker ChatActionType = "choose_sticker" ChatActionUploadVideo ChatActionType = "upload_video"
ChatActionFindLocation ChatActionType = "find_location" // ChatActionUploadVoice tells Telegram the bot is uploading a voice message.
ChatActionUploadVoice ChatActionType = "upload_voice"
// ChatActionUploadDocument tells Telegram the bot is uploading a document.
ChatActionUploadDocument ChatActionType = "upload_document"
// ChatActionChooseSticker tells Telegram the bot is choosing a sticker.
ChatActionChooseSticker ChatActionType = "choose_sticker"
// ChatActionFindLocation tells Telegram the bot is finding a location.
ChatActionFindLocation ChatActionType = "find_location"
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
ChatActionUploadVideoNote ChatActionType = "upload_video_note" ChatActionUploadVideoNote ChatActionType = "upload_video_note"
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
) )
+108 -9
View File
@@ -6,7 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// UpdateParams holds parameters for the getUpdates method. // UpdateParams holds parameters for the getUpdates method.
@@ -25,6 +25,14 @@ func (api *API) GetMe() (User, error) {
return req.Do(api) return req.Do(api)
} }
// GetMeWithContext is the context-aware variant of GetMe.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getme
func (api *API) GetMeWithContext(ctx context.Context) (User, error) {
req := NewRequest[User, EmptyParams]("getMe", NoParams)
return req.DoWithContext(ctx, api)
}
// LogOut logs the bot out from the cloud Bot API server. // LogOut logs the bot out from the cloud Bot API server.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#logout // See https://core.telegram.org/bots/api#logout
@@ -33,14 +41,30 @@ func (api *API) LogOut() (bool, error) {
return req.Do(api) return req.Do(api)
} }
// Close closes the bot instance on the local server. // LogOutWithContext is the context-aware variant of LogOut.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#logout
func (api *API) LogOutWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool, EmptyParams]("logOut", NoParams)
return req.DoWithContext(ctx, api)
}
// CloseRemote closes the bot instance on the local server.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#close // See https://core.telegram.org/bots/api#close
func (api *API) Close() (bool, error) { func (api *API) CloseRemote() (bool, error) {
req := NewRequest[bool, EmptyParams]("close", NoParams) req := NewRequest[bool, EmptyParams]("close", NoParams)
return req.Do(api) return req.Do(api)
} }
// CloseRemoteWithContext is the context-aware variant of CloseRemote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#close
func (api *API) CloseRemoteWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool, EmptyParams]("close", NoParams)
return req.DoWithContext(ctx, api)
}
// GetUpdates receives incoming updates using long polling. // GetUpdates receives incoming updates using long polling.
// See https://core.telegram.org/bots/api#getupdates // See https://core.telegram.org/bots/api#getupdates
func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
@@ -48,16 +72,19 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
return req.Do(api) return req.Do(api)
} }
// GetUpdatesWithContext is the context-aware variant of GetUpdates.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getupdates
func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) ([]Update, error) { func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) ([]Update, error) {
req := NewRequest[[]Update]("getUpdates", params) req := NewRequest[[]Update]("getUpdates", params)
return req.DoWithContext(ctx, api) return req.DoWithContext(ctx, api)
} }
// SetWebhookP holds parameters for the setWebhook method. // SetWebhookP holds parameters for the setWebhook method.
// To upload a self-signed certificate, use Uploader.SetWebhook.
// See https://core.telegram.org/bots/api#setwebhook // See https://core.telegram.org/bots/api#setwebhook
type SetWebhookP struct { type SetWebhookP struct {
URL string `json:"url"` URL string `json:"url"`
Certificate string `json:"certificate,omitempty"`
IPAddress string `json:"ip_address,omitempty"` IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"` MaxConnections int `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
@@ -66,6 +93,7 @@ type SetWebhookP struct {
} }
// SetWebhook sets a webhook URL for incoming updates. // SetWebhook sets a webhook URL for incoming updates.
// For certificate upload, use Uploader.SetWebhook.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setwebhook // See https://core.telegram.org/bots/api#setwebhook
func (api *API) SetWebhook(params SetWebhookP) (bool, error) { func (api *API) SetWebhook(params SetWebhookP) (bool, error) {
@@ -73,6 +101,15 @@ func (api *API) SetWebhook(params SetWebhookP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetWebhookWithContext is the context-aware variant of SetWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// For certificate upload, use Uploader.SetWebhook.
// See https://core.telegram.org/bots/api#setwebhook
func (api *API) SetWebhookWithContext(ctx context.Context, params SetWebhookP) (bool, error) {
req := NewRequest[bool]("setWebhook", params)
return req.DoWithContext(ctx, api)
}
// DeleteWebhookP holds parameters for the deleteWebhook method. // DeleteWebhookP holds parameters for the deleteWebhook method.
// See https://core.telegram.org/bots/api#deletewebhook // See https://core.telegram.org/bots/api#deletewebhook
type DeleteWebhookP struct { type DeleteWebhookP struct {
@@ -87,6 +124,14 @@ func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// DeleteWebhookWithContext is the context-aware variant of DeleteWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletewebhook
func (api *API) DeleteWebhookWithContext(ctx context.Context, params DeleteWebhookP) (bool, error) {
req := NewRequest[bool]("deleteWebhook", params)
return req.DoWithContext(ctx, api)
}
// GetWebhookInfo returns the current webhook status. // GetWebhookInfo returns the current webhook status.
// See https://core.telegram.org/bots/api#getwebhookinfo // See https://core.telegram.org/bots/api#getwebhookinfo
func (api *API) GetWebhookInfo() (WebhookInfo, error) { func (api *API) GetWebhookInfo() (WebhookInfo, error) {
@@ -94,6 +139,14 @@ func (api *API) GetWebhookInfo() (WebhookInfo, error) {
return req.Do(api) return req.Do(api)
} }
// GetWebhookInfoWithContext is the context-aware variant of GetWebhookInfo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getwebhookinfo
func (api *API) GetWebhookInfoWithContext(ctx context.Context) (WebhookInfo, error) {
req := NewRequest[WebhookInfo]("getWebhookInfo", NoParams)
return req.DoWithContext(ctx, api)
}
// GetFileP holds parameters for the getFile method. // GetFileP holds parameters for the getFile method.
// See https://core.telegram.org/bots/api#getfile // See https://core.telegram.org/bots/api#getfile
type GetFileP struct { type GetFileP struct {
@@ -107,17 +160,63 @@ func (api *API) GetFile(params GetFileP) (File, error) {
return req.Do(api) return req.Do(api)
} }
// GetFileWithContext is the context-aware variant of GetFile.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getfile
func (api *API) GetFileWithContext(ctx context.Context, params GetFileP) (File, error) {
req := NewRequest[File]("getFile", params)
return req.DoWithContext(ctx, api)
}
// GetFileByLink downloads a file from Telegram's file server using the provided file link. // GetFileByLink downloads a file from Telegram's file server using the provided file link.
// The link is usually obtained from File.FilePath. // The link is usually obtained from File.FilePath.
// For large files, prefer OpenFileByLink or OpenFileByLinkWithContext to stream the response body.
// See https://core.telegram.org/bots/api#file // See https://core.telegram.org/bots/api#file
func (api *API) GetFileByLink(link string) ([]byte, error) { func (api *API) GetFileByLink(link string) ([]byte, error) {
return api.getFileByLink(context.Background(), link)
}
// GetFileByLinkWithContext is the context-aware variant of GetFileByLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// For large files, prefer OpenFileByLinkWithContext to stream the response body.
// See https://core.telegram.org/bots/api#file
func (api *API) GetFileByLinkWithContext(ctx context.Context, link string) ([]byte, error) {
return api.getFileByLink(ctx, link)
}
// OpenFileByLink opens a streaming response body for a file hosted on Telegram's file server.
// The caller must close the returned ReadCloser.
// See https://core.telegram.org/bots/api#file
func (api *API) OpenFileByLink(link string) (io.ReadCloser, error) {
return api.openFileByLink(context.Background(), link)
}
// OpenFileByLinkWithContext is the context-aware variant of OpenFileByLink.
// The caller must close the returned ReadCloser.
// See https://core.telegram.org/bots/api#file
func (api *API) OpenFileByLinkWithContext(ctx context.Context, link string) (io.ReadCloser, error) {
return api.openFileByLink(ctx, link)
}
func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) {
body, err := api.openFileByLink(ctx, link)
if err != nil {
return nil, err
}
defer func() {
_ = body.Close()
}()
return io.ReadAll(body)
}
func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, error) {
methodPrefix := "" methodPrefix := ""
if api.useTestServer { if api.useTestServer {
methodPrefix = "/test" methodPrefix = "/test"
} }
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link) u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -127,15 +226,15 @@ func (api *API) GetFileByLink(link string) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
defer func() {
_ = res.Body.Close()
}()
body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10)) body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10))
if readErr != nil { if readErr != nil {
return nil, fmt.Errorf("unexpected status %d", res.StatusCode) return nil, fmt.Errorf("unexpected status %d", res.StatusCode)
} }
return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body)) return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body))
} }
return io.ReadAll(res.Body) return res.Body, nil
} }
+44 -6
View File
@@ -27,8 +27,8 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -44,6 +44,44 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
} }
} }
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("streamed payload")),
}, nil
}),
}),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
body, err := api.OpenFileByLink("files/report.txt")
if err != nil {
t.Fatalf("OpenFileByLink returned error: %v", err)
}
defer func() {
if err := body.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if string(data) != "streamed payload" {
t.Fatalf("unexpected payload: %q", string(data))
}
}
func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
client := &http.Client{ client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -60,8 +98,8 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -97,8 +135,8 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
+2 -2
View File
@@ -10,8 +10,8 @@ const (
ParseHTML ParseMode = "HTML" ParseHTML ParseMode = "HTML"
// ParseMD enables legacy Markdown style parsing. // ParseMD enables legacy Markdown style parsing.
ParseMD ParseMode = "Markdown" ParseMD ParseMode = "Markdown"
// ParseNone disables any parsing. // ParseNone disables parse_mode and leaves plain-text requests unannotated.
ParseNone ParseMode = "None" ParseNone ParseMode = ""
) )
// EmptyParams is a placeholder for methods that take no parameters. // EmptyParams is a placeholder for methods that take no parameters.
+37
View File
@@ -0,0 +1,37 @@
package tgapi
import (
"encoding/json"
"strings"
"testing"
)
func TestParseNoneOmitsParseModeInJSON(t *testing.T) {
data, err := json.Marshal(SendMessageP{
ChatID: 42,
Text: "hello",
ParseMode: ParseNone,
})
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if strings.Contains(string(data), `"parse_mode"`) {
t.Fatalf("expected parse_mode to be omitted, got %s", string(data))
}
}
func TestParseModeStillSerializesExplicitModes(t *testing.T) {
data, err := json.Marshal(SendMessageP{
ChatID: 42,
Text: "hello",
ParseMode: ParseMDV2,
})
if err != nil {
t.Fatalf("Marshal returned error: %v", err)
}
if !strings.Contains(string(data), `"parse_mode":"MarkdownV2"`) {
t.Fatalf("expected MarkdownV2 parse_mode, got %s", string(data))
}
}
+10
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method. // SetPassportDataErrorsP holds parameters for the setPassportDataErrors method.
// See https://core.telegram.org/bots/api#setpassportdataerrors // See https://core.telegram.org/bots/api#setpassportdataerrors
type SetPassportDataErrorsP struct { type SetPassportDataErrorsP struct {
@@ -14,3 +16,11 @@ func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, erro
req := NewRequest[bool]("setPassportDataErrors", params) req := NewRequest[bool]("setPassportDataErrors", params)
return req.Do(api) return req.Do(api)
} }
// SetPassportDataErrorsWithContext is the context-aware variant of SetPassportDataErrors.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setpassportdataerrors
func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrorsP) (bool, error) {
req := NewRequest[bool]("setPassportDataErrors", params)
return req.DoWithContext(ctx, api)
}
+37 -4
View File
@@ -1,12 +1,13 @@
package tgapi package tgapi
import "context"
// SendInvoiceP holds parameters for the sendInvoice method. // SendInvoiceP holds parameters for the sendInvoice method.
// See https://core.telegram.org/bots/api#sendinvoice // See https://core.telegram.org/bots/api#sendinvoice
type SendInvoiceP struct { type SendInvoiceP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"`
ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"`
MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
@@ -47,6 +48,14 @@ func (api *API) SendInvoice(params SendInvoiceP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendInvoiceWithContext is the context-aware variant of SendInvoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendinvoice
func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoiceP) (Message, error) {
req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateInvoiceLinkP holds parameters for the createInvoiceLink method. // CreateInvoiceLinkP holds parameters for the createInvoiceLink method.
// See https://core.telegram.org/bots/api#createinvoicelink // See https://core.telegram.org/bots/api#createinvoicelink
type CreateInvoiceLinkP struct { type CreateInvoiceLinkP struct {
@@ -83,6 +92,14 @@ func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) {
return req.Do(api) return req.Do(api)
} }
// CreateInvoiceLinkWithContext is the context-aware variant of CreateInvoiceLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createinvoicelink
func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateInvoiceLinkP) (string, error) {
req := NewRequest[string]("createInvoiceLink", params)
return req.DoWithContext(ctx, api)
}
// AnswerShippingQueryP holds parameters for the answerShippingQuery method. // AnswerShippingQueryP holds parameters for the answerShippingQuery method.
// See https://core.telegram.org/bots/api#answershippingquery // See https://core.telegram.org/bots/api#answershippingquery
type AnswerShippingQueryP struct { type AnswerShippingQueryP struct {
@@ -100,6 +117,14 @@ func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// AnswerShippingQueryWithContext is the context-aware variant of AnswerShippingQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answershippingquery
func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params AnswerShippingQueryP) (bool, error) {
req := NewRequest[bool]("answerShippingQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method. // AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method.
// See https://core.telegram.org/bots/api#answerprecheckoutquery // See https://core.telegram.org/bots/api#answerprecheckoutquery
type AnswerPreCheckoutQueryP struct { type AnswerPreCheckoutQueryP struct {
@@ -115,3 +140,11 @@ func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, er
req := NewRequest[bool]("answerPreCheckoutQuery", params) req := NewRequest[bool]("answerPreCheckoutQuery", params)
return req.Do(api) return req.Do(api)
} }
// AnswerPreCheckoutQueryWithContext is the context-aware variant of AnswerPreCheckoutQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answerprecheckoutquery
func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params AnswerPreCheckoutQueryP) (bool, error) {
req := NewRequest[bool]("answerPreCheckoutQuery", params)
return req.DoWithContext(ctx, api)
}
+22 -62
View File
@@ -5,44 +5,35 @@ import (
"sync" "sync"
) )
// workerPool — приватная структура, управляющая пулом воркеров.
// Внешний код не может создавать или напрямую взаимодействовать с этой структурой.
// Используется только через экспортируемые методы newWorkerPool, start, stop, submit.
type workerPool struct { type workerPool struct {
taskCh chan requestEnvelope // канал для принятия задач (буферизованный) taskCh chan requestEnvelope
queueSize int // максимальный размер очереди queueSize int
workers int // количество воркеров (горутин) workers int
wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке wg sync.WaitGroup
quit chan struct{} // канал для сигнала остановки quit chan struct{}
stopOnce sync.Once // гарантирует идемпотентную остановку пула stopOnce sync.Once
started bool // флаг, указывающий, запущен ли пул started bool
stopped bool // флаг, указывающий, что пул остановлен stopped bool
startedMu sync.Mutex // мьютекс для безопасного доступа к started startedMu sync.Mutex
} }
// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата.
// Используется только внутри пакета для передачи задач воркерам.
type requestEnvelope struct { type requestEnvelope struct {
ctx context.Context // контекст конкретной задачи ctx context.Context
doFunc func(context.Context) (any, error) // функция, выполняющая запрос doFunc func(context.Context) (any, error)
resultCh chan requestResult // канал, через который воркер вернёт результат resultCh chan requestResult
} }
// requestResult — приватная структура, представляющая результат выполнения задачи.
// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult.
type requestResult struct { type requestResult struct {
value any // значение, возвращённое задачей value any
err error // ошибка, если возникла err error
} }
// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди.
// Это единственный способ создать workerPool — внешний код не может создать его напрямую.
func newWorkerPool(workers int, queueSize int) *workerPool { func newWorkerPool(workers int, queueSize int) *workerPool {
if workers <= 0 { if workers <= 0 {
workers = 1 // защита от некорректных значений workers = 1
} }
if queueSize <= 0 { if queueSize <= 0 {
queueSize = 100 // разумный дефолт queueSize = 100
} }
return &workerPool{ return &workerPool{
@@ -53,43 +44,32 @@ func newWorkerPool(workers int, queueSize int) *workerPool {
} }
} }
// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди.
// Метод идемпотентен: если пул уже запущен — ничего не делает.
// Должен вызываться перед первым вызовом submit.
func (p *workerPool) start() { func (p *workerPool) start() {
p.startedMu.Lock() p.startedMu.Lock()
defer p.startedMu.Unlock() defer p.startedMu.Unlock()
if p.started { if p.started {
return // уже запущен — ничего не делаем return
} }
p.started = true p.started = true
// Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле
for i := 0; i < p.workers; i++ { for i := 0; i < p.workers; i++ {
p.wg.Add(1) p.wg.Add(1)
go p.worker() // запускаем горутину go p.worker()
} }
} }
// stop останавливает пул воркеров.
// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач.
// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта.
func (p *workerPool) stop() { func (p *workerPool) stop() {
p.stopOnce.Do(func() { p.stopOnce.Do(func() {
p.startedMu.Lock() p.startedMu.Lock()
p.stopped = true p.stopped = true
p.started = false p.started = false
close(p.quit) // сигнал для всех воркеров — выйти из цикла close(p.quit)
p.startedMu.Unlock() p.startedMu.Unlock()
p.wg.Wait() // ждём, пока все воркеры завершатся p.wg.Wait()
}) })
} }
// submit отправляет задачу в очередь и возвращает канал, через который будет получен результат.
// Если очередь переполнена — возвращает ErrPoolQueueFull.
// Канал результата имеет буфер 1, чтобы не блокировать воркера при записи.
// Контекст используется для отмены задачи, если клиент отменил запрос до отправки.
func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) { func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) {
p.startedMu.Lock() p.startedMu.Lock()
if p.stopped || !p.started { if p.stopped || !p.started {
@@ -97,55 +77,39 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any,
return nil, ErrPoolStopped return nil, ErrPoolStopped
} }
// Проверяем, не превышена ли очередь
if len(p.taskCh) >= p.queueSize { if len(p.taskCh) >= p.queueSize {
p.startedMu.Unlock() p.startedMu.Unlock()
return nil, ErrPoolQueueFull return nil, ErrPoolQueueFull
} }
// Создаём канал для результата — буферизованный, чтобы не блокировать воркера
resultCh := make(chan requestResult, 1) resultCh := make(chan requestResult, 1)
// Создаём обёртку задачи
envelope := requestEnvelope{ envelope := requestEnvelope{
ctx: ctx, ctx: ctx,
doFunc: do, doFunc: do,
resultCh: resultCh, resultCh: resultCh,
} }
// Пытаемся отправить задачу в очередь
select { select {
case <-ctx.Done(): case <-ctx.Done():
p.startedMu.Unlock() p.startedMu.Unlock()
// Клиент отменил операцию до отправки — возвращаем ошибку отмены
return nil, ctx.Err() return nil, ctx.Err()
case p.taskCh <- envelope: case p.taskCh <- envelope:
p.startedMu.Unlock() p.startedMu.Unlock()
// Успешно отправлено — возвращаем канал для чтения результата
return resultCh, nil return resultCh, nil
default: default:
p.startedMu.Unlock() p.startedMu.Unlock()
// Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай
return nil, ErrPoolQueueFull return nil, ErrPoolQueueFull
} }
} }
// worker — приватная горутина, выполняющая задачи из очереди.
// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки.
// При получении задачи:
// - вызывает doFunc с контекстом
// - записывает результат в resultCh
// - закрывает канал, чтобы клиент мог прочитать и завершить
//
// После закрытия quit-канала — воркер завершает работу.
func (p *workerPool) worker() { func (p *workerPool) worker() {
defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины defer p.wg.Done()
for { for {
select { select {
case <-p.quit: case <-p.quit:
// Получен сигнал остановки — дренируем очередь и выходим. // Drain queued work after stop. No new tasks are accepted.
// После stop() новые задачи не принимаются.
for { for {
select { select {
case envelope := <-p.taskCh: case envelope := <-p.taskCh:
@@ -162,14 +126,10 @@ func (p *workerPool) worker() {
} }
func (p *workerPool) executeEnvelope(envelope requestEnvelope) { func (p *workerPool) executeEnvelope(envelope requestEnvelope) {
// Выполняем задачу с переданным контекстом (клиентский или общий)
value, err := envelope.doFunc(envelope.ctx) value, err := envelope.doFunc(envelope.ctx)
// Записываем результат в канал — не блокируем, т.к. буфер 1
envelope.resultCh <- requestResult{ envelope.resultCh <- requestResult{
value: value, value: value,
err: err, err: err,
} }
// Закрываем канал — клиент знает, что результат пришёл и больше не будет
close(envelope.resultCh) close(envelope.resultCh)
} }
+62
View File
@@ -0,0 +1,62 @@
package tgapi
import (
"context"
"errors"
"testing"
)
func TestWorkerPoolSubmitAfterStop(t *testing.T) {
pool := newWorkerPool(1, 1)
pool.start()
pool.stop()
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return nil, nil
}); !errors.Is(err, ErrPoolStopped) {
t.Fatalf("expected ErrPoolStopped, got %v", err)
}
}
func TestWorkerPoolQueueFull(t *testing.T) {
pool := newWorkerPool(1, 1)
pool.start()
defer pool.stop()
started := make(chan struct{})
release := make(chan struct{})
firstResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
close(started)
<-release
return "first", nil
})
if err != nil {
t.Fatalf("first submit returned error: %v", err)
}
<-started
secondResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return "second", nil
})
if err != nil {
t.Fatalf("second submit returned error: %v", err)
}
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return "third", nil
}); !errors.Is(err, ErrPoolQueueFull) {
t.Fatalf("expected ErrPoolQueueFull, got %v", err)
}
close(release)
first := <-firstResult
if first.err != nil || first.value != "first" {
t.Fatalf("unexpected first result: %+v", first)
}
second := <-secondResult
if second.err != nil || second.value != "second" {
t.Fatalf("unexpected second result: %+v", second)
}
}
+34
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// GetStarTransactionsP holds parameters for the getStarTransactions method. // GetStarTransactionsP holds parameters for the getStarTransactions method.
// See https://core.telegram.org/bots/api#getstartransactions // See https://core.telegram.org/bots/api#getstartransactions
type GetStarTransactionsP struct { type GetStarTransactionsP struct {
@@ -14,6 +16,14 @@ func (api *API) GetMyStarBalance() (StarAmount, error) {
return req.Do(api) return req.Do(api)
} }
// GetMyStarBalanceWithContext is the context-aware variant of GetMyStarBalance.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmystarbalance
func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, error) {
req := NewRequest[StarAmount]("getMyStarBalance", NoParams)
return req.DoWithContext(ctx, api)
}
// GetStarTransactions returns Telegram Star transactions for the bot. // GetStarTransactions returns Telegram Star transactions for the bot.
// See https://core.telegram.org/bots/api#getstartransactions // See https://core.telegram.org/bots/api#getstartransactions
func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) { func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) {
@@ -21,6 +31,14 @@ func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactio
return req.Do(api) return req.Do(api)
} }
// GetStarTransactionsWithContext is the context-aware variant of GetStarTransactions.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getstartransactions
func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetStarTransactionsP) (StarTransactions, error) {
req := NewRequest[StarTransactions]("getStarTransactions", params)
return req.DoWithContext(ctx, api)
}
// RefundStarPaymentP holds parameters for the refundStarPayment method. // RefundStarPaymentP holds parameters for the refundStarPayment method.
// See https://core.telegram.org/bots/api#refundstarpayment // See https://core.telegram.org/bots/api#refundstarpayment
type RefundStarPaymentP struct { type RefundStarPaymentP struct {
@@ -36,6 +54,14 @@ func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// RefundStarPaymentWithContext is the context-aware variant of RefundStarPayment.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#refundstarpayment
func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundStarPaymentP) (bool, error) {
req := NewRequest[bool]("refundStarPayment", params)
return req.DoWithContext(ctx, api)
}
// EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method. // EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method.
// See https://core.telegram.org/bots/api#edituserstarsubscription // See https://core.telegram.org/bots/api#edituserstarsubscription
type EditUserStarSubscriptionP struct { type EditUserStarSubscriptionP struct {
@@ -51,3 +77,11 @@ func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool
req := NewRequest[bool]("editUserStarSubscription", params) req := NewRequest[bool]("editUserStarSubscription", params)
return req.Do(api) return req.Do(api)
} }
// EditUserStarSubscriptionWithContext is the context-aware variant of EditUserStarSubscription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#edituserstarsubscription
func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscriptionP) (bool, error) {
req := NewRequest[bool]("editUserStarSubscription", params)
return req.DoWithContext(ctx, api)
}
+138
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// SendStickerP holds parameters for the sendSticker method. // SendStickerP holds parameters for the sendSticker method.
// See https://core.telegram.org/bots/api#sendsticker // See https://core.telegram.org/bots/api#sendsticker
type SendStickerP struct { type SendStickerP struct {
@@ -14,6 +16,10 @@ type SendStickerP struct {
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"`
MessageEffectID string `json:"message_effect_id,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"`
SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"`
ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
} }
// SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker. // SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker.
@@ -23,6 +29,14 @@ func (api *API) SendSticker(params SendStickerP) (Message, error) {
return req.Do(api) return req.Do(api)
} }
// SendStickerWithContext is the context-aware variant of SendSticker.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendsticker
func (api *API) SendStickerWithContext(ctx context.Context, params SendStickerP) (Message, error) {
req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetStickerSetP holds parameters for the getStickerSet method. // GetStickerSetP holds parameters for the getStickerSet method.
// See https://core.telegram.org/bots/api#getstickerset // See https://core.telegram.org/bots/api#getstickerset
type GetStickerSetP struct { type GetStickerSetP struct {
@@ -36,6 +50,14 @@ func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) {
return req.Do(api) return req.Do(api)
} }
// GetStickerSetWithContext is the context-aware variant of GetStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getstickerset
func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerSetP) (StickerSet, error) {
req := NewRequest[StickerSet]("getStickerSet", params)
return req.DoWithContext(ctx, api)
}
// GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method. // GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method.
// See https://core.telegram.org/bots/api#getcustomemojistickers // See https://core.telegram.org/bots/api#getcustomemojistickers
type GetCustomEmojiStickersP struct { type GetCustomEmojiStickersP struct {
@@ -49,6 +71,14 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke
return req.Do(api) return req.Do(api)
} }
// GetCustomEmojiStickersWithContext is the context-aware variant of GetCustomEmojiStickers.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getcustomemojistickers
func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params GetCustomEmojiStickersP) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getCustomEmojiStickers", params)
return req.DoWithContext(ctx, api)
}
// UploadStickerFileP holds parameters for the uploadStickerFile method. // UploadStickerFileP holds parameters for the uploadStickerFile method.
// See https://core.telegram.org/bots/api#uploadstickerfile // See https://core.telegram.org/bots/api#uploadstickerfile
type UploadStickerFileP struct { type UploadStickerFileP struct {
@@ -68,6 +98,18 @@ func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFil
return req.Do(uploader) return req.Do(uploader)
} }
// UploadStickerFileWithContext is the context-aware variant of UploadStickerFile.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#uploadstickerfile
func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadStickerFileP, sticker UploaderFile) (File, error) {
uploader := NewUploader(api)
defer func() {
_ = uploader.Close()
}()
req := NewUploaderRequest[File]("uploadStickerFile", params, sticker.SetType(UploaderStickerType))
return req.DoWithContext(ctx, uploader)
}
// CreateNewStickerSetP holds parameters for the createNewStickerSet method. // CreateNewStickerSetP holds parameters for the createNewStickerSet method.
// See https://core.telegram.org/bots/api#createnewstickerset // See https://core.telegram.org/bots/api#createnewstickerset
type CreateNewStickerSetP struct { type CreateNewStickerSetP struct {
@@ -88,6 +130,14 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// CreateNewStickerSetWithContext is the context-aware variant of CreateNewStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#createnewstickerset
func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params CreateNewStickerSetP) (bool, error) {
req := NewRequest[bool]("createNewStickerSet", params)
return req.DoWithContext(ctx, api)
}
// AddStickerToSetP holds parameters for the addStickerToSet method. // AddStickerToSetP holds parameters for the addStickerToSet method.
// See https://core.telegram.org/bots/api#addstickertoset // See https://core.telegram.org/bots/api#addstickertoset
type AddStickerToSetP struct { type AddStickerToSetP struct {
@@ -104,6 +154,14 @@ func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// AddStickerToSetWithContext is the context-aware variant of AddStickerToSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#addstickertoset
func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddStickerToSetP) (bool, error) {
req := NewRequest[bool]("addStickerToSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method. // SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method.
// See https://core.telegram.org/bots/api#setstickerpositioninset // See https://core.telegram.org/bots/api#setstickerpositioninset
type SetStickerPositionInSetP struct { type SetStickerPositionInSetP struct {
@@ -119,6 +177,14 @@ func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool,
return req.Do(api) return req.Do(api)
} }
// SetStickerPositionInSetWithContext is the context-aware variant of SetStickerPositionInSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickerpositioninset
func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params SetStickerPositionInSetP) (bool, error) {
req := NewRequest[bool]("setStickerPositionInSet", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method. // DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method.
// See https://core.telegram.org/bots/api#deletestickerfromset // See https://core.telegram.org/bots/api#deletestickerfromset
type DeleteStickerFromSetP struct { type DeleteStickerFromSetP struct {
@@ -133,6 +199,14 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error)
return req.Do(api) return req.Do(api)
} }
// DeleteStickerFromSetWithContext is the context-aware variant of DeleteStickerFromSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestickerfromset
func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params DeleteStickerFromSetP) (bool, error) {
req := NewRequest[bool]("deleteStickerFromSet", params)
return req.DoWithContext(ctx, api)
}
// ReplaceStickerInSetP holds parameters for the replaceStickerInSet method. // ReplaceStickerInSetP holds parameters for the replaceStickerInSet method.
// See https://core.telegram.org/bots/api#replacestickerinset // See https://core.telegram.org/bots/api#replacestickerinset
type ReplaceStickerInSetP struct { type ReplaceStickerInSetP struct {
@@ -150,6 +224,14 @@ func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// ReplaceStickerInSetWithContext is the context-aware variant of ReplaceStickerInSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#replacestickerinset
func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params ReplaceStickerInSetP) (bool, error) {
req := NewRequest[bool]("replaceStickerInSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerEmojiListP holds parameters for the setStickerEmojiList method. // SetStickerEmojiListP holds parameters for the setStickerEmojiList method.
// See https://core.telegram.org/bots/api#setstickeremojilist // See https://core.telegram.org/bots/api#setstickeremojilist
type SetStickerEmojiListP struct { type SetStickerEmojiListP struct {
@@ -165,6 +247,14 @@ func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetStickerEmojiListWithContext is the context-aware variant of SetStickerEmojiList.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickeremojilist
func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetStickerEmojiListP) (bool, error) {
req := NewRequest[bool]("setStickerEmojiList", params)
return req.DoWithContext(ctx, api)
}
// SetStickerKeywordsP holds parameters for the setStickerKeywords method. // SetStickerKeywordsP holds parameters for the setStickerKeywords method.
// See https://core.telegram.org/bots/api#setstickerkeywords // See https://core.telegram.org/bots/api#setstickerkeywords
type SetStickerKeywordsP struct { type SetStickerKeywordsP struct {
@@ -180,6 +270,14 @@ func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetStickerKeywordsWithContext is the context-aware variant of SetStickerKeywords.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickerkeywords
func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetStickerKeywordsP) (bool, error) {
req := NewRequest[bool]("setStickerKeywords", params)
return req.DoWithContext(ctx, api)
}
// SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method. // SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method.
// See https://core.telegram.org/bots/api#setstickermaskposition // See https://core.telegram.org/bots/api#setstickermaskposition
type SetStickerMaskPositionP struct { type SetStickerMaskPositionP struct {
@@ -195,6 +293,14 @@ func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, er
return req.Do(api) return req.Do(api)
} }
// SetStickerMaskPositionWithContext is the context-aware variant of SetStickerMaskPosition.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickermaskposition
func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params SetStickerMaskPositionP) (bool, error) {
req := NewRequest[bool]("setStickerMaskPosition", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetTitleP holds parameters for the setStickerSetTitle method. // SetStickerSetTitleP holds parameters for the setStickerSetTitle method.
// See https://core.telegram.org/bots/api#setstickersettitle // See https://core.telegram.org/bots/api#setstickersettitle
type SetStickerSetTitleP struct { type SetStickerSetTitleP struct {
@@ -210,6 +316,14 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetStickerSetTitleWithContext is the context-aware variant of SetStickerSetTitle.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickersettitle
func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetStickerSetTitleP) (bool, error) {
req := NewRequest[bool]("setStickerSetTitle", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method. // SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setstickersetthumbnail // See https://core.telegram.org/bots/api#setstickersetthumbnail
type SetStickerSetThumbnailP struct { type SetStickerSetThumbnailP struct {
@@ -227,6 +341,14 @@ func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, er
return req.Do(api) return req.Do(api)
} }
// SetStickerSetThumbnailWithContext is the context-aware variant of SetStickerSetThumbnail.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setstickersetthumbnail
func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params SetStickerSetThumbnailP) (bool, error) {
req := NewRequest[bool]("setStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method. // SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
type SetCustomEmojiStickerSetThumbnailP struct { type SetCustomEmojiStickerSetThumbnailP struct {
@@ -242,6 +364,14 @@ func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSe
return req.Do(api) return req.Do(api)
} }
// SetCustomEmojiStickerSetThumbnailWithContext is the context-aware variant of SetCustomEmojiStickerSetThumbnail.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context, params SetCustomEmojiStickerSetThumbnailP) (bool, error) {
req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerSetP holds parameters for the deleteStickerSet method. // DeleteStickerSetP holds parameters for the deleteStickerSet method.
// See https://core.telegram.org/bots/api#deletestickerset // See https://core.telegram.org/bots/api#deletestickerset
type DeleteStickerSetP struct { type DeleteStickerSetP struct {
@@ -255,3 +385,11 @@ func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) {
req := NewRequest[bool]("deleteStickerSet", params) req := NewRequest[bool]("deleteStickerSet", params)
return req.Do(api) return req.Do(api)
} }
// DeleteStickerSetWithContext is the context-aware variant of DeleteStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestickerset
func (api *API) DeleteStickerSetWithContext(ctx context.Context, params DeleteStickerSetP) (bool, error) {
req := NewRequest[bool]("deleteStickerSet", params)
return req.DoWithContext(ctx, api)
}
+83 -40
View File
@@ -6,6 +6,9 @@ import "encoding/json"
type UpdateType string type UpdateType string
const ( const (
// UpdateTypeUnknown marks an update whose payload does not match a known Telegram update kind.
UpdateTypeUnknown UpdateType = "unknown"
// UpdateTypeMessage is a regular message update. // UpdateTypeMessage is a regular message update.
UpdateTypeMessage UpdateType = "message" UpdateTypeMessage UpdateType = "message"
// UpdateTypeEditedMessage is an edited message update. // UpdateTypeEditedMessage is an edited message update.
@@ -27,8 +30,6 @@ const (
UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message" UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message"
// UpdateTypeDeletedBusinessMessages is a deleted business messages update. // UpdateTypeDeletedBusinessMessages is a deleted business messages update.
UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages" UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages"
// UpdateTypeDeletedBusinessMessage is kept as a backward-compatible alias.
UpdateTypeDeletedBusinessMessage UpdateType = UpdateTypeDeletedBusinessMessages
// UpdateTypeInlineQuery is an inline query update. // UpdateTypeInlineQuery is an inline query update.
UpdateTypeInlineQuery UpdateType = "inline_query" UpdateTypeInlineQuery UpdateType = "inline_query"
@@ -61,6 +62,8 @@ const (
// Update represents an incoming update from Telegram. // Update represents an incoming update from Telegram.
// See https://core.telegram.org/bots/api#update // See https://core.telegram.org/bots/api#update
type Update struct { type Update struct {
Type UpdateType `json:"-"`
UpdateID int `json:"update_id"` UpdateID int `json:"update_id"`
Message *Message `json:"message,omitempty"` Message *Message `json:"message,omitempty"`
EditedMessage *Message `json:"edited_message,omitempty"` EditedMessage *Message `json:"edited_message,omitempty"`
@@ -71,7 +74,6 @@ type Update struct {
BusinessMessage *Message `json:"business_message,omitempty"` BusinessMessage *Message `json:"business_message,omitempty"`
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"`
DeletedBusinessMessage *BusinessMessagesDeleted `json:"-"`
MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
@@ -91,33 +93,72 @@ type Update struct {
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"`
} }
func (u *Update) syncDeletedBusinessMessages() { // UnmarshalJSON decodes an update and derives its Type from the populated payload field.
if u.DeletedBusinessMessages != nil {
u.DeletedBusinessMessage = u.DeletedBusinessMessages
return
}
if u.DeletedBusinessMessage != nil {
u.DeletedBusinessMessages = u.DeletedBusinessMessage
}
}
// UnmarshalJSON keeps the deprecated DeletedBusinessMessage alias in sync.
func (u *Update) UnmarshalJSON(data []byte) error { func (u *Update) UnmarshalJSON(data []byte) error {
type alias Update type Alias Update
var aux alias
var aux Alias
if err := json.Unmarshal(data, &aux); err != nil { if err := json.Unmarshal(data, &aux); err != nil {
return err return err
} }
*u = Update(aux)
u.syncDeletedBusinessMessages()
return nil
}
// MarshalJSON emits the canonical deleted_business_messages field. *u = Update(aux)
func (u Update) MarshalJSON() ([]byte, error) {
u.syncDeletedBusinessMessages() switch {
type alias Update case u.Message != nil:
return json.Marshal(alias(u)) u.Type = UpdateTypeMessage
case u.EditedMessage != nil:
u.Type = UpdateTypeEditedMessage
case u.ChannelPost != nil:
u.Type = UpdateTypeChannelPost
case u.EditedChannelPost != nil:
u.Type = UpdateTypeEditedChannelPost
case u.BusinessConnection != nil:
u.Type = UpdateTypeBusinessConnection
case u.BusinessMessage != nil:
u.Type = UpdateTypeBusinessMessage
case u.EditedBusinessMessage != nil:
u.Type = UpdateTypeEditedBusinessMessage
case u.DeletedBusinessMessages != nil:
u.Type = UpdateTypeDeletedBusinessMessages
case u.MessageReaction != nil:
u.Type = UpdateTypeMessageReaction
case u.MessageReactionCount != nil:
u.Type = UpdateTypeMessageReactionCount
case u.InlineQuery != nil:
u.Type = UpdateTypeInlineQuery
case u.ChosenInlineResult != nil:
u.Type = UpdateTypeChosenInlineResult
case u.CallbackQuery != nil:
u.Type = UpdateTypeCallbackQuery
case u.ShippingQuery != nil:
u.Type = UpdateTypeShippingQuery
case u.PreCheckoutQuery != nil:
u.Type = UpdateTypePreCheckoutQuery
case u.PurchasedPaidMedia != nil:
u.Type = UpdateTypePurchasedPaidMedia
case u.Poll != nil:
u.Type = UpdateTypePoll
case u.PollAnswer != nil:
u.Type = UpdateTypePollAnswer
case u.MyChatMember != nil:
u.Type = UpdateTypeMyChatMember
case u.ChatMember != nil:
u.Type = UpdateTypeChatMember
case u.ChatJoinRequest != nil:
u.Type = UpdateTypeChatJoinRequest
case u.ChatBoost != nil:
u.Type = UpdateTypeChatBoost
case u.RemovedChatBoost != nil:
u.Type = UpdateTypeRemovedChatBoost
default:
u.Type = UpdateTypeUnknown
}
return nil
} }
// InlineQuery represents an incoming inline query. // InlineQuery represents an incoming inline query.
@@ -351,19 +392,19 @@ type GiftBackground struct {
// Gift represents a gift that can be sent. // Gift represents a gift that can be sent.
type Gift struct { type Gift struct {
ID string `json:"id"` ID string `json:"id"`
Sticker Sticker `json:"sticker"` Sticker Sticker `json:"sticker"`
StarCount int `json:"star_count"` StarCount int `json:"star_count"`
UpdateStarCount *int `json:"update_star_count,omitempty"` UpdateStarCount *int `json:"update_star_count,omitempty"`
IsPremium *bool `json:"is_premium,omitempty"` IsPremium *bool `json:"is_premium,omitempty"`
HasColors *bool `json:"has_colors,omitempty"` HasColors *bool `json:"has_colors,omitempty"`
TotalCount *int `json:"total_count,omitempty"` TotalCount *int `json:"total_count,omitempty"`
RemainingCount *int `json:"remaining_count,omitempty"` RemainingCount *int `json:"remaining_count,omitempty"`
PersonalTotalCount *int `json:"personal_total_count,omitempty"` PersonalTotalCount *int `json:"personal_total_count,omitempty"`
PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"` PersonalRemainingCount *int `json:"personal_remaining_count,omitempty"`
Background GiftBackground `json:"background,omitempty"` Background *GiftBackground `json:"background,omitempty"`
UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"` UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"`
PublisherChat *Chat `json:"publisher_chat,omitempty"` PublisherChat *Chat `json:"publisher_chat,omitempty"`
} }
// Gifts represents a list of gifts. // Gifts represents a list of gifts.
@@ -375,8 +416,10 @@ type Gifts struct {
type OwnedGiftType string type OwnedGiftType string
const ( const (
// OwnedGiftRegularType identifies a regular owned gift.
OwnedGiftRegularType OwnedGiftType = "regular" OwnedGiftRegularType OwnedGiftType = "regular"
OwnedGiftUniqueType OwnedGiftType = "unique" // OwnedGiftUniqueType identifies a unique owned gift.
OwnedGiftUniqueType OwnedGiftType = "unique"
) )
// OwnedGift represents a gift owned by a user or chat. // OwnedGift represents a gift owned by a user or chat.
@@ -388,7 +431,7 @@ type OwnedGift struct {
// Fields specific to "regular" type // Fields specific to "regular" type
Gift Gift `json:"gift"` Gift Gift `json:"gift"`
SenderUser User `json:"sender_user,omitempty"` SenderUser *User `json:"sender_user,omitempty"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"` Entities []MessageEntity `json:"entities,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"` IsPrivate *bool `json:"is_private,omitempty"`
+80 -33
View File
@@ -6,41 +6,88 @@ import (
"testing" "testing"
) )
func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) { func TestUpdateUnmarshalSetsType(t *testing.T) {
var update Update tests := []struct {
err := json.Unmarshal([]byte(`{ name string
"update_id": 1, body string
"deleted_business_messages": { want UpdateType
"business_connection_id": "conn", }{
"chat": {"id": 42, "type": "private"}, {
"message_ids": [3, 5] name: "deleted business messages",
} body: `{
}`), &update) "update_id": 1,
if err != nil { "deleted_business_messages": {
t.Fatalf("Unmarshal returned error: %v", err) "business_connection_id": "conn",
"chat": {"id": 42, "type": "private"},
"message_ids": [3, 5]
}
}`,
want: UpdateTypeDeletedBusinessMessages,
},
{
name: "callback query",
body: `{
"update_id": 2,
"callback_query": {
"id": "cb",
"from": {"id": 1, "is_bot": false, "first_name": "Test"},
"chat_instance": "instance",
"data": "payload"
}
}`,
want: UpdateTypeCallbackQuery,
},
{
name: "chat boost",
body: `{
"update_id": 3,
"chat_boost": {
"chat": {"id": -1001, "type": "supergroup", "title": "Boosted"},
"boost": {
"boost_id": "boost-1",
"add_date": 1735689600,
"expiration_date": 1738291600,
"source": {
"source": "premium",
"user": {"id": 1, "is_bot": false, "first_name": "Test"}
}
}
}
}`,
want: UpdateTypeChatBoost,
},
{
name: "unknown",
body: `{"update_id":4}`,
want: UpdateTypeUnknown,
},
} }
if update.DeletedBusinessMessages == nil { for _, tt := range tests {
t.Fatal("expected DeletedBusinessMessages to be populated") t.Run(tt.name, func(t *testing.T) {
} var update Update
if update.DeletedBusinessMessage == nil { if err := json.Unmarshal([]byte(tt.body), &update); err != nil {
t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated") t.Fatalf("Unmarshal returned error: %v", err)
} }
if update.DeletedBusinessMessages != update.DeletedBusinessMessage { if update.Type != tt.want {
t.Fatal("expected deleted business message fields to share the same payload") t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
} }
if got := update.DeletedBusinessMessages.MessageIDs; len(got) != 2 || got[0] != 3 || got[1] != 5 { if tt.want == UpdateTypeChatBoost && update.ChatBoost.Boost.BoostID != "boost-1" {
t.Fatalf("unexpected message ids: %v", got) t.Fatalf("unexpected boost id: got %q want %q", update.ChatBoost.Boost.BoostID, "boost-1")
}
})
} }
} }
func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) { func TestUpdateMarshalOmitsSyntheticTypeField(t *testing.T) {
update := Update{ update := Update{
UpdateID: 1, UpdateID: 1,
DeletedBusinessMessage: &BusinessMessagesDeleted{ Type: UpdateTypeCallbackQuery,
BusinessConnectionID: "conn", CallbackQuery: &CallbackQuery{
Chat: Chat{ID: 42, Type: string(ChatTypePrivate)}, ID: "cb",
MessageIDs: []int{7}, From: User{ID: 1, FirstName: "Test"},
ChatInstance: "instance",
Data: "payload",
}, },
} }
@@ -50,11 +97,8 @@ func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
} }
got := string(data) got := string(data)
if !strings.Contains(got, `"deleted_business_messages"`) { if strings.Contains(got, `"type"`) {
t.Fatalf("expected canonical deleted_business_messages field, got %s", got) t.Fatalf("unexpected synthetic type field, got %s", got)
}
if strings.Contains(got, `"deleted_business_message"`) {
t.Fatalf("unexpected singular deleted_business_message field, got %s", got)
} }
} }
@@ -66,4 +110,7 @@ func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) {
if update.ShippingQuery != nil { if update.ShippingQuery != nil {
t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery) t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery)
} }
if update.Type != UpdateTypeUnknown {
t.Fatalf("expected UpdateTypeUnknown, got %q", update.Type)
}
} }
+18 -13
View File
@@ -7,10 +7,11 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
const ( const (
@@ -30,6 +31,8 @@ const (
UploaderThumbnailType UploaderFileType = "thumbnail" UploaderThumbnailType UploaderFileType = "thumbnail"
// UploaderStickerType is the multipart field name for sticker uploads. // UploaderStickerType is the multipart field name for sticker uploads.
UploaderStickerType UploaderFileType = "sticker" UploaderStickerType UploaderFileType = "sticker"
// UploaderCertificateType is the multipart field name for webhook certificate uploads.
UploaderCertificateType UploaderFileType = "certificate"
) )
// UploaderFileType represents the Telegram form field name for a file upload. // UploaderFileType represents the Telegram form field name for a file upload.
@@ -67,8 +70,7 @@ type Uploader struct {
// NewUploader creates a multipart uploader bound to an API client. // NewUploader creates a multipart uploader bound to an API client.
func NewUploader(api *API) *Uploader { func NewUploader(api *API) *Uploader {
logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER") logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
logger.AddWriter(logger.CreateJsonStdoutWriter())
return &Uploader{api, logger} return &Uploader{api, logger}
} }
@@ -80,8 +82,12 @@ func (u *Uploader) Close() error { return u.logger.Close() }
// See https://core.telegram.org/bots/api // See https://core.telegram.org/bots/api
func (u *Uploader) GetLogger() *slog.Logger { return u.logger } func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
// UploaderRequest is a multipart file upload request to the Telegram API. // UploaderRequest is a low-level multipart upload request wrapper.
// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one. //
// Prefer method-specific helpers such as SendPhoto or SetWebhook. UploaderRequest
// is intended for advanced use cases where callers manage the method name, files,
// and request/response types themselves. In that sense it is an unsafe escape
// hatch compared with the typed uploader API.
type UploaderRequest[R, P any] struct { type UploaderRequest[R, P any] struct {
method string method string
files []UploaderFile files []UploaderFile
@@ -89,16 +95,17 @@ type UploaderRequest[R, P any] struct {
chatId int64 chatId int64
} }
// NewUploaderRequest creates a new multipart upload request with no associated chat ID. // NewUploaderRequest creates a low-level multipart upload request with no associated chat ID.
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0}
} }
// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID. // NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID.
// The chat ID is used for per-chat rate limiting. // The chat ID is used for per-chat rate limiting.
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId}
} }
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
var zero R var zero R
@@ -203,8 +210,7 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return r.DoWithContext(context.Background(), up) return r.DoWithContext(context.Background(), up)
} }
// prepareMultipart builds a multipart form body from the given files and params. // Internal helper that builds a finalized multipart body from files and params.
// Params are encoded via utils.Encode. The writer boundary is finalized before returning.
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf) w := multipart.NewWriter(buf)
@@ -237,10 +243,9 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
return buf, w.FormDataContentType(), nil return buf, w.FormDataContentType(), nil
} }
// uploaderTypeByExt infers the Telegram upload field name from a file extension. // Internal helper that infers an upload field name from a file extension.
// Falls back to UploaderDocumentType for unrecognized extensions.
func uploaderTypeByExt(filename string) UploaderFileType { func uploaderTypeByExt(filename string) UploaderFileType {
ext := filepath.Ext(filename) ext := strings.ToLower(filepath.Ext(filename))
switch ext { switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".bmp": case ".jpg", ".jpeg", ".png", ".webp", ".bmp":
return UploaderPhotoType return UploaderPhotoType
+23 -2
View File
@@ -44,8 +44,8 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -104,6 +104,27 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
} }
} }
func TestNewUploaderFileDetectsFileTypeCaseInsensitively(t *testing.T) {
tests := []struct {
name string
filename string
want UploaderFileType
}{
{name: "uppercase photo", filename: "PHOTO.JPG", want: UploaderPhotoType},
{name: "uppercase voice", filename: "voice.OGG", want: UploaderVoiceType},
{name: "unknown defaults to document", filename: "archive.BIN", want: UploaderDocumentType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := NewUploaderFile(tt.filename, []byte("x"))
if file.field != tt.want {
t.Fatalf("unexpected uploader field: got %q want %q", file.field, tt.want)
}
})
}
}
func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) { func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type")) _, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil { if err != nil {
+110
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// UploadPhotoP holds parameters for uploading a photo using the Uploader. // UploadPhotoP holds parameters for uploading a photo using the Uploader.
// See https://core.telegram.org/bots/api#sendphoto // See https://core.telegram.org/bots/api#sendphoto
type UploadPhotoP struct { type UploadPhotoP struct {
@@ -32,6 +34,16 @@ func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, e
return req.Do(u) return req.Do(u)
} }
// SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendphoto
func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhotoP, file UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file)
return req.DoWithContext(ctx, u)
}
// UploadAudioP holds parameters for uploading an audio file using the Uploader. // UploadAudioP holds parameters for uploading an audio file using the Uploader.
// See https://core.telegram.org/bots/api#sendaudio // See https://core.telegram.org/bots/api#sendaudio
type UploadAudioP struct { type UploadAudioP struct {
@@ -66,6 +78,16 @@ func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Messag
return req.Do(u) return req.Do(u)
} }
// SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendaudio
func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudioP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadDocumentP holds parameters for uploading a document using the Uploader. // UploadDocumentP holds parameters for uploading a document using the Uploader.
// See https://core.telegram.org/bots/api#senddocument // See https://core.telegram.org/bots/api#senddocument
type UploadDocumentP struct { type UploadDocumentP struct {
@@ -97,6 +119,16 @@ func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (
return req.Do(u) return req.Do(u)
} }
// SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#senddocument
func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocumentP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVideoP holds parameters for uploading a video using the Uploader. // UploadVideoP holds parameters for uploading a video using the Uploader.
// See https://core.telegram.org/bots/api#sendvideo // See https://core.telegram.org/bots/api#sendvideo
type UploadVideoP struct { type UploadVideoP struct {
@@ -135,6 +167,16 @@ func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Messag
return req.Do(u) return req.Do(u)
} }
// SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideo
func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideoP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadAnimationP holds parameters for uploading an animation using the Uploader. // UploadAnimationP holds parameters for uploading an animation using the Uploader.
// See https://core.telegram.org/bots/api#sendanimation // See https://core.telegram.org/bots/api#sendanimation
type UploadAnimationP struct { type UploadAnimationP struct {
@@ -171,6 +213,16 @@ func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile)
return req.Do(u) return req.Do(u)
} }
// SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendanimation
func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimationP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVoiceP holds parameters for uploading a voice note using the Uploader. // UploadVoiceP holds parameters for uploading a voice note using the Uploader.
// See https://core.telegram.org/bots/api#sendvoice // See https://core.telegram.org/bots/api#sendvoice
type UploadVoiceP struct { type UploadVoiceP struct {
@@ -202,6 +254,16 @@ func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Messag
return req.Do(u) return req.Do(u)
} }
// SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvoice
func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoiceP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVideoNoteP holds parameters for uploading a video note (rounded video) using the Uploader. // UploadVideoNoteP holds parameters for uploading a video note (rounded video) using the Uploader.
// See https://core.telegram.org/bots/api#sendvideonote // See https://core.telegram.org/bots/api#sendvideonote
type UploadVideoNoteP struct { type UploadVideoNoteP struct {
@@ -231,6 +293,16 @@ func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile)
return req.Do(u) return req.Do(u)
} }
// SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideonote
func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNoteP, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadChatPhotoP holds parameters for uploading a chat photo using the Uploader. // UploadChatPhotoP holds parameters for uploading a chat photo using the Uploader.
// See https://core.telegram.org/bots/api#setchatphoto // See https://core.telegram.org/bots/api#setchatphoto
type UploadChatPhotoP struct { type UploadChatPhotoP struct {
@@ -244,3 +316,41 @@ func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bo
req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo) req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
return req.Do(u) return req.Do(u)
} }
// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatphoto
func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhotoP, photo UploaderFile) (bool, error) {
req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
return req.DoWithContext(ctx, u)
}
// UploadSetWebhookP holds multipart parameters for the setWebhook method.
// Use this type when uploading a self-signed certificate file.
// See https://core.telegram.org/bots/api#setwebhook
type UploadSetWebhookP struct {
URL string `json:"url"`
IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
SecretToken string `json:"secret_token,omitempty"`
}
// SetWebhook uploads a certificate and sets a webhook URL.
// certificate maps to the multipart field \"certificate\".
// See https://core.telegram.org/bots/api#setwebhook
func (u *Uploader) SetWebhook(params UploadSetWebhookP, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.Do(u)
}
// SetWebhookWithContext is the context-aware variant of SetWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setwebhook
func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhookP, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.DoWithContext(ctx, u)
}
+34
View File
@@ -1,5 +1,7 @@
package tgapi package tgapi
import "context"
// GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method. // GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method.
// See https://core.telegram.org/bots/api#getuserprofilephotos // See https://core.telegram.org/bots/api#getuserprofilephotos
type GetUserProfilePhotosP struct { type GetUserProfilePhotosP struct {
@@ -15,6 +17,14 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP
return req.Do(api) return req.Do(api)
} }
// GetUserProfilePhotosWithContext is the context-aware variant of GetUserProfilePhotos.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getuserprofilephotos
func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetUserProfilePhotosP) (UserProfilePhotos, error) {
req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params)
return req.DoWithContext(ctx, api)
}
// GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method. // GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method.
// See https://core.telegram.org/bots/api#getuserprofileaudios // See https://core.telegram.org/bots/api#getuserprofileaudios
type GetUserProfileAudiosP struct { type GetUserProfileAudiosP struct {
@@ -30,6 +40,14 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA
return req.Do(api) return req.Do(api)
} }
// GetUserProfileAudiosWithContext is the context-aware variant of GetUserProfileAudios.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getuserprofileaudios
func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetUserProfileAudiosP) (UserProfileAudios, error) {
req := NewRequest[UserProfileAudios]("getUserProfileAudios", params)
return req.DoWithContext(ctx, api)
}
// SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method. // SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method.
// See https://core.telegram.org/bots/api#setuseremojistatus // See https://core.telegram.org/bots/api#setuseremojistatus
type SetUserEmojiStatusP struct { type SetUserEmojiStatusP struct {
@@ -46,6 +64,14 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetUserEmojiStatusWithContext is the context-aware variant of SetUserEmojiStatus.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setuseremojistatus
func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUserEmojiStatusP) (bool, error) {
req := NewRequest[bool]("setUserEmojiStatus", params)
return req.DoWithContext(ctx, api)
}
// GetUserGiftsP holds parameters for the GetUserGifts method. // GetUserGiftsP holds parameters for the GetUserGifts method.
// See https://core.telegram.org/bots/api#getusergifts // See https://core.telegram.org/bots/api#getusergifts
type GetUserGiftsP struct { type GetUserGiftsP struct {
@@ -66,3 +92,11 @@ func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getUserGifts", params) req := NewRequest[OwnedGifts]("getUserGifts", params)
return req.Do(api) return req.Do(api)
} }
// GetUserGiftsWithContext is the context-aware variant of GetUserGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getusergifts
func (api *API) GetUserGiftsWithContext(ctx context.Context, params GetUserGiftsP) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getUserGifts", params)
return req.DoWithContext(ctx, api)
}
+10 -6
View File
@@ -3,7 +3,7 @@ package laniakea
import ( import (
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// Ptr returns a pointer to v. // Ptr returns a pointer to v.
@@ -53,11 +53,15 @@ func EscapePunctuation(s string) string {
return s return s
} }
// Version constants mirror values from the internal utils/version package.
const ( const (
// VersionString re-exports the module version string.
VersionString = utils.VersionString VersionString = utils.VersionString
VersionMajor = utils.VersionMajor // VersionMajor re-exports the module major version.
VersionMinor = utils.VersionMinor VersionMajor = utils.VersionMajor
VersionPatch = utils.VersionPatch // VersionMinor re-exports the module minor version.
VersionBeta = utils.VersionBeta VersionMinor = utils.VersionMinor
// VersionPatch re-exports the module patch version.
VersionPatch = utils.VersionPatch
// VersionBeta re-exports the module prerelease counter.
VersionBeta = utils.VersionBeta
) )
+5 -7
View File
@@ -9,6 +9,7 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
// ErrDropOverflow is returned when drop mode rejects a rate-limited request.
var ErrDropOverflow = errors.New("drop overflow limit") var ErrDropOverflow = errors.New("drop overflow limit")
// RateLimiter implements per-chat and global rate limiting with optional blocking. // RateLimiter implements per-chat and global rate limiting with optional blocking.
@@ -102,7 +103,7 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error {
return chatLimiter.Wait(ctx) return chatLimiter.Wait(ctx)
} }
// getGlobalLimiter returns the global limiter safely under read lock. // Internal helper that returns the global limiter under read lock.
func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter {
rl.globalMu.RLock() rl.globalMu.RLock()
defer rl.globalMu.RUnlock() defer rl.globalMu.RUnlock()
@@ -190,8 +191,7 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6
return nil return nil
} }
// waitForGlobalUnlock blocks until global cooldown expires or context is done. // Internal helper that waits for the global cooldown to expire.
// Does not check token bucket — only cooldown.
func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
rl.globalMu.RLock() rl.globalMu.RLock()
until := rl.globalLockUntil until := rl.globalLockUntil
@@ -209,8 +209,7 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
} }
} }
// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done. // Internal helper that waits for a chat-specific cooldown to expire.
// Does not check token bucket — only cooldown.
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
rl.chatMu.RLock() rl.chatMu.RLock()
until, ok := rl.chatLocks[chatID] until, ok := rl.chatLocks[chatID]
@@ -228,8 +227,7 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
} }
} }
// getChatLimiter returns the rate limiter for the given chat, creating it if needed. // Internal helper that returns or creates a per-chat limiter.
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
rl.chatMu.Lock() rl.chatMu.Lock()
defer rl.chatMu.Unlock() defer rl.chatMu.Unlock()
+41
View File
@@ -0,0 +1,41 @@
package utils
import (
"context"
"errors"
"testing"
"time"
)
func TestRateLimiterCheckDropOverflowHonorsGlobalLock(t *testing.T) {
rl := NewRateLimiter()
rl.SetGlobalLock(1)
if err := rl.Check(context.Background(), true, 0); !errors.Is(err, ErrDropOverflow) {
t.Fatalf("expected ErrDropOverflow, got %v", err)
}
}
func TestRateLimiterChatLocksAreScopedPerChat(t *testing.T) {
rl := NewRateLimiter()
rl.SetChatLock(42, 1)
if rl.Allow(42) {
t.Fatal("expected locked chat to be rejected")
}
if !rl.Allow(7) {
t.Fatal("expected unrelated chat to remain allowed")
}
}
func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) {
rl := NewRateLimiter()
rl.SetGlobalLock(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := rl.GlobalWait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected DeadlineExceeded, got %v", err)
}
}
+1 -2
View File
@@ -3,7 +3,6 @@ package utils
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"mime/multipart" "mime/multipart"
"reflect" "reflect"
"slices" "slices"
@@ -110,6 +109,6 @@ func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) er
if err != nil { if err != nil {
return err return err
} }
_, err = io.Copy(fw, strings.NewReader(string(value))) _, err = fw.Write(value)
return err return err
} }
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"mime/multipart" "mime/multipart"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
type multipartEncodeParams struct { type multipartEncodeParams struct {
+27 -1
View File
@@ -3,7 +3,7 @@ package utils
import ( import (
"os" "os"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL. // GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
@@ -14,3 +14,29 @@ func GetLoggerLevel() slog.LogLevel {
} }
return level return level
} }
// CreateLogger creates a logger with the shared default policy:
// JSON stdout output, provided prefix, and provided level.
func CreateLogger(prefix string, level slog.LogLevel) *slog.Logger {
logger := slog.CreateLogger().Level(level)
if prefix != "" {
logger.Prefix(prefix)
}
logger.AddWriter(logger.CreateJsonStdoutWriter())
return logger
}
// CreateFileLogger creates a logger with the shared default policy and appends
// file output to the provided path.
//
// The returned logger is always non-nil. When file writer creation fails, the
// logger still writes to stdout and the error is returned to the caller.
func CreateFileLogger(prefix string, level slog.LogLevel, filePath string) (*slog.Logger, error) {
logger := CreateLogger(prefix, level)
fileWriter, err := logger.CreateTextFileWriter(filePath)
if err != nil {
return logger, err
}
logger.AddWriter(fileWriter)
return logger, nil
}
+34
View File
@@ -0,0 +1,34 @@
package utils
import (
"os"
"path/filepath"
"strings"
"testing"
"git.scuroneko.dev/scuroneko/slog"
)
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
logPath := filepath.Join(t.TempDir(), "main.log")
logger, err := CreateFileLogger("TEST", slog.DEBUG, logPath)
if err != nil {
t.Fatalf("CreateFileLogger returned error: %v", err)
}
logger.Infoln("hello from file logger")
if err := logger.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile returned error: %v", err)
}
if !strings.Contains(string(data), "hello from file logger") {
t.Fatalf("expected log message in file, got %q", string(data))
}
if !strings.Contains(string(data), "[TEST]") {
t.Fatalf("expected prefix in file, got %q", string(data))
}
}
+10 -5
View File
@@ -1,9 +1,14 @@
package utils package utils
const ( const (
VersionString = "1.0.0-rc.2" // VersionString is the module version string.
VersionMajor = 1 VersionString = "1.0.0-rc.12"
VersionMinor = 0 // VersionMajor is the module major version.
VersionPatch = 0 VersionMajor = 1
VersionBeta = 2 // VersionMinor is the module minor version.
VersionMinor = 0
// VersionPatch is the module patch version.
VersionPatch = 0
// VersionBeta is the prerelease counter for the current version.
VersionBeta = 12
) )