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
+3
View File
@@ -1,2 +1,5 @@
.idea/
.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](assets/logo.jpg)
[![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)
![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.
[На русском](README_RU.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
---
## ✨ Features
@@ -25,7 +29,13 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
## 📦 Installation
```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)
@@ -37,17 +47,18 @@ package main
import (
"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.
// It receives two parameters:
// - 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)
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.
// ctx.Text contains the user's message with the command part stripped off.
ctx.Answer(ctx.Text) // User input WITHOUT command
return nil
}
func main() {
@@ -56,7 +67,10 @@ func main() {
// 2. Initialize a new bot instance.
// 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.
defer bot.Close()
@@ -70,8 +84,9 @@ func main() {
// 5. Add another command using an anonymous function (closure).
// 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")
return nil
}, "ping"))
// 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).
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.
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.
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), 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.
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, 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.
7. `AutoGenerateCommands`: Adds built-in commands (/start, /help) and a command that lists all available commands.
8. `Run()`: Starts the bot's update polling loop.
7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
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
### Plugins
Plugins are the main way to organize code. A plugin can have multiple commands and middlewares.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin)
```
@@ -114,9 +132,10 @@ bot.AddPlugins(plugin)
A command is a function that handles a specific bot command (e.g., /start).
```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Access command arguments via ctx.Args ([]string)
// 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:
- `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).
- `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.
- `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).
@@ -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`.
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
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
type MyDB struct { /* ... */ }
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 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).
### 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
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware)
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
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.
## ⚙️ 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.
- **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
This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details.
## 📚 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)
+109 -27
View File
@@ -1,13 +1,17 @@
# Laniakea
![Laniakea](assets/logo.jpg)
[![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)
![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)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
---
## ✨ Возможности
@@ -26,7 +30,13 @@
## 📦 Установка
```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 (
"log"
"git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea
"git.scuroneko.dev/scuroneko/laniakea" // Импортируем библиотеку Laniakea
)
// echo — это функция-обработчик команды.
// Она получает два параметра:
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
func echo(ctx *laniakea.MsgContext, db laniakea.NoDB) error {
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
return nil
}
func main() {
@@ -57,7 +68,10 @@ func main() {
// 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера).
bot := laniakea.NewBot[laniakea.NoDB](opts)
bot, err := laniakea.NewBot[laniakea.NoDB](opts)
if err != nil {
log.Fatal(err)
}
// Гарантируем освобождение ресурсов бота при выходе.
defer bot.Close()
@@ -71,8 +85,9 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "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")
return nil
}, "ping"))
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
@@ -87,7 +102,9 @@ func main() {
}
// 8. Запускаем бота, начиная прослушивание обновлений (long polling).
bot.Run()
if err := bot.Run(); err != nil {
log.Fatal(err)
}
}
```
@@ -95,18 +112,19 @@ func main() {
1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T, а ошибку возвращают для централизованной обработки.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд.
8. `Run()`: Запускает цикл опроса обновлений бота.
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. Экземпляр `Bot` одноразовый. После завершения `Run()` или `RunWithContext()` для следующего запуска создавайте новый бот.
## 📖 Основные концепции
### Плагины (Plugins)
Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin)
```
@@ -115,9 +133,10 @@ bot.AddPlugins(plugin)
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Доступ к аргументам команды через ctx.Args ([]string)
// Ответ пользователю: ctx.Answer("какой-то текст")
return nil
}
```
@@ -125,26 +144,78 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Предоставляет доступ к входящему сообщению и полезные методы для ответа:
- `Answer(text string)`: Отправляет сообщение с parse_mode none.
- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений.
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
- `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 клавиатурой.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки.
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие.
- `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки.
- `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
- И много других методов и полей!
### Контекст базы данных (Database Context)
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД обычно стоит использовать pointer type.
```go
type MyDB struct { /* ... */ }
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 — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста.
@@ -159,11 +230,12 @@ func(ctx *MsgContext, db T) bool
- Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается).
### Добавление middleware
Используйте метод Use плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления.
Используйте метод `AddMiddleware` плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware)
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
```
@@ -192,15 +264,25 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
## ⚙️ Расширенная настройка
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard().
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram.
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля.
- **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
- **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
- **Локализация**: `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).
## 📚 Дополнительная информация
[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)
+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

+362 -58
View File
@@ -4,25 +4,33 @@ import (
"context"
"errors"
"fmt"
"maps"
"reflect"
"slices"
"sort"
"strings"
"sync"
"time"
"git.nix13.pw/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
"github.com/alitto/pond/v2"
)
// DbContext is an interface representing the application's database context.
// It is injected into plugins and middleware via Bot.DatabaseContext().
// DbContext is the generic dependency type injected into bots, plugins, and handlers.
// Use it for shared application state such as database handles or service containers.
//
// Example:
//
// 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.
type DbContext any
@@ -33,7 +41,7 @@ type NoDB struct{ DbContext }
// 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).
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.
type BotPayloadType string
@@ -45,6 +53,20 @@ var (
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.
//
// Manages:
@@ -54,13 +76,15 @@ var (
// - Logging and rate limiting
// - 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 {
token string
debug bool
errorTemplate string
username string
payloadType BotPayloadType
strictPayloadType bool
maxWorkers int
logger *slog.Logger // Main bot logger (JSON stdout + optional file)
@@ -74,16 +98,24 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader
dbContext *T // Injected database context
dbContext T // Injected database context
hasDBContext bool
warnedValueDB bool
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder
sessionStore SessionStore // Session store for scene management
sceneScopePriority []SceneScope
updateOffsetMu sync.Mutex
updateOffset int // Last processed update ID
updateTypes []tgapi.UpdateType // Types of updates to fetch
updateQueue chan *tgapi.Update // Internal queue for processing updates
runnerOnceWG sync.WaitGroup // Tracks one-time 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.
@@ -94,13 +126,12 @@ type Bot[T DbContext] struct {
// - Fetches bot username via GetMe()
// - Sets up DraftProvider with random IDs
// - Adds API and Uploader loggers to extraLoggers
//
// Panics if:
// - Token is empty
// - GetMe() fails (invalid token or network error)
func NewBot[T any](opts *BotOpts) *Bot[T] {
func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
if opts == nil {
return nil, ErrOptsIsNil
}
if opts.Token == "" {
panic("laniakea: BotOpts.Token is required")
return nil, ErrTokenRequired
}
updateQueue := make(chan *tgapi.Update, 512)
@@ -134,6 +165,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
updateOffset: 0,
errorTemplate: "%s",
payloadType: BotPayloadBase64,
strictPayloadType: opts.StrictPayloadType,
maxWorkers: workers,
updateQueue: updateQueue,
api: api,
@@ -147,6 +179,9 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
extraLoggers: make([]*slog.Logger, 0),
l10n: &L10n{},
draftProvider: NewRandomDraftProvider(api),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
// 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()
if err != nil {
_ = bot.Close()
bot.logger.Fatal(err)
return nil, err
}
bot.username = Val(u.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")))
return bot
return bot, nil
}
// Close gracefully shuts down bot-owned resources.
//
// Closes:
// Close shuts down, in order:
// - Registered plugins via Plugin.Close
// - Uploader (waits for pending uploads)
// - API client
// - API client internals
// - RequestLogger (if enabled)
// - Main logger
//
// RunWithContext does not call Close automatically. The caller is responsible
// 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 {
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 {
bot.logger.Errorln(err)
e = append(e, err)
}
if err := bot.api.CloseApi(); err != nil {
if err := bot.api.Close(); err != nil {
bot.logger.Errorln(err)
e = append(e, err)
}
@@ -210,38 +251,45 @@ func (bot *Bot[T]) Close() error {
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).
// Writes to stdout in JSON format by default.
// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath.
// This is separate from Bot.Close(), which only releases local resources.
func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
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) {
level := slog.FATAL
if opts.Debug {
level = slog.DEBUG
}
bot.logger = slog.CreateLogger().Level(level).Prefix("BOT")
bot.logger.AddWriter(bot.logger.CreateJsonStdoutWriter())
bot.logger = utils.CreateLogger("BOT", level)
if opts.WriteToFile {
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 {
bot.logger.Fatal(err)
bot.logger.Errorln(err)
} else {
bot.logger = logger
}
bot.logger.AddWriter(fileWriter)
}
if opts.UseRequestLogger {
bot.RequestLogger = slog.CreateLogger().Level(level).Prefix("REQUESTS")
bot.RequestLogger.AddWriter(bot.RequestLogger.CreateJsonStdoutWriter())
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
if opts.WriteToFile {
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 {
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.
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.
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
// GetDBContext returns the injected database context.
// Returns nil if not set via DatabaseContext().
func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext }
// If DatabaseContext was not called, it returns the zero value of T.
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.
// Returns empty string if translation not found.
@@ -283,10 +343,60 @@ func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
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.
// 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.hasDBContext = true
return bot
}
@@ -298,14 +408,25 @@ func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
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":[...]}`.
// 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] {
bot.payloadType = t
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.
// Does not overwrite existing types.
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.
func (bot *Bot[T]) Debug(debug bool) *Bot[T] {
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
}
// AddPlugins registers one or more plugins.
// 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] {
level := bot.GetLoggerLevel()
for _, p := range plugin {
bot.plugins = append(bot.plugins, *p)
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.name))
if p == nil {
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
}
@@ -356,13 +510,14 @@ func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
//
// 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] {
for _, m := range middleware {
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.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:
//
// 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] {
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.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
// - Request logger (if enabled)
// - 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:
//
@@ -440,6 +601,14 @@ func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
// return db.QueryLogger()
// })
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)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
@@ -448,6 +617,11 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
for _, p := range bot.plugins {
if p.logger != nil {
p.logger.AddWriter(w)
}
}
return bot
}
@@ -474,16 +648,20 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
// // ... later ...
// cancel() // triggers graceful shutdown
// _ = 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 {
bot.logger.Fatalln("no prefixes defined")
return
return ErrNoPrefixes
}
if len(bot.plugins) == 0 {
bot.logger.Fatalln("no plugins defined")
return
return ErrNoPlugins
}
if err := bot.beginRun(); err != nil {
return err
}
defer bot.finishRun()
bot.ExecRunners(ctx)
@@ -497,6 +675,7 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
}
close(bot.updateQueue)
}()
retryDelay := time.Duration(0)
for {
select {
case <-ctx.Done():
@@ -504,10 +683,23 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
default:
updates, err := bot.Updates(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
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
}
retryDelay = 0
for _, update := range updates {
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 {
u := update // capture loop variable
pool.Submit(func() {
bot.handle(u)
bot.handle(ctx, u)
})
}
pool.Stop() // Wait for all tasks to complete and stop the pool
bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait()
return nil
}
// 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.
//
// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully.
func (bot *Bot[T]) Run() {
bot.RunWithContext(context.Background())
func (bot *Bot[T]) Run() error {
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
}
+33 -8
View File
@@ -5,13 +5,13 @@ import (
"strconv"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// BotOpts holds configuration options for initializing a Bot.
//
// 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 {
// Token is the Telegram bot token (required).
Token string
@@ -56,7 +56,11 @@ type BotOpts struct {
// Use this to prioritize responsiveness over reliability.
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
}
@@ -75,20 +79,31 @@ type BotOpts struct {
// - API_URL: custom API endpoint
// - RATE_LIMIT: max requests per second (default: 30)
// - 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 {
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 n, err := strconv.Atoi(rl); err == nil {
rateLimit = n
}
}
stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES"))
updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes))
for _, updateType := range stringUpdateTypes {
updateTypes = append(updateTypes, tgapi.UpdateType(updateType))
if mw := os.Getenv("MAX_WORKERS"); mw != "" {
if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil {
maxWorkers = n
}
}
return &BotOpts{
@@ -108,6 +123,9 @@ func LoadOptsFromEnv() *BotOpts {
RateLimit: rateLimit,
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
}
// 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.
// Must be called before NewBot, as the value is captured during bot creation.
//
+10 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) {
@@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
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"
"fmt"
"regexp"
"sort"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// 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
// exceeds Telegram's limit of 100 bot commands per bot.
@@ -20,7 +21,7 @@ var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$")
// bot initialization.
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 {
desc := ""
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}
}
// checkCmdRegex reports whether cmd matches CmdRegexp.
// Internal helper to validate Telegram command names.
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 {
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 {
continue
}
@@ -62,9 +70,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands
}
// gatherCommands collects all commands from all plugins
// and converts them into tgapi.BotCommand objects.
// See gatherCommandsForPlugin.
// Internal helper to collect all auto-generated commands from registered plugins.
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins {
+26 -5
View File
@@ -4,13 +4,14 @@ import (
"errors"
"io"
"net/http"
"reflect"
"strconv"
"strings"
"sync/atomic"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -37,13 +38,13 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
SetHTTPClient(client),
)
defer func() {
if err := api.CloseApi(); err != nil {
t.Fatalf("CloseApi returned error: %v", err)
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
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++ {
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())
}
}
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.
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,
rate limiting, and dependency injection. Created via NewBot[T].
- Plugins: Organize commands and payloads into reusable units.
A plugin can have multiple commands and shared middlewares.
- Commands: Named bot commands with descriptions, argument validation, and
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].
- Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection.
- Plugins group commands, payloads, and non-command update handlers behind shared middleware.
- MsgContext provides access to the current update and reply/edit/delete helpers.
- InlineKeyboard builds callback-driven keyboards and structured payloads.
- DraftProvider accumulates multi-step replies before sending them.
- L10n stores key-based translations with fallback behavior.
- Runners execute startup or background tasks alongside the polling loop.
Example usage:
bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()).
DatabaseContext(&myDB).
bot, err := laniakea.NewBot[*mydb.DBContext](laniakea.LoadOptsFromEnv())
if err != nil {
return err
}
bot.DatabaseContext(myDB).
AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(&authMiddleware, &logMiddleware).
AddRunner(&cleanupRunner).
AddMiddleware(authMiddleware, logMiddleware).
AddRunner(cleanupRunner).
AddL10n(l10n.New())
bot.Run()
return bot.Run()
All public methods are safe for concurrent use unless stated otherwise.
Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset).
Configure bots, plugins, and localization before starting Run or RunWithContext.
Runtime accessors are safe for concurrent use unless stated otherwise.
*/
package laniakea
+15 -16
View File
@@ -1,18 +1,14 @@
package laniakea
import (
"errors"
"math/rand/v2"
"sync"
"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.
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs.
// Interface for generating unique draft IDs.
type draftIdGenerator interface {
// Next returns the next unique draft ID.
Next() uint64
@@ -38,12 +34,9 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
return g.lastId.Add(1)
}
// DraftProvider manages a collection of Drafts and provides methods to create and
// configure them. It holds shared configuration (chat, parse mode, entities) and
// a draft ID generator.
// DraftProvider manages a collection of Drafts and a shared draft ID generator.
//
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines
// requires external synchronization.
// DraftProvider is safe for concurrent use.
type DraftProvider struct {
mu sync.RWMutex
api *tgapi.API
@@ -133,10 +126,7 @@ type Draft struct {
// NewDraft creates a new draft with the provided parse mode.
//
// The draft inherits the provider's chatID, messageThreadID, and entities.
// If parseMode is zero, the provider's default parseMode is used.
//
// Panics if chatID is zero — call SetChat() on the provider first.
// The caller must set a chat with SetChat before Push or Flush.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
id := p.generator.Next()
draft := &Draft{
@@ -224,6 +214,12 @@ func (d *Draft) Flush() error {
if d.Message == "" {
return nil
}
if d.chatID == 0 {
return ErrDraftChatIDZero
}
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageP{
ChatID: d.chatID,
@@ -242,12 +238,15 @@ func (d *Draft) Flush() error {
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 {
if d.chatID == 0 {
return ErrDraftChatIDZero
}
d.Message += text
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageDraftP{
ChatID: d.chatID,
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
retract v1.0.0-rc.5
require (
git.nix13.pw/scuroneko/extypes v1.2.2
git.nix13.pw/scuroneko/slog v1.1.2
git.scuroneko.dev/scuroneko/extypes v1.2.3
git.scuroneko.dev/scuroneko/slog v1.1.3
github.com/alitto/pond/v2 v2.7.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.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk=
git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0=
git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM=
git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
git.scuroneko.dev/scuroneko/slog v1.1.3 h1:vI4GZykn8gDb6OJ2xq+KLcEk38M7O4e/z1kzpeRHEHw=
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/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
+234 -54
View File
@@ -1,86 +1,90 @@
package laniakea
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
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() {
if r := recover(); r != nil {
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,
botLogger: bot.logger,
Logger: bot.logger,
errorTemplate: bot.errorTemplate,
l10n: bot.l10n,
draftProvider: bot.draftProvider,
sceneRuntime: bot,
payloadType: bot.payloadType,
ctx: ctx,
}
bot.prepareUpdateCtx(u, msgCtx)
for _, middleware := range bot.middlewares {
if !middleware.Execute(ctx, bot.dbContext) {
if !middleware.Execute(msgCtx, bot.dbContext) {
return
}
}
if u.CallbackQuery != nil {
bot.handleCallback(u, ctx)
} else {
bot.handleMessage(u, ctx)
sceneHandled, err := bot.tryHandleScene(msgCtx)
if err != nil {
bot.logger.Errorln(err)
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) {
if update.Message == nil {
return
}
if update.Message.From == nil {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return
}
var text string
if len(update.Message.Text) > 0 {
text = update.Message.Text
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
text = update.Message.Caption
return
}
text = strings.TrimSpace(text)
prefix, hasPrefix := bot.checkPrefixes(text)
if !hasPrefix {
prefix, cmd, args := bot.parseCommand(text)
if cmd == "" {
return
}
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, "@") {
botUsername := bot.username
@@ -94,6 +98,10 @@ func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args
ctx.Args = strings.Fields(args) // Убирает лишние пробелы
if plugin.logger != nil {
ctx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
@@ -110,16 +118,6 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
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
for _, plugin := range bot.plugins {
@@ -128,6 +126,10 @@ func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
continue
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
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) {
for _, prefix := range bot.prefixes {
if prefix == "" {
if bot.logger != nil {
bot.logger.Warnln("empty prefix is not allowed")
}
continue
}
if strings.HasPrefix(text, prefix) {
@@ -147,6 +281,23 @@ func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
}
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) {
b, err := json.Marshal(d)
@@ -186,19 +337,48 @@ func decodeBase64Payload(s string) (CallbackData, error) {
}
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 {
case BotPayloadBase64:
return decodeBase64Payload(s)
case BotPayloadJson:
return decodeJsonPayload(s)
data, err := decodeBase64Payload(s)
if err == nil {
return data, BotPayloadBase64, nil
}
return CallbackData{}, ErrInvalidPayloadType
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:
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
}
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
// return encodePayload(bot.payloadType, d)
// }
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
import "testing"
import (
"context"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestCheckPrefixesSkipsEmptyPrefixes(t *testing.T) {
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)
}
}
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)
}
}
+24 -19
View File
@@ -3,17 +3,16 @@ package laniakea
import (
"fmt"
"git.nix13.pw/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/extypes"
"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 (
// ButtonStyleDanger marks a destructive inline keyboard action.
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger"
// ButtonStyleSuccess marks a confirmatory inline keyboard action.
ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success"
// ButtonStylePrimary marks a primary inline keyboard action.
ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary"
)
@@ -83,8 +82,7 @@ func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) In
return b
}
// build converts the builder state into a tgapi.InlineKeyboardButton.
// This method is typically called internally by InlineKeyboard.AddButton().
// Internal helper that converts the builder state into a Telegram button.
func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton {
return tgapi.InlineKeyboardButton{
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.
// 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 {
in.payloadType = t
return in
}
// append adds a button to the current line. If the line is full, it auto-flushes.
// This is an internal helper used by other builder methods.
// GetPayloadType returns the keyboard-local callback payload encoding type.
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 {
if in.CurrentLine.Len() == in.maxRow {
in.AddLine()
@@ -235,12 +240,12 @@ type CallbackData struct {
// (int, string, bool, float64) but may not serialize complex structs meaningfully.
//
// 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))
for i, arg := range args {
stringArgs[i] = fmt.Sprint(arg)
}
return &CallbackData{
return CallbackData{
Command: command,
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
// errors occur — avoiding "invalid callback_data" errors from Telegram.
func (d *CallbackData) ToJson() string {
data, err := encodeJsonPayload(*d)
func (d CallbackData) ToJson() string {
data, err := encodeJsonPayload(d)
if err != nil {
// Fallback: return minimal valid JSON to avoid Telegram API rejection
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.
// Returns an empty string if serialization or encoding fails.
func (d *CallbackData) ToBase64() string {
s, err := encodeBase64Payload(*d)
func (d CallbackData) ToBase64() string {
s, err := encodeBase64Payload(d)
if err != nil {
return ``
}
@@ -275,7 +280,7 @@ func (d *CallbackData) ToBase64() string {
// Encode serializes the CallbackData according to the specified payload type.
// Supported types: BotPayloadJson and BotPayloadBase64.
// For unknown types, returns an empty string.
func (d *CallbackData) Encode(t BotPayloadType) string {
func (d CallbackData) Encode(t BotPayloadType) string {
switch t {
case BotPayloadBase64:
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
// DictEntry represents a single localized entry with language-to-text mappings.
// Example: {"ru": "Привет", "en": "Hello"}.
import "sync"
// DictEntry maps language codes to translated strings.
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 {
entries map[string]DictEntry // Map of translation keys to language dictionaries
fallbackLang string // Language code to use when requested language is missing
mu sync.RWMutex
entries map[string]DictEntry
fallbackLang string
}
// NewL10n creates a new L10n instance with the specified 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.
// NewL10n creates a localization store with the given fallback language.
func NewL10n(fallbackLanguage string) *L10n {
return &L10n{
entries: make(map[string]DictEntry),
@@ -23,54 +20,52 @@ func NewL10n(fallbackLanguage string) *L10n {
}
}
// AddDictEntry adds a new translation entry for the given 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.
// AddDictEntry stores translations for key.
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
}
// GetFallbackLanguage returns the currently configured fallback language code.
func (l *L10n) GetFallbackLanguage() string {
l.mu.RLock()
defer l.mu.RUnlock()
return l.fallbackLang
}
// Translate retrieves the translation for the given key and language.
//
// 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.
// Translate returns the translation for key in lang, falling back to the configured language or the key itself.
func (l *L10n) Translate(lang, key string) string {
l.mu.RLock()
defer l.mu.RUnlock()
entries, exists := l.entries[key]
if !exists {
return key // Return key as fallback when translation is missing
return key
}
// Try requested language
if translation, ok := entries[lang]; ok {
return translation
}
// Fall back to configured fallback language
if fallback, ok := entries[l.fallbackLang]; ok {
return fallback
}
// If fallback language is also missing, return the 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"
"encoding/json"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// Updates fetches new updates from Telegram API using long polling.
+314 -46
View File
@@ -2,11 +2,15 @@ package laniakea
import (
"context"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
// MsgContext holds the context for handling a Telegram message or callback query.
@@ -19,6 +23,10 @@ type MsgContext struct {
Msg *tgapi.Message
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
CallbackMsgId int
CallbackQueryId string
@@ -28,10 +36,12 @@ type MsgContext struct {
Args []string
errorTemplate string
botLogger *slog.Logger
l10n *L10n
draftProvider *DraftProvider
payloadType BotPayloadType
sceneRuntime sceneRuntime
ctx context.Context
}
// AnswerMessage represents a message sent or edited via MsgContext.
@@ -43,9 +53,12 @@ type AnswerMessage struct {
ctx *MsgContext // internal back-reference
}
// edit is an internal helper to edit a message's text with optional keyboard and parse mode.
// Used by Edit, EditMarkdown, EditCallback, etc.
// Internal helper for text edits with optional keyboard and parse mode.
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{
Text: text,
ParseMode: parseMode,
@@ -57,15 +70,15 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId
default:
ctx.botLogger.Errorln("Can't edit message: no valid message target")
ctx.Logger.Errorln(ErrEditTargetMissing)
return nil
}
if keyboard != nil {
params.ReplyMarkup = keyboard.Get()
}
msg, _, err := ctx.Api.EditMessageText(params)
msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
resultMessageID := messageId
@@ -91,11 +104,10 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2)
}
// editCallback is an internal helper to edit the message associated with a callback query.
// Supports both regular callback messages and inline callback messages.
// Internal helper for editing callback-linked messages.
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
ctx.botLogger.Errorln("Can't edit non-callback update message")
ctx.Logger.Errorln(ErrCallbackMessageMissing)
return nil
}
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)
}
// editPhotoText edits the caption of a photo/video message.
// Returns nil when no valid edit target is available for the current context.
// Internal helper for media-caption edits.
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{
Caption: text,
ParseMode: parseMode,
@@ -139,16 +154,16 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId
default:
ctx.botLogger.Errorln("Can't edit caption: no valid message target")
ctx.Logger.Errorln(ErrEditTargetMissing)
return nil
}
if kb != nil {
params.ReplyMarkup = kb.Get()
}
msg, _, err := ctx.Api.EditMessageCaption(params)
msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
resultMessageID := messageId
@@ -184,11 +199,14 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2)
}
// answer sends a new message with optional keyboard and parse mode.
// Uses API limiter to respect Telegram rate limits per chat.
// Internal helper for message replies with optional keyboard and parse mode.
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
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
}
params := tgapi.SendMessageP{
@@ -206,9 +224,9 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
}
msg, err := ctx.Api.SendMessage(params)
msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
return &AnswerMessage{
@@ -221,6 +239,14 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage {
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.
//
// ⚠️ 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)
}
// 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.
//
// ⚠️ 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)
}
// 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.
//
// ⚠️ 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)
}
// 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 {
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
}
params := tgapi.SendPhotoP{
@@ -274,9 +355,9 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
}
msg, err := ctx.Api.SendPhoto(params)
msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
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)
}
// delete removes a message by ID.
// Internal helper that deletes a message by ID.
func (ctx *MsgContext) delete(messageId int) {
if messageId == 0 {
ctx.botLogger.Errorln("Can't delete message: message ID zero")
ctx.Logger.Errorln(ErrMessageIDZero)
return
}
if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't delete message: no chat message context")
ctx.Logger.Errorln(ErrMessageContextNil)
return
}
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{
_, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessageP{
ChatID: ctx.Msg.Chat.ID,
MessageID: messageId,
})
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.
func (ctx *MsgContext) CallbackDelete() {
if ctx.CallbackMsgId == 0 {
ctx.botLogger.Errorln("Can't delete callback message: no callback message ID")
ctx.Logger.Errorln(ErrCallbackMessageMissing)
return
}
ctx.delete(ctx.CallbackMsgId)
}
// answerCallbackQuery sends a response to a callback query (optional text/alert/url).
// Does nothing if CallbackQueryId is empty.
// Internal helper that answers a callback query with optional text, alert, or URL.
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryId) == 0 {
return
}
_, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{
_, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQueryP{
CallbackQueryID: ctx.CallbackQueryId,
Text: text, ShowAlert: showAlert, URL: url,
})
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.
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
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
}
params := tgapi.SendChatActionP{
@@ -390,16 +470,13 @@ func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
if ctx.Msg.MessageThreadID > 0 {
params.MessageThreadID = ctx.Msg.MessageThreadID
}
_, err := ctx.Api.SendChatAction(params)
_, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params)
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
}
// error sends an error message to the user and logs it.
// Uses errorTemplate to format the message.
// For callbacks: sends as callback answer (no alert).
// For regular messages: sends as plain text.
// Internal helper that formats, sends, and logs an error.
func (ctx *MsgContext) error(err error) {
text := fmt.Sprintf(ctx.errorTemplate, err.Error())
@@ -408,7 +485,7 @@ func (ctx *MsgContext) error(err error) {
} else {
ctx.answer(text, nil, tgapi.ParseNone)
}
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
// Error is an alias for error().
@@ -416,16 +493,26 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
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
}
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if ctx.Api.Limiter != nil {
c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
return nil
}
}
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
return draft
@@ -459,3 +546,184 @@ func (ctx *MsgContext) Translate(key string) string {
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
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 (
"encoding/json"
"errors"
"io"
"net/http"
"reflect"
"strings"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
@@ -37,8 +39,8 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
SetHTTPClient(client),
)
defer func() {
if err := api.CloseApi(); err != nil {
t.Fatalf("CloseApi returned error: %v", err)
if err := api.Close(); err != nil {
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)},
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
},
botLogger: slog.CreateLogger(),
Logger: slog.CreateLogger(),
}
answer := ctx.AnswerPhoto("photo-id", "caption")
@@ -62,3 +64,247 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
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)
}
}
+135 -36
View File
@@ -4,10 +4,13 @@ import (
"errors"
"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
const (
@@ -49,12 +52,12 @@ type CommandArg struct {
// NewCommandArg creates a new CommandArg with the given text and type.
// Uses a default regex based on the type (string or int).
// For CommandValueAnyType, no validation is performed.
func NewCommandArg(text string) *CommandArg {
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false}
func NewCommandArg(text string) CommandArg {
return CommandArg{CommandValueAnyType, text, CommandRegexString, false}
}
// 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
switch t {
case CommandValueIntType:
@@ -71,14 +74,15 @@ func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
// SetRequired marks this argument as required.
// Returns the receiver for method chaining.
func (c *CommandArg) SetRequired() *CommandArg {
func (c CommandArg) SetRequired() CommandArg {
c.required = true
return c
}
// CommandExecutor is the function type that executes a command.
// 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.
// Can be registered in a Plugin and optionally skipped from auto-generation.
@@ -122,15 +126,13 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] {
return c
}
// validateArgs checks if the provided arguments match the command's requirements.
// Returns ErrCmdArgCountMismatch if too few arguments are provided.
// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation.
// Internal helper that validates provided command arguments.
func (c *Command[T]) validateArgs(args []string) error {
// Count required args
requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len()
if len(args) < requiredCount {
for i := range c.args.Len() {
if i >= len(args) && c.args.Get(i).required {
return ErrCmdArgCountMismatch
}
}
// Validate each argument against its regex
for i, arg := range args {
@@ -151,19 +153,35 @@ func (c *Command[T]) validateArgs(args []string) error {
// Plugin represents a collection of commands and payloads (e.g., callback handlers),
// 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 {
name string // Name of the plugin (e.g., "admin", "user")
commands map[string]*Command[T] // Registered commands (triggered by message)
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
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.
func NewPlugin[T DbContext](name string) *Plugin[T] {
return &Plugin[T]{
name, make(map[string]*Command[T]),
make(map[string]*Command[T]), extypes.Slice[Middleware[T]]{}, false,
name: name,
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
}
// 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.
// Middlewares are executed before any command or payload.
func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] {
@@ -210,10 +265,53 @@ func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] {
return p
}
// executeCmd finds and executes a command by its trigger string.
// Validates arguments and runs middlewares before executor.
// On error, sends an error message to the user via ctx.error().
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) {
// SetLogger sets the logger used for this plugin's handlers.
//
// 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]) 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]
if !exists {
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
for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) {
if !m.Execute(ctx, db) {
return
}
}
// 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.
// Validates arguments and runs middlewares before executor.
// On error, sends an error message to the user via ctx.error().
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) {
// Internal helper that validates and executes a payload handler.
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) {
command, exists := p.payloads[payload]
if !exists {
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
for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) {
if !m.Execute(ctx, db) {
return
}
}
// Execute payload
command.exec(ctx, dbContext)
if err := command.exec(ctx, db); err != nil {
ctx.error(err)
}
}
// executeMiddlewares runs all plugin middlewares in order.
// Returns false if any middleware returns false (blocks execution).
func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
// Internal helper that runs plugin middlewares in order.
func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool {
for _, m := range p.middlewares {
if !m.Execute(ctx, db) {
return false
@@ -276,7 +375,7 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
// MiddlewareExecutor is the function type for middleware logic.
// Returns true to continue execution, false to block it.
// 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.
// Can be synchronous (blocking) or asynchronous (non-blocking).
@@ -288,19 +387,19 @@ type Middleware[T DbContext] struct {
}
// NewMiddleware creates a new synchronous middleware.
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] {
return &Middleware[T]{name, executor, 0, false}
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) Middleware[T] {
return Middleware[T]{name, executor, 0, false}
}
// 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
return m
}
// SetAsync marks the middleware to run asynchronously.
// 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
return m
}
@@ -308,7 +407,7 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
// Execute runs the middleware.
// If async, runs in a goroutine and returns true immediately.
// 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 {
ctx := *ctx // copy context to avoid race condition
go func(ctx MsgContext) {
+18 -2
View File
@@ -6,7 +6,7 @@ import (
)
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 {
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)
}
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 {
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)
}
}
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.
// DO NOT call builder methods concurrently or after Execute().
func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] {
return &Runner[T]{
func NewRunner[T DbContext](name string, fn RunnerFn[T]) Runner[T] {
return Runner[T]{
name: name,
fn: fn,
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.
// If true, the runner runs only once.
// 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
return r
}
@@ -55,7 +55,7 @@ func (r *Runner[T]) Onetime(onetime bool) *Runner[T] {
// If false, the runner blocks the caller during execution.
//
// 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
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
// 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
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"
"time"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
)
// 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.
// Always call CloseApi() when done to release resources.
// Always call Close() when done to release resources.
func NewAPI(opts *APIOpts) *API {
l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API")
l.AddWriter(l.CreateJsonStdoutWriter())
l := utils.CreateLogger("API", utils.GetLoggerLevel())
client := opts.client
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.
// See https://core.telegram.org/bots/api
func (api *API) CloseApi() error {
func (api *API) Close() error {
api.pool.stop()
if api.client != nil {
api.client.CloseIdleConnections()
}
return api.logger.Close()
}
@@ -150,37 +152,29 @@ type ApiResponse[R any] struct {
Parameters *ResponseParameters `json:"parameters,omitempty"`
}
// TelegramRequest is an internal helper struct.
// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged.
// Instead, use explicit methods like SendMessage, GetUpdates, etc.
// TelegramRequest is a low-level Telegram API request wrapper.
//
// Why? Because using generics with arbitrary types P and R leads to:
// - No compile-time validation of parameters
// - No IDE autocompletion
// - Runtime panics on malformed JSON
// - Hard-to-debug errors
//
// Recommended: Define specific methods for each Telegram method (see below).
// Prefer method-specific helpers such as SendMessage or GetUpdates. TelegramRequest
// bypasses method-specific parameter types and convenience helpers, so callers are
// responsible for using the correct method name and compatible request and response types.
// In that sense it is an unsafe escape hatch compared with the typed API surface.
type TelegramRequest[R, P any] struct {
method string
params P
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] {
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.
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
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) {
var zero R
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)
}
// readBody reads and limits response body to prevent memory exhaustion.
// Telegram responses are typically small (<1MB), but we cap at 10MB.
// Internal helper that reads and caps a Telegram response body.
func readBody(body io.ReadCloser) ([]byte, error) {
reader := io.LimitReader(body, 10<<20) // 10 MB
return io.ReadAll(reader)
}
// parseBody unmarshals a Telegram API response into a typed ApiResponse.
// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle.
// Internal helper that parses a typed Telegram API response body.
func parseBody[R any](data []byte) (ApiResponse[R], error) {
var resp ApiResponse[R]
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)
}
type closingTransport struct {
roundTripFunc
closed bool
}
func (t *closingTransport) CloseIdleConnections() {
t.closed = true
}
func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
var gotPath string
var gotAcceptEncoding string
@@ -35,8 +44,8 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
SetHTTPClient(client),
)
defer func() {
if err := api.CloseApi(); err != nil {
t.Fatalf("CloseApi returned error: %v", err)
if err := api.Close(); err != nil {
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)
}
}
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")
}
}
+81 -1
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SendPhotoP holds parameters for the sendPhoto method.
// See https://core.telegram.org/bots/api#sendphoto
type SendPhotoP struct {
@@ -32,6 +34,14 @@ func (api *API) SendPhoto(params SendPhotoP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendaudio
type SendAudioP struct {
@@ -47,6 +57,7 @@ type SendAudioP struct {
Duration int `json:"duration,omitempty"`
Performer string `json:"performer,omitempty"`
Title string `json:"title,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"`
@@ -65,6 +76,14 @@ func (api *API) SendAudio(params SendAudioP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#senddocument
type SendDocumentP struct {
@@ -74,9 +93,11 @@ type SendDocumentP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Document string `json:"document"`
Thumbnail string `json:"thumbnail,omitempty"`
Caption string `json:"caption,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"`
ProtectContent bool `json:"protect_content,omitempty"`
@@ -95,6 +116,14 @@ func (api *API) SendDocument(params SendDocumentP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendvideo
type SendVideoP struct {
@@ -104,6 +133,7 @@ type SendVideoP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Video string `json:"video"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
@@ -134,6 +164,14 @@ func (api *API) SendVideo(params SendVideoP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendanimation
type SendAnimationP struct {
@@ -143,6 +181,7 @@ type SendAnimationP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Animation string `json:"animation"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
@@ -169,6 +208,14 @@ func (api *API) SendAnimation(params SendAnimationP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendvoice
type SendVoiceP struct {
@@ -194,11 +241,19 @@ type SendVoiceP struct {
// SendVoice sends a voice note.
// 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)
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.
// See https://core.telegram.org/bots/api#sendvideonote
type SendVideoNoteP struct {
@@ -208,6 +263,7 @@ type SendVideoNoteP struct {
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
VideoNote string `json:"video_note"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"`
Length int `json:"length,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"`
@@ -227,6 +283,14 @@ func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendpaidmedia
type SendPaidMediaP struct {
@@ -258,6 +322,14 @@ func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendmediagroup
type SendMediaGroupP struct {
@@ -280,3 +352,11 @@ func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) {
req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
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"`
Media string `json:"media"`
Cover string `json:"cover"`
StartTimestamp int64 `json:"start_timestamp"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
SupportsStreaming bool `json:"supports_streaming"`
Cover *string `json:"cover,omitempty"`
StartTimestamp *int64 `json:"start_timestamp,omitempty"`
Width *int `json:"width,omitempty"`
Height *int `json:"height,omitempty"`
Duration *int `json:"duration,omitempty"`
SupportsStreaming *bool `json:"supports_streaming,omitempty"`
}
// PhotoSize represents one size of a photo or a file/sticker thumbnail.
+150 -4
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SetMyCommandsP holds parameters for the setMyCommands method.
// See https://core.telegram.org/bots/api#setmycommands
type SetMyCommandsP struct {
@@ -16,6 +18,14 @@ func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#deletemycommands
type DeleteMyCommandsP struct {
@@ -31,6 +41,14 @@ func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getmycommands
type GetMyCommands struct {
@@ -45,6 +63,14 @@ func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) {
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.
// See https://core.telegram.org/bots/api#setmyname
type SetMyName struct {
@@ -60,6 +86,14 @@ func (api *API) SetMyName(params SetMyName) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getmyname
type GetMyName struct {
@@ -73,6 +107,14 @@ func (api *API) GetMyName(params GetMyName) (BotName, error) {
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.
// See https://core.telegram.org/bots/api#setmydescription
type SetMyDescription struct {
@@ -88,6 +130,14 @@ func (api *API) SetMyDescription(params SetMyDescription) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getmydescription
type GetMyDescription struct {
@@ -101,6 +151,14 @@ func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error
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.
// See https://core.telegram.org/bots/api#setmyshortdescription
type SetMyShortDescription struct {
@@ -116,6 +174,14 @@ func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error
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.
// See https://core.telegram.org/bots/api#getmyshortdescription
type GetMyShortDescription struct {
@@ -129,6 +195,14 @@ func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDes
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.
// See https://core.telegram.org/bots/api#setmyprofilephoto
type SetMyProfilePhotoP struct {
@@ -143,6 +217,14 @@ func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) {
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.
// Returns true on success.
// See https://core.telegram.org/bots/api#removemyprofilephoto
@@ -151,10 +233,18 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) {
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.
// See https://core.telegram.org/bots/api#setchatmenubutton
type SetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"`
ChatID int64 `json:"chat_id,omitempty"`
MenuButton MenuButtonType `json:"menu_button"`
}
@@ -166,19 +256,35 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getchatmenubutton
type GetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"`
ChatID int64 `json:"chat_id,omitempty"`
}
// GetChatMenuButton returns the current menu button for the given chat.
// See https://core.telegram.org/bots/api#getchatmenubutton
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) {
req := NewRequest[BaseMenuButton]("getChatMenuButton", params)
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (MenuButton, error) {
req := NewRequest[MenuButton]("getChatMenuButton", params)
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.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights
type SetMyDefaultAdministratorRightsP struct {
@@ -194,6 +300,14 @@ func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministrator
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.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights
type GetMyDefaultAdministratorRightsP struct {
@@ -207,6 +321,14 @@ func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministrator
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.
// See https://core.telegram.org/bots/api#getavailablegifts
func (api *API) GetAvailableGifts() (Gifts, error) {
@@ -214,6 +336,14 @@ func (api *API) GetAvailableGifts() (Gifts, error) {
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.
// See https://core.telegram.org/bots/api#sendgift
type SendGiftP struct {
@@ -234,6 +364,14 @@ func (api *API) SendGift(params SendGiftP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#giftpremiumsubscription
type GiftPremiumSubscriptionP struct {
@@ -252,3 +390,11 @@ func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool,
req := NewRequest[bool]("giftPremiumSubscription", params)
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)
}
+9 -4
View File
@@ -54,7 +54,9 @@ type BotShortDescription struct {
type InputProfilePhotoType string
const (
// InputProfilePhotoStaticType identifies a static profile photo input.
InputProfilePhotoStaticType InputProfilePhotoType = "static"
// InputProfilePhotoAnimatedType identifies an animated profile photo input.
InputProfilePhotoAnimatedType InputProfilePhotoType = "animated"
)
@@ -75,17 +77,20 @@ type InputProfilePhoto struct {
type MenuButtonType string
const (
// MenuButtonCommandsType identifies a commands menu button.
MenuButtonCommandsType MenuButtonType = "commands"
// MenuButtonWebAppType identifies a web app menu button.
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
type BaseMenuButton struct {
type MenuButton struct {
Type MenuButtonType `json:"type"`
// WebApp fields (for web_app button)
Text string `json:"text"`
WebApp WebAppInfo `json:"web_app"`
Text *string `json:"text"`
WebApp *WebAppInfo `json:"web_app"`
}
+194
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// VerifyUserP holds parameters for the verifyUser method.
// See https://core.telegram.org/bots/api#verifyuser
type VerifyUserP struct {
@@ -15,6 +17,14 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#verifychat
type VerifyChatP struct {
@@ -30,6 +40,14 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#removeuserverification
type RemoveUserVerificationP struct {
@@ -44,6 +62,14 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er
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.
// See https://core.telegram.org/bots/api#removechatverification
type RemoveChatVerificationP struct {
@@ -58,6 +84,14 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er
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.
// See https://core.telegram.org/bots/api#readbusinessmessage
type ReadBusinessMessageP struct {
@@ -74,6 +108,14 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getbusinessconnection
type GetBusinessConnectionP struct {
@@ -87,6 +129,14 @@ func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessCo
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.
// See https://core.telegram.org/bots/api#deletebusinessmessages
type DeleteBusinessMessagesP struct {
@@ -102,6 +152,14 @@ func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, er
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.
// See https://core.telegram.org/bots/api#setbusinessaccountname
type SetBusinessAccountNameP struct {
@@ -118,6 +176,14 @@ func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, er
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.
// See https://core.telegram.org/bots/api#setbusinessaccountusername
type SetBusinessAccountUsernameP struct {
@@ -133,6 +199,14 @@ func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (
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.
// See https://core.telegram.org/bots/api#setbusinessaccountbio
type SetBusinessAccountBioP struct {
@@ -148,6 +222,14 @@ func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, erro
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.
// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto
type SetBusinessAccountProfilePhoto struct {
@@ -164,6 +246,14 @@ func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfileP
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.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
type RemoveBusinessAccountProfilePhotoP struct {
@@ -179,6 +269,14 @@ func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountPr
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.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
type SetBusinessAccountGiftSettingsP struct {
@@ -195,6 +293,14 @@ func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSett
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.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
type GetBusinessAccountStarBalanceP struct {
@@ -208,6 +314,14 @@ func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalan
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.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars
type TransferBusinessAccountStarsP struct {
@@ -223,6 +337,14 @@ func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars
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.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts
type GetBusinessAccountGiftsP struct {
@@ -246,6 +368,14 @@ func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedG
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.
// See https://core.telegram.org/bots/api#convertgifttostars
type ConvertGiftToStarsP struct {
@@ -261,6 +391,14 @@ func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#upgradegift
type UpgradeGiftP struct {
@@ -278,6 +416,14 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#transfergift
type TransferGiftP struct {
@@ -295,6 +441,14 @@ func (api *API) TransferGift(params TransferGiftP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#poststory
type PostStoryP struct {
@@ -318,6 +472,14 @@ func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) {
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.
// See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryVideo(params PostStoryP) (Story, error) {
@@ -325,6 +487,14 @@ func (api *API) PostStoryVideo(params PostStoryP) (Story, error) {
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.
// See https://core.telegram.org/bots/api#repoststory
type RepostStoryP struct {
@@ -344,6 +514,14 @@ func (api *API) RepostStory(params RepostStoryP) (Story, error) {
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.
// See https://core.telegram.org/bots/api#editstory
type EditStoryP struct {
@@ -365,6 +543,14 @@ func (api *API) EditStory(params EditStoryP) (Story, error) {
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.
// See https://core.telegram.org/bots/api#deletestory
type DeleteStoryP struct {
@@ -379,3 +565,11 @@ func (api *API) DeleteStory(params DeleteStoryP) (bool, error) {
req := NewRequest[bool]("deleteStory", params)
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)
}
+7
View File
@@ -72,7 +72,9 @@ type BusinessMessagesDeleted struct {
type InputStoryContentType string
const (
// InputStoryContentPhotoType identifies photo story content.
InputStoryContentPhotoType InputStoryContentType = "photo"
// InputStoryContentVideoType identifies video story content.
InputStoryContentVideoType InputStoryContentType = "video"
)
@@ -106,10 +108,15 @@ type StoryAreaPosition struct {
type StoryAreaTypeType string
const (
// StoryAreaTypeLocationType identifies a location story area.
StoryAreaTypeLocationType StoryAreaTypeType = "location"
// StoryAreaTypeReactionType identifies a suggested reaction story area.
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"
)
+258
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// BanChatMemberP holds parameters for the banChatMember method.
// See https://core.telegram.org/bots/api#banchatmember
type BanChatMemberP struct {
@@ -17,6 +19,14 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#unbanchatmember
type UnbanChatMemberP struct {
@@ -33,6 +43,14 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#restrictchatmember
type RestrictChatMemberP struct {
@@ -51,6 +69,14 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#promotechatmember
type PromoteChatMember struct {
@@ -84,6 +110,14 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
type SetChatAdministratorCustomTitleP struct {
@@ -100,6 +134,14 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto
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.
// See https://core.telegram.org/bots/api#setchatmembertag
type SetChatMemberTagP struct {
@@ -116,6 +158,14 @@ func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#banchatsenderchat
type BanChatSenderChatP struct {
@@ -131,6 +181,14 @@ func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#unbanchatsenderchat
type UnbanChatSenderChatP struct {
@@ -146,6 +204,14 @@ func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setchatpermissions
type SetChatPermissionsP struct {
@@ -162,6 +228,14 @@ func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#exportchatinvitelink
type ExportChatInviteLinkP struct {
@@ -176,6 +250,14 @@ func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, erro
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.
// See https://core.telegram.org/bots/api#createchatinvitelink
type CreateChatInviteLinkP struct {
@@ -194,6 +276,14 @@ func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLi
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.
// See https://core.telegram.org/bots/api#editchatinvitelink
type EditChatInviteLinkP struct {
@@ -214,6 +304,14 @@ func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink,
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.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
type CreateChatSubscriptionInviteLinkP struct {
@@ -231,6 +329,14 @@ func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionIn
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.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
type EditChatSubscriptionInviteLinkP struct {
@@ -247,6 +353,14 @@ func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInvite
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.
// See https://core.telegram.org/bots/api#revokechatinvitelink
type RevokeChatInviteLinkP struct {
@@ -262,6 +376,14 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi
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.
// See https://core.telegram.org/bots/api#approvechatjoinrequest
type ApproveChatJoinRequestP struct {
@@ -277,6 +399,14 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er
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.
// See https://core.telegram.org/bots/api#declinechatjoinrequest
type DeclineChatJoinRequestP struct {
@@ -292,6 +422,14 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er
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.
// See https://core.telegram.org/bots/api#setchatphoto
type SetChatPhotoP struct {
@@ -325,6 +463,14 @@ func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setchattitle
type SetChatTitleP struct {
@@ -340,6 +486,14 @@ func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setchatdescription
type SetChatDescriptionP struct {
@@ -355,6 +509,14 @@ func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#pinchatmessage
type PinChatMessageP struct {
@@ -372,6 +534,14 @@ func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#unpinchatmessage
type UnpinChatMessageP struct {
@@ -388,6 +558,14 @@ func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#unpinallchatmessages
type UnpinAllChatMessagesP struct {
@@ -402,6 +580,14 @@ func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error)
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.
// See https://core.telegram.org/bots/api#leavechat
type LeaveChatP struct {
@@ -416,6 +602,14 @@ func (api *API) LeaveChat(params LeaveChatP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getchat
type GetChatP struct {
@@ -429,6 +623,14 @@ func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) {
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.
// See https://core.telegram.org/bots/api#getchatadministrators
type GetChatAdministratorsP struct {
@@ -442,6 +644,14 @@ func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMemb
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.
// See https://core.telegram.org/bots/api#getchatmembercount
type GetChatMembersCountP struct {
@@ -455,6 +665,14 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) {
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.
// See https://core.telegram.org/bots/api#getchatmember
type GetChatMemberP struct {
@@ -469,6 +687,14 @@ func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) {
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.
// See https://core.telegram.org/bots/api#setchatstickerset
type SetChatStickerSetP struct {
@@ -484,6 +710,14 @@ func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#deletechatstickerset
type DeleteChatStickerSetP struct {
@@ -498,6 +732,14 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error)
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.
// See https://core.telegram.org/bots/api#getuserchatboosts
type GetUserChatBoostsP struct {
@@ -512,6 +754,14 @@ func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, er
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.
// See https://core.telegram.org/bots/api#getchatgifts
type GetChatGiftsP struct {
@@ -534,3 +784,11 @@ func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) {
req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
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)
}
+11 -1
View File
@@ -17,9 +17,13 @@ type Chat struct {
type ChatType string
const (
// ChatTypePrivate identifies a private chat.
ChatTypePrivate ChatType = "private"
// ChatTypeGroup identifies a basic group chat.
ChatTypeGroup ChatType = "group"
// ChatTypeSupergroup identifies a supergroup chat.
ChatTypeSupergroup ChatType = "supergroup"
// ChatTypeChannel identifies a channel chat.
ChatTypeChannel ChatType = "channel"
)
@@ -143,11 +147,17 @@ type ChatInviteLink struct {
type ChatMemberStatusType string
const (
// ChatMemberStatusOwner identifies a chat owner.
ChatMemberStatusOwner ChatMemberStatusType = "owner"
// ChatMemberStatusAdministrator identifies a chat administrator.
ChatMemberStatusAdministrator ChatMemberStatusType = "administrator"
// ChatMemberStatusMember identifies a regular member.
ChatMemberStatusMember ChatMemberStatusType = "member"
// ChatMemberStatusRestricted identifies a restricted member.
ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
// ChatMemberStatusLeft identifies a user who left the chat.
ChatMemberStatusLeft ChatMemberStatusType = "left"
// ChatMemberStatusBanned identifies a banned user.
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
)
@@ -214,7 +224,7 @@ type ChatBoostSource struct {
// ChatBoost represents a boost added to a chat.
// See https://core.telegram.org/bots/api#chatboost
type ChatBoost struct {
BoostID int `json:"boost_id"`
BoostID string `json:"boost_id"`
AddDate int `json:"add_date"`
ExpirationDate int `json:"expiration_date"`
Source ChatBoostSource `json:"source"`
+7
View File
@@ -2,7 +2,14 @@ package tgapi
import "errors"
// ErrRateLimit reports that a request exceeded the configured rate limiter.
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")
// ErrPoolQueueFull reports that the internal request queue is 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")
+106
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID.
type BaseForumTopicP struct {
ChatID int64 `json:"chat_id"`
@@ -13,6 +15,14 @@ func (api *API) GetForumTopicIconStickers() ([]Sticker, error) {
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.
// See https://core.telegram.org/bots/api#createforumtopic
type CreateForumTopicP struct {
@@ -30,6 +40,14 @@ func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) {
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.
// See https://core.telegram.org/bots/api#editforumtopic
type EditForumTopicP struct {
@@ -46,6 +64,14 @@ func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) {
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#closeforumtopic
@@ -54,6 +80,14 @@ func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) {
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#reopenforumtopic
@@ -62,6 +96,14 @@ func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) {
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#deleteforumtopic
@@ -70,6 +112,14 @@ func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) {
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#unpinallforumtopicmessages
@@ -78,6 +128,14 @@ func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error)
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.
type BaseGeneralForumTopicP struct {
ChatID int64 `json:"chat_id"`
@@ -98,6 +156,14 @@ func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, erro
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#closegeneralforumtopic
@@ -106,6 +172,14 @@ func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, err
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic
@@ -114,6 +188,14 @@ func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic
@@ -122,6 +204,14 @@ func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, erro
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.
// Returns True on success.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic
@@ -130,6 +220,14 @@ func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
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.
// Returns True on success.
// 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)
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
import "context"
// SendGameP holds parameters for the sendGame method.
// See https://core.telegram.org/bots/api#sendgame
type SendGameP struct {
@@ -24,6 +26,14 @@ func (api *API) SendGame(params SendGameP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#setgamescore
type SetGameScoreP struct {
@@ -52,6 +62,21 @@ func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) {
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.
// See https://core.telegram.org/bots/api#getgamehighscores
type GetGameHighScoresP struct {
@@ -67,3 +92,11 @@ func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, e
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
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
import "context"
// AnswerInlineQueryP holds parameters for the answerInlineQuery method.
// See https://core.telegram.org/bots/api#answerinlinequery
type AnswerInlineQueryP struct {
@@ -19,6 +21,14 @@ func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#answerwebappquery
type AnswerWebAppQueryP struct {
@@ -33,6 +43,14 @@ func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage,
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.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage
type SavePreparedInlineMessageP struct {
@@ -50,3 +68,11 @@ func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (Pr
req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
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)
}
+268 -1
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// SendMessageP holds parameters for the sendMessage method.
// See https://core.telegram.org/bots/api#sendmessage
type SendMessageP struct {
@@ -29,6 +31,14 @@ func (api *API) SendMessage(params SendMessageP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#forwardmessage
type ForwardMessageP struct {
@@ -53,6 +63,14 @@ func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#forwardmessages
type ForwardMessagesP struct {
@@ -74,6 +92,14 @@ func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) {
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.
// See https://core.telegram.org/bots/api#copymessage
type CopyMessageP struct {
@@ -110,6 +136,17 @@ func (api *API) CopyMessage(params CopyMessageP) (int, error) {
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.
// See https://core.telegram.org/bots/api#copymessages
type CopyMessagesP struct {
@@ -132,6 +169,14 @@ func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) {
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.
// See https://core.telegram.org/bots/api#sendlocation
type SendLocationP struct {
@@ -164,6 +209,14 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendvenue
type SendVenueP struct {
@@ -198,6 +251,14 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendcontact
type SendContactP struct {
@@ -228,6 +289,14 @@ func (api *API) SendContact(params SendContactP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendpoll
type SendPollP struct {
@@ -266,6 +335,14 @@ func (api *API) SendPoll(params SendPollP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendchecklist
type SendChecklistP struct {
@@ -288,6 +365,14 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#senddice
type SendDiceP struct {
@@ -315,6 +400,14 @@ func (api *API) SendDice(params SendDiceP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#sendmessagedraft
type SendMessageDraftP struct {
@@ -334,6 +427,14 @@ func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#sendchataction
type SendChatActionP struct {
@@ -351,6 +452,14 @@ func (api *API) SendChatAction(params SendChatActionP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setmessagereaction
type SetMessageReactionP struct {
@@ -368,6 +477,14 @@ func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#editmessagetext
type EditMessageTextP struct {
@@ -377,6 +494,8 @@ type EditMessageTextP struct {
InlineMessageID string `json:"inline_message_id,omitempty"`
Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
}
@@ -396,6 +515,21 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error)
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.
// See https://core.telegram.org/bots/api#editmessagecaption
type EditMessageCaptionP struct {
@@ -405,6 +539,8 @@ type EditMessageCaptionP struct {
InlineMessageID string `json:"inline_message_id,omitempty"`
Caption string `json:"caption"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
}
@@ -424,6 +560,21 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e
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.
// See https://core.telegram.org/bots/api#editmessagemedia
type EditMessageMediaP struct {
@@ -451,6 +602,21 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error
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.
// See https://core.telegram.org/bots/api#editmessagelivelocation
type EditMessageLiveLocationP struct {
@@ -484,6 +650,21 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag
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.
// See https://core.telegram.org/bots/api#stopmessagelivelocation
type StopMessageLiveLocationP struct {
@@ -510,6 +691,21 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag
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.
type EditMessageChecklistP struct {
BusinessConnectionID string `json:"business_connection_id"`
@@ -526,6 +722,14 @@ func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, err
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.
// See https://core.telegram.org/bots/api#editmessagereplymarkup
type EditMessageReplyMarkupP struct {
@@ -552,13 +756,28 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message,
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.
// See https://core.telegram.org/bots/api#stoppoll
type StopPollP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_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.
@@ -569,6 +788,14 @@ func (api *API) StopPoll(params StopPollP) (Poll, error) {
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.
// See https://core.telegram.org/bots/api#approvesuggestedpost
type ApproveSuggestedPostP struct {
@@ -585,6 +812,14 @@ func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error)
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.
// See https://core.telegram.org/bots/api#declinesuggestedpost
type DeclineSuggestedPostP struct {
@@ -601,6 +836,14 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error)
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.
// See https://core.telegram.org/bots/api#deletemessage
type DeleteMessageP struct {
@@ -616,6 +859,14 @@ func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#deletemessages
type DeleteMessagesP struct {
@@ -631,6 +882,14 @@ func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#answercallbackquery
type AnswerCallbackQueryP struct {
@@ -648,3 +907,11 @@ func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
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)
}
+40 -6
View File
@@ -1,6 +1,6 @@
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.
type MessageID struct {
@@ -45,7 +45,7 @@ type Message struct {
Text string `json:"text"`
Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"`
Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"`
Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
@@ -77,25 +77,45 @@ type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage }
type MessageEntityType string
const (
// MessageEntityMention identifies an @mention entity.
MessageEntityMention MessageEntityType = "mention"
// MessageEntityHashtag identifies a hashtag entity.
MessageEntityHashtag MessageEntityType = "hashtag"
// MessageEntityCashtag identifies a cashtag entity.
MessageEntityCashtag MessageEntityType = "cashtag"
// MessageEntityBotCommand identifies a bot command entity.
MessageEntityBotCommand MessageEntityType = "bot_command"
// MessageEntityUrl identifies a URL entity.
MessageEntityUrl MessageEntityType = "url"
// MessageEntityEmail identifies an email entity.
MessageEntityEmail MessageEntityType = "email"
// 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"
// MessageEntityCode identifies inline code.
MessageEntityCode MessageEntityType = "code"
// MessageEntityPre identifies a preformatted block.
MessageEntityPre MessageEntityType = "pre"
// MessageEntityTextLink identifies linked text.
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"
)
@@ -124,7 +144,7 @@ type ReplyParameters struct {
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
Quote string `json:"quote,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"`
ChecklistTaskID int `json:"checklist_task_id,omitempty"`
}
@@ -166,8 +186,11 @@ type InlineKeyboardMarkup struct {
type KeyboardButtonStyle string
const (
// KeyboardButtonStyleDanger marks a destructive keyboard button.
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
// KeyboardButtonStyleSuccess marks a confirmatory keyboard button.
KeyboardButtonStyleSuccess KeyboardButtonStyle = "success"
// KeyboardButtonStylePrimary marks a primary keyboard button.
KeyboardButtonStylePrimary KeyboardButtonStyle = "primary"
)
@@ -257,14 +280,16 @@ type CallbackQuery struct {
type InputPollOption struct {
Text string `json:"text"`
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.
type PollType string
const (
// PollTypeRegular identifies a regular poll.
PollTypeRegular PollType = "regular"
// PollTypeQuiz identifies a quiz poll.
PollTypeQuiz PollType = "quiz"
)
@@ -273,14 +298,14 @@ type InputChecklistTask struct {
ID int `json:"id"`
Text string `json:"text"`
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.
type InputChecklist struct {
Title string `json:"title"`
ParseMode ParseMode `json:"parse_mode,omitempty"`
TitleEntities []*MessageEntity `json:"title_entities,omitempty"`
TitleEntities []MessageEntity `json:"title_entities,omitempty"`
Tasks []InputChecklistTask `json:"tasks"`
OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"`
OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"`
@@ -290,14 +315,23 @@ type InputChecklist struct {
type ChatActionType string
const (
// ChatActionTyping tells Telegram the bot is typing.
ChatActionTyping ChatActionType = "typing"
// ChatActionUploadPhoto tells Telegram the bot is uploading a photo.
ChatActionUploadPhoto ChatActionType = "upload_photo"
// ChatActionUploadVideo tells Telegram the bot is uploading a video.
ChatActionUploadVideo ChatActionType = "upload_video"
// 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"
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
)
+106 -7
View File
@@ -6,7 +6,7 @@ import (
"io"
"net/http"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
// UpdateParams holds parameters for the getUpdates method.
@@ -25,6 +25,14 @@ func (api *API) GetMe() (User, error) {
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.
// Returns true on success.
// See https://core.telegram.org/bots/api#logout
@@ -33,14 +41,30 @@ func (api *API) LogOut() (bool, error) {
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.
// 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)
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.
// See https://core.telegram.org/bots/api#getupdates
func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
@@ -48,16 +72,19 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
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) {
req := NewRequest[[]Update]("getUpdates", params)
return req.DoWithContext(ctx, api)
}
// SetWebhookP holds parameters for the setWebhook method.
// To upload a self-signed certificate, use Uploader.SetWebhook.
// See https://core.telegram.org/bots/api#setwebhook
type SetWebhookP struct {
URL string `json:"url"`
Certificate string `json:"certificate,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
@@ -66,6 +93,7 @@ type SetWebhookP struct {
}
// SetWebhook sets a webhook URL for incoming updates.
// For certificate upload, use Uploader.SetWebhook.
// Returns true on success.
// See https://core.telegram.org/bots/api#setwebhook
func (api *API) SetWebhook(params SetWebhookP) (bool, error) {
@@ -73,6 +101,15 @@ func (api *API) SetWebhook(params SetWebhookP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#deletewebhook
type DeleteWebhookP struct {
@@ -87,6 +124,14 @@ func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getwebhookinfo
func (api *API) GetWebhookInfo() (WebhookInfo, error) {
@@ -94,6 +139,14 @@ func (api *API) GetWebhookInfo() (WebhookInfo, error) {
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.
// See https://core.telegram.org/bots/api#getfile
type GetFileP struct {
@@ -107,17 +160,63 @@ func (api *API) GetFile(params GetFileP) (File, error) {
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.
// 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
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 := ""
if api.useTestServer {
methodPrefix = "/test"
}
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 {
return nil, err
}
@@ -127,15 +226,15 @@ func (api *API) GetFileByLink(link string) ([]byte, error) {
if err != nil {
return nil, err
}
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10))
if readErr != nil {
return nil, fmt.Errorf("unexpected status %d", res.StatusCode)
}
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),
)
defer func() {
if err := api.CloseApi(); err != nil {
t.Fatalf("CloseApi returned error: %v", err)
if err := api.Close(); err != nil {
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) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -60,8 +98,8 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
SetHTTPClient(client),
)
defer func() {
if err := api.CloseApi(); err != nil {
t.Fatalf("CloseApi returned error: %v", err)
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
@@ -97,8 +135,8 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
SetHTTPClient(client),
)
defer func() {
if err := api.CloseApi(); err != nil {
t.Fatalf("CloseApi returned error: %v", err)
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
+2 -2
View File
@@ -10,8 +10,8 @@ const (
ParseHTML ParseMode = "HTML"
// ParseMD enables legacy Markdown style parsing.
ParseMD ParseMode = "Markdown"
// ParseNone disables any parsing.
ParseNone ParseMode = "None"
// ParseNone disables parse_mode and leaves plain-text requests unannotated.
ParseNone ParseMode = ""
)
// 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
import "context"
// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method.
// See https://core.telegram.org/bots/api#setpassportdataerrors
type SetPassportDataErrorsP struct {
@@ -14,3 +16,11 @@ func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, erro
req := NewRequest[bool]("setPassportDataErrors", params)
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)
}
+34 -1
View File
@@ -1,9 +1,10 @@
package tgapi
import "context"
// SendInvoiceP holds parameters for the sendInvoice method.
// See https://core.telegram.org/bots/api#sendinvoice
type SendInvoiceP struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -47,6 +48,14 @@ func (api *API) SendInvoice(params SendInvoiceP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#createinvoicelink
type CreateInvoiceLinkP struct {
@@ -83,6 +92,14 @@ func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) {
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.
// See https://core.telegram.org/bots/api#answershippingquery
type AnswerShippingQueryP struct {
@@ -100,6 +117,14 @@ func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#answerprecheckoutquery
type AnswerPreCheckoutQueryP struct {
@@ -115,3 +140,11 @@ func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, er
req := NewRequest[bool]("answerPreCheckoutQuery", params)
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"
)
// workerPool — приватная структура, управляющая пулом воркеров.
// Внешний код не может создавать или напрямую взаимодействовать с этой структурой.
// Используется только через экспортируемые методы newWorkerPool, start, stop, submit.
type workerPool struct {
taskCh chan requestEnvelope // канал для принятия задач (буферизованный)
queueSize int // максимальный размер очереди
workers int // количество воркеров (горутин)
wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке
quit chan struct{} // канал для сигнала остановки
stopOnce sync.Once // гарантирует идемпотентную остановку пула
started bool // флаг, указывающий, запущен ли пул
stopped bool // флаг, указывающий, что пул остановлен
startedMu sync.Mutex // мьютекс для безопасного доступа к started
taskCh chan requestEnvelope
queueSize int
workers int
wg sync.WaitGroup
quit chan struct{}
stopOnce sync.Once
started bool
stopped bool
startedMu sync.Mutex
}
// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата.
// Используется только внутри пакета для передачи задач воркерам.
type requestEnvelope struct {
ctx context.Context // контекст конкретной задачи
doFunc func(context.Context) (any, error) // функция, выполняющая запрос
resultCh chan requestResult // канал, через который воркер вернёт результат
ctx context.Context
doFunc func(context.Context) (any, error)
resultCh chan requestResult
}
// requestResult — приватная структура, представляющая результат выполнения задачи.
// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult.
type requestResult struct {
value any // значение, возвращённое задачей
err error // ошибка, если возникла
value any
err error
}
// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди.
// Это единственный способ создать workerPool — внешний код не может создать его напрямую.
func newWorkerPool(workers int, queueSize int) *workerPool {
if workers <= 0 {
workers = 1 // защита от некорректных значений
workers = 1
}
if queueSize <= 0 {
queueSize = 100 // разумный дефолт
queueSize = 100
}
return &workerPool{
@@ -53,43 +44,32 @@ func newWorkerPool(workers int, queueSize int) *workerPool {
}
}
// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди.
// Метод идемпотентен: если пул уже запущен — ничего не делает.
// Должен вызываться перед первым вызовом submit.
func (p *workerPool) start() {
p.startedMu.Lock()
defer p.startedMu.Unlock()
if p.started {
return // уже запущен — ничего не делаем
return
}
p.started = true
// Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker() // запускаем горутину
go p.worker()
}
}
// stop останавливает пул воркеров.
// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач.
// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта.
func (p *workerPool) stop() {
p.stopOnce.Do(func() {
p.startedMu.Lock()
p.stopped = true
p.started = false
close(p.quit) // сигнал для всех воркеров — выйти из цикла
close(p.quit)
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) {
p.startedMu.Lock()
if p.stopped || !p.started {
@@ -97,55 +77,39 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any,
return nil, ErrPoolStopped
}
// Проверяем, не превышена ли очередь
if len(p.taskCh) >= p.queueSize {
p.startedMu.Unlock()
return nil, ErrPoolQueueFull
}
// Создаём канал для результата — буферизованный, чтобы не блокировать воркера
resultCh := make(chan requestResult, 1)
// Создаём обёртку задачи
envelope := requestEnvelope{
ctx: ctx,
doFunc: do,
resultCh: resultCh,
}
// Пытаемся отправить задачу в очередь
select {
case <-ctx.Done():
p.startedMu.Unlock()
// Клиент отменил операцию до отправки — возвращаем ошибку отмены
return nil, ctx.Err()
case p.taskCh <- envelope:
p.startedMu.Unlock()
// Успешно отправлено — возвращаем канал для чтения результата
return resultCh, nil
default:
p.startedMu.Unlock()
// Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай
return nil, ErrPoolQueueFull
}
}
// worker — приватная горутина, выполняющая задачи из очереди.
// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки.
// При получении задачи:
// - вызывает doFunc с контекстом
// - записывает результат в resultCh
// - закрывает канал, чтобы клиент мог прочитать и завершить
//
// После закрытия quit-канала — воркер завершает работу.
func (p *workerPool) worker() {
defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины
defer p.wg.Done()
for {
select {
case <-p.quit:
// Получен сигнал остановки — дренируем очередь и выходим.
// После stop() новые задачи не принимаются.
// Drain queued work after stop. No new tasks are accepted.
for {
select {
case envelope := <-p.taskCh:
@@ -162,14 +126,10 @@ func (p *workerPool) worker() {
}
func (p *workerPool) executeEnvelope(envelope requestEnvelope) {
// Выполняем задачу с переданным контекстом (клиентский или общий)
value, err := envelope.doFunc(envelope.ctx)
// Записываем результат в канал — не блокируем, т.к. буфер 1
envelope.resultCh <- requestResult{
value: value,
err: err,
}
// Закрываем канал — клиент знает, что результат пришёл и больше не будет
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
import "context"
// GetStarTransactionsP holds parameters for the getStarTransactions method.
// See https://core.telegram.org/bots/api#getstartransactions
type GetStarTransactionsP struct {
@@ -14,6 +16,14 @@ func (api *API) GetMyStarBalance() (StarAmount, error) {
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.
// See https://core.telegram.org/bots/api#getstartransactions
func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) {
@@ -21,6 +31,14 @@ func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactio
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.
// See https://core.telegram.org/bots/api#refundstarpayment
type RefundStarPaymentP struct {
@@ -36,6 +54,14 @@ func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#edituserstarsubscription
type EditUserStarSubscriptionP struct {
@@ -51,3 +77,11 @@ func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool
req := NewRequest[bool]("editUserStarSubscription", params)
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
import "context"
// SendStickerP holds parameters for the sendSticker method.
// See https://core.telegram.org/bots/api#sendsticker
type SendStickerP struct {
@@ -14,6 +16,10 @@ type SendStickerP struct {
ProtectContent bool `json:"protect_content,omitempty"`
AllowPaidBroadcast bool `json:"allow_paid_broadcast,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.
@@ -23,6 +29,14 @@ func (api *API) SendSticker(params SendStickerP) (Message, error) {
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.
// See https://core.telegram.org/bots/api#getstickerset
type GetStickerSetP struct {
@@ -36,6 +50,14 @@ func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) {
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.
// See https://core.telegram.org/bots/api#getcustomemojistickers
type GetCustomEmojiStickersP struct {
@@ -49,6 +71,14 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke
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.
// See https://core.telegram.org/bots/api#uploadstickerfile
type UploadStickerFileP struct {
@@ -68,6 +98,18 @@ func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFil
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.
// See https://core.telegram.org/bots/api#createnewstickerset
type CreateNewStickerSetP struct {
@@ -88,6 +130,14 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#addstickertoset
type AddStickerToSetP struct {
@@ -104,6 +154,14 @@ func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setstickerpositioninset
type SetStickerPositionInSetP struct {
@@ -119,6 +177,14 @@ func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool,
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.
// See https://core.telegram.org/bots/api#deletestickerfromset
type DeleteStickerFromSetP struct {
@@ -133,6 +199,14 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error)
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.
// See https://core.telegram.org/bots/api#replacestickerinset
type ReplaceStickerInSetP struct {
@@ -150,6 +224,14 @@ func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setstickeremojilist
type SetStickerEmojiListP struct {
@@ -165,6 +247,14 @@ func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setstickerkeywords
type SetStickerKeywordsP struct {
@@ -180,6 +270,14 @@ func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setstickermaskposition
type SetStickerMaskPositionP struct {
@@ -195,6 +293,14 @@ func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, er
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.
// See https://core.telegram.org/bots/api#setstickersettitle
type SetStickerSetTitleP struct {
@@ -210,6 +316,14 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#setstickersetthumbnail
type SetStickerSetThumbnailP struct {
@@ -227,6 +341,14 @@ func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, er
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.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
type SetCustomEmojiStickerSetThumbnailP struct {
@@ -242,6 +364,14 @@ func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSe
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.
// See https://core.telegram.org/bots/api#deletestickerset
type DeleteStickerSetP struct {
@@ -255,3 +385,11 @@ func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) {
req := NewRequest[bool]("deleteStickerSet", params)
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)
}
+68 -25
View File
@@ -6,6 +6,9 @@ import "encoding/json"
type UpdateType string
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 UpdateType = "message"
// UpdateTypeEditedMessage is an edited message update.
@@ -27,8 +30,6 @@ const (
UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message"
// UpdateTypeDeletedBusinessMessages is a deleted business messages update.
UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages"
// UpdateTypeDeletedBusinessMessage is kept as a backward-compatible alias.
UpdateTypeDeletedBusinessMessage UpdateType = UpdateTypeDeletedBusinessMessages
// UpdateTypeInlineQuery is an inline query update.
UpdateTypeInlineQuery UpdateType = "inline_query"
@@ -61,6 +62,8 @@ const (
// Update represents an incoming update from Telegram.
// See https://core.telegram.org/bots/api#update
type Update struct {
Type UpdateType `json:"-"`
UpdateID int `json:"update_id"`
Message *Message `json:"message,omitempty"`
EditedMessage *Message `json:"edited_message,omitempty"`
@@ -71,7 +74,6 @@ type Update struct {
BusinessMessage *Message `json:"business_message,omitempty"`
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"`
DeletedBusinessMessage *BusinessMessagesDeleted `json:"-"`
MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
@@ -91,33 +93,72 @@ type Update struct {
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"`
}
func (u *Update) syncDeletedBusinessMessages() {
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.
// UnmarshalJSON decodes an update and derives its Type from the populated payload field.
func (u *Update) UnmarshalJSON(data []byte) error {
type alias Update
var aux alias
type Alias Update
var aux Alias
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
*u = Update(aux)
u.syncDeletedBusinessMessages()
return nil
switch {
case u.Message != nil:
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
}
// MarshalJSON emits the canonical deleted_business_messages field.
func (u Update) MarshalJSON() ([]byte, error) {
u.syncDeletedBusinessMessages()
type alias Update
return json.Marshal(alias(u))
return nil
}
// InlineQuery represents an incoming inline query.
@@ -361,7 +402,7 @@ type Gift struct {
RemainingCount *int `json:"remaining_count,omitempty"`
PersonalTotalCount *int `json:"personal_total_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"`
PublisherChat *Chat `json:"publisher_chat,omitempty"`
}
@@ -375,7 +416,9 @@ type Gifts struct {
type OwnedGiftType string
const (
// OwnedGiftRegularType identifies a regular owned gift.
OwnedGiftRegularType OwnedGiftType = "regular"
// OwnedGiftUniqueType identifies a unique owned gift.
OwnedGiftUniqueType OwnedGiftType = "unique"
)
@@ -388,7 +431,7 @@ type OwnedGift struct {
// Fields specific to "regular" type
Gift Gift `json:"gift"`
SenderUser User `json:"sender_user,omitempty"`
SenderUser *User `json:"sender_user,omitempty"`
Text string `json:"text,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
+72 -25
View File
@@ -6,41 +6,88 @@ import (
"testing"
)
func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) {
var update Update
err := json.Unmarshal([]byte(`{
func TestUpdateUnmarshalSetsType(t *testing.T) {
tests := []struct {
name string
body string
want UpdateType
}{
{
name: "deleted business messages",
body: `{
"update_id": 1,
"deleted_business_messages": {
"business_connection_id": "conn",
"chat": {"id": 42, "type": "private"},
"message_ids": [3, 5]
}
}`), &update)
if err != nil {
}`,
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,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var update Update
if err := json.Unmarshal([]byte(tt.body), &update); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if update.DeletedBusinessMessages == nil {
t.Fatal("expected DeletedBusinessMessages to be populated")
if update.Type != tt.want {
t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
}
if update.DeletedBusinessMessage == nil {
t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated")
if tt.want == UpdateTypeChatBoost && update.ChatBoost.Boost.BoostID != "boost-1" {
t.Fatalf("unexpected boost id: got %q want %q", update.ChatBoost.Boost.BoostID, "boost-1")
}
if update.DeletedBusinessMessages != update.DeletedBusinessMessage {
t.Fatal("expected deleted business message fields to share the same payload")
}
if got := update.DeletedBusinessMessages.MessageIDs; len(got) != 2 || got[0] != 3 || got[1] != 5 {
t.Fatalf("unexpected message ids: %v", got)
})
}
}
func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
func TestUpdateMarshalOmitsSyntheticTypeField(t *testing.T) {
update := Update{
UpdateID: 1,
DeletedBusinessMessage: &BusinessMessagesDeleted{
BusinessConnectionID: "conn",
Chat: Chat{ID: 42, Type: string(ChatTypePrivate)},
MessageIDs: []int{7},
Type: UpdateTypeCallbackQuery,
CallbackQuery: &CallbackQuery{
ID: "cb",
From: User{ID: 1, FirstName: "Test"},
ChatInstance: "instance",
Data: "payload",
},
}
@@ -50,11 +97,8 @@ func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
}
got := string(data)
if !strings.Contains(got, `"deleted_business_messages"`) {
t.Fatalf("expected canonical deleted_business_messages field, got %s", got)
}
if strings.Contains(got, `"deleted_business_message"`) {
t.Fatalf("unexpected singular deleted_business_message field, got %s", got)
if strings.Contains(got, `"type"`) {
t.Fatalf("unexpected synthetic type field, got %s", got)
}
}
@@ -66,4 +110,7 @@ func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) {
if update.ShippingQuery != nil {
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"
"net/http"
"path/filepath"
"strings"
"time"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
)
const (
@@ -30,6 +31,8 @@ const (
UploaderThumbnailType UploaderFileType = "thumbnail"
// UploaderStickerType is the multipart field name for sticker uploads.
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.
@@ -67,8 +70,7 @@ type Uploader struct {
// NewUploader creates a multipart uploader bound to an API client.
func NewUploader(api *API) *Uploader {
logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER")
logger.AddWriter(logger.CreateJsonStdoutWriter())
logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
return &Uploader{api, logger}
}
@@ -80,8 +82,12 @@ func (u *Uploader) Close() error { return u.logger.Close() }
// See https://core.telegram.org/bots/api
func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
// UploaderRequest is a multipart file upload request to the Telegram API.
// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one.
// UploaderRequest is a low-level multipart upload request wrapper.
//
// 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 {
method string
files []UploaderFile
@@ -89,16 +95,17 @@ type UploaderRequest[R, P any] struct {
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] {
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.
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}
}
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
var zero R
@@ -203,8 +210,7 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return r.DoWithContext(context.Background(), up)
}
// prepareMultipart builds a multipart form body from the given files and params.
// Params are encoded via utils.Encode. The writer boundary is finalized before returning.
// Internal helper that builds a finalized multipart body from files and params.
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf)
@@ -237,10 +243,9 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
return buf, w.FormDataContentType(), nil
}
// uploaderTypeByExt infers the Telegram upload field name from a file extension.
// Falls back to UploaderDocumentType for unrecognized extensions.
// Internal helper that infers an upload field name from a file extension.
func uploaderTypeByExt(filename string) UploaderFileType {
ext := filepath.Ext(filename)
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".bmp":
return UploaderPhotoType
+23 -2
View File
@@ -44,8 +44,8 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
SetHTTPClient(client),
)
defer func() {
if err := api.CloseApi(); err != nil {
t.Fatalf("CloseApi returned error: %v", err)
if err := api.Close(); err != nil {
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) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil {
+110
View File
@@ -1,5 +1,7 @@
package tgapi
import "context"
// UploadPhotoP holds parameters for uploading a photo using the Uploader.
// See https://core.telegram.org/bots/api#sendphoto
type UploadPhotoP struct {
@@ -32,6 +34,16 @@ func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, e
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.
// See https://core.telegram.org/bots/api#sendaudio
type UploadAudioP struct {
@@ -66,6 +78,16 @@ func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Messag
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.
// See https://core.telegram.org/bots/api#senddocument
type UploadDocumentP struct {
@@ -97,6 +119,16 @@ func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (
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.
// See https://core.telegram.org/bots/api#sendvideo
type UploadVideoP struct {
@@ -135,6 +167,16 @@ func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Messag
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.
// See https://core.telegram.org/bots/api#sendanimation
type UploadAnimationP struct {
@@ -171,6 +213,16 @@ func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile)
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.
// See https://core.telegram.org/bots/api#sendvoice
type UploadVoiceP struct {
@@ -202,6 +254,16 @@ func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Messag
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.
// See https://core.telegram.org/bots/api#sendvideonote
type UploadVideoNoteP struct {
@@ -231,6 +293,16 @@ func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile)
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.
// See https://core.telegram.org/bots/api#setchatphoto
type UploadChatPhotoP struct {
@@ -244,3 +316,41 @@ func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bo
req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
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
import "context"
// GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method.
// See https://core.telegram.org/bots/api#getuserprofilephotos
type GetUserProfilePhotosP struct {
@@ -15,6 +17,14 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP
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.
// See https://core.telegram.org/bots/api#getuserprofileaudios
type GetUserProfileAudiosP struct {
@@ -30,6 +40,14 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA
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.
// See https://core.telegram.org/bots/api#setuseremojistatus
type SetUserEmojiStatusP struct {
@@ -46,6 +64,14 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) {
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.
// See https://core.telegram.org/bots/api#getusergifts
type GetUserGiftsP struct {
@@ -66,3 +92,11 @@ func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getUserGifts", params)
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)
}
+6 -2
View File
@@ -3,7 +3,7 @@ package laniakea
import (
"strings"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
// Ptr returns a pointer to v.
@@ -53,11 +53,15 @@ func EscapePunctuation(s string) string {
return s
}
// Version constants mirror values from the internal utils/version package.
const (
// VersionString re-exports the module version string.
VersionString = utils.VersionString
// VersionMajor re-exports the module major version.
VersionMajor = utils.VersionMajor
// VersionMinor re-exports the module minor version.
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"
)
// ErrDropOverflow is returned when drop mode rejects a rate-limited request.
var ErrDropOverflow = errors.New("drop overflow limit")
// 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)
}
// 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 {
rl.globalMu.RLock()
defer rl.globalMu.RUnlock()
@@ -190,8 +191,7 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6
return nil
}
// waitForGlobalUnlock blocks until global cooldown expires or context is done.
// Does not check token bucket — only cooldown.
// Internal helper that waits for the global cooldown to expire.
func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
rl.globalMu.RLock()
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.
// Does not check token bucket — only cooldown.
// Internal helper that waits for a chat-specific cooldown to expire.
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
rl.chatMu.RLock()
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.
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
// Internal helper that returns or creates a per-chat limiter.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
rl.chatMu.Lock()
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 (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"reflect"
"slices"
@@ -110,6 +109,6 @@ func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) er
if err != nil {
return err
}
_, err = io.Copy(fw, strings.NewReader(string(value)))
_, err = fw.Write(value)
return err
}
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"mime/multipart"
"testing"
"git.nix13.pw/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
type multipartEncodeParams struct {
+27 -1
View File
@@ -3,7 +3,7 @@ package utils
import (
"os"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/slog"
)
// GetLoggerLevel returns DEBUG when DEBUG=true in env, otherwise FATAL.
@@ -14,3 +14,29 @@ func GetLoggerLevel() slog.LogLevel {
}
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))
}
}
+7 -2
View File
@@ -1,9 +1,14 @@
package utils
const (
VersionString = "1.0.0-rc.2"
// VersionString is the module version string.
VersionString = "1.0.0-rc.12"
// VersionMajor is the module major version.
VersionMajor = 1
// VersionMinor is the module minor version.
VersionMinor = 0
// VersionPatch is the module patch version.
VersionPatch = 0
VersionBeta = 2
// VersionBeta is the prerelease counter for the current version.
VersionBeta = 12
)