REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare commits

...
Author SHA1 Message Date
ScuroNeko 83bcab6415 Rename tgapi request structs
Add MaybeInaccessibleMessage tests and godoc
Update wiki and Bot API docs for the new naming
2026-04-06 16:06:57 +03:00
ScuroNeko d55f58c092 Add shared webhook runtime support
Share queue and runner lifecycle between polling and webhook

Keep Close() local-only and harden webhook startup validation

Document runtime transitions and add webhook regression tests
2026-04-01 17:36:23 +03:00
ScuroNeko ba25dab6b1 Refine backlog policy and runtime guards
Move speculative framework items into Ideas and bump rc.14

Guard tgapi API/uploader setup and nil plugin registrations

Add missing observer Event godoc
2026-03-31 16:57:08 +03:00
ScuroNeko a818174fbf add observer runtime model
Instrument command, payload, update, scene, runner, polling, and policy flows
Add observer regression coverage and mark observability backlog item done
2026-03-31 13:50:21 +03:00
ScuroNeko 140f3397b2 add authorization policies
Introduce first-class policy helpers and composition through middleware

Normalize chat context for more updates and add regression coverage

Document the completed backlog item in changelog, TODO, and wiki
2026-03-30 18:17:28 +03:00
ScuroNeko e2444752c2 rename app data API
Align docs, examples, and tests with AppData and NoData

Update handler, scene, runner, and plugin generics to the new naming

Ignore .codex and record the cleanup in the changelog
2026-03-30 16:43:59 +03:00
ScuroNeko 66eb72cb3c Finalize rc.13 framework contracts
Formalize update routing, config freeze, and error visibility
Add regression coverage and sync backlog records
2026-03-30 01:01:13 +03:00
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
96 changed files with 10597 additions and 1987 deletions
+5 -1
View File
@@ -1,2 +1,6 @@
.idea/
test/
.wiki/
.vscode/
test/
.codex/
.codex
+162
View File
@@ -0,0 +1,162 @@
# 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 in the main repository.
- Wiki-only edits must never be added to `CHANGELOG.md`.
- `AGENTS.md`-only edits must never be added to `CHANGELOG.md`.
## 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 must not be added to `CHANGELOG.md`.
- Changes made only in `AGENTS.md` must not be added to `CHANGELOG.md`.
- 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.
+291
View File
@@ -0,0 +1,291 @@
# Changelog
## v1.0.0-rc.14
### Bot API 9.6
#### Managed Bots
- Added the field can_manage_bots to the class User.
- Added the class KeyboardButtonRequestManagedBot and the field request_managed_bot to the class KeyboardButton.
- Added the class ManagedBotCreated and the field managed_bot_created to the class Message.
- Added updates about the creation of managed bots and the change of their token, represented by the class ManagedBotUpdated and the field managed_bot in the class Update.
- Added the methods getManagedBotToken and replaceManagedBotToken.
- Added the class PreparedKeyboardButton and the method savePreparedKeyboardButton, allowing bots to request users, chats and managed bots from Mini Apps.
- Added the method requestChat to the class WebApp.
- Added support for https://t.me/newbot/{manager_bot_username}/{suggested_bot_username}[?name={suggested_bot_name}] links, allowing bots to request the creation of a managed bot via a link.
### Polls
- Added support for quizzes with multiple correct answers.
- Replaced the field correct_option_id with the field correct_option_ids in the class Poll.
- Replaced the parameter correct_option_id with the parameter correct_option_ids in the method sendPoll.
- Allowed to pass allows_multiple_answers for quizzes in the method sendPoll.
- Increased the maximum time for automatic poll closure to 2628000 seconds.
- Added the field allows_revoting to the class Poll.
- Added the parameter allows_revoting to the method sendPoll.
- Added the parameter shuffle_options to the method sendPoll.
- Added the parameter allow_adding_options to the method sendPoll.
- Added the parameter hide_results_until_closes to the method sendPoll.
- Added the fields description and description_entities to the class Poll.
- Added the parameters description, description_parse_mode, and description_entities to the method sendPoll.
- Added the field persistent_id to the class PollOption, representing a persistent identifier for the option.
- Added the field option_persistent_ids to the class PollAnswer.
- Added the fields added_by_user and added_by_chat to the class PollOption, denoting the user and the chat which added the option.
- Added the field addition_date to the class PollOption, describing the date when the option was added.
- Added the class PollOptionAdded and the field poll_option_added to the class Message.
- Added the class PollOptionDeleted and the field poll_option_deleted to the class Message.
- Added the field poll_option_id to the class ReplyParameters, allowing bots to reply to a specific poll option.
- Added the field reply_to_poll_option_id to the class Message.
- Allowed “date_time” entities in checklist title, checklist task text, TextQuote, ReplyParameters quote, sendGift, and giftPremiumSubscription.
**More info**: https://core.telegram.org/bots/api#april-3-2026
### Breaking Changes
- Exported `tgapi` request parameter structs were renamed from the `*P` suffix to their method names. Update code such as `tgapi.SendMessageP{...}` to `tgapi.SendMessage{...}`.
### Changed
- *Support for Bot API 9.6*
- Added missing godoc for recently introduced Telegram Bot API managed-bot, prepared-button, chat-owner, and video-quality exported declarations.
- Renamed exported `tgapi` request parameter structs from the `*P` suffix to their method names, for example `SendMessageP` -> `SendMessage` and `SetWebhookP` -> `SetWebhook`.
- Added missing godoc for the exported observer `Event` marker interface.
- Webhook execution now shares the bot's queued update-dispatch path with polling, including worker-pool delivery, runner startup, single-use run semantics, and default fallback to bot-level update type filters when webhook-specific filters are not set.
- Webhook godoc and the English and Russian READMEs now describe the bot-level webhook runtime, its single-use lifecycle, and the main `RunWebHookWithContext(...)` entry points more explicitly.
- `Bot.Close()` once again releases only local resources and no longer deletes remote webhook registrations implicitly; explicit remote webhook teardown remains opt-in through `CloseWebHook()`.
- Polling and webhook docs now explicitly state that a deployment must delete its webhook before switching from webhook delivery to long polling.
- Webhook startup now validates path shape and TLS file count before remote webhook setup, and the shared webhook mux now serves both HTTP and TLS runtime paths consistently.
- Webhook-related `tgapi` request params now use `int8` for `max_connections`, matching Telegram's `1..100` range and the higher-level webhook options API.
- Webhook startup now also requires a non-empty `SecretToken` when the optional `/status` endpoint is enabled, preventing anonymous exposure of webhook operational metadata.
- Webhook debug logging now records update metadata instead of dumping raw request bodies.
- Package docs, README guidance, and core wiki pages now align with the current public API and runtime model, including `NoData`, `SetAppData(...)`, `SetL10n(...)`, `AddAppDataLoggerWriter(...)`, shared runner startup semantics, and the webhook runtime entry points.
### Tests
- Added regression coverage for webhook queue delivery, webhook runtime single-use behavior, runner startup in webhook mode, and default webhook `allowed_updates` inheritance from bot-level update type configuration.
- Added regression coverage proving `Bot.Close()` does not make remote webhook delete requests.
- Added webhook regression coverage for path validation, TLS file-count validation, oversized-body rejection, status-endpoint secret checks, and invalid TLS startup arguments.
- Added webhook regression coverage proving `/status` cannot be enabled without a non-empty `SecretToken`.
- Added regression coverage for Bot API 9.6 poll decoding, `managed_bot` update decoding, and structured `setChatMenuButton(...)` request serialization.
- Added regression coverage for `MaybeInaccessibleMessage` accessible and inaccessible JSON decoding.
## v1.0.0-rc.13
### Added
- `AsUserError(...)`, `AsInternalError(...)`, `IsUserError(...)`, and `IsInternalError(...)` for explicitly marking centralized handler errors as user-visible or internal-only without breaking the existing default error flow.
- `Policy[T]`, `RequirePolicy(...)`, and built-in chat and callback policy helpers for expressing reusable authorization rules through the existing middleware pipeline.
- `Bot.UsePolicy(...)` and `Plugin.UsePolicy(...)` as shorthand for registering policies as middleware.
- `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` for composing reusable authorization rules without introducing a second execution pipeline.
### Changed
- Bot configuration mutators now treat the bot as configuration-frozen after the first run begins and ignore late mutation attempts for bot-level config such as prefixes, payload defaults, plugins, middleware, runners, localization, scene session wiring, and database context injection.
- `MsgContext` godoc and field comments now describe the normalized update contract more explicitly, including when `Msg`, `From`, callback target fields, `Text`, and `Args` are expected to be populated.
- `MsgContext` normalization now also carries `Chat` and `ChatID` for more Telegram update kinds, allowing policy and update handlers to rely on normalized chat identity outside message-only flows.
- `MsgContext.Error(...)` and returned handler errors now suppress the automatic user reply when the error is explicitly marked with `AsInternalError(...)`, while keeping the previous user-visible default for unclassified errors.
- Godoc, README examples, and regression-test naming now consistently describe the shared generic dependency model as app data, including `NoData` and `SetAppData(...)`.
- Observer configuration now treats `SetObserver(nil)` as clearing instrumentation instead of leaving the previous observer attached.
- Observer lifecycle events now cover generic update handlers and scene command, step, and message-fallback handlers with logical handler names and durations.
- `RequirePolicy(...)` now emits `PolicyCheckedEvent` for both passed and denied policy decisions.
- Scene command, step, and message-fallback flows now emit observer `ErrorEvent`s with scene-specific handler kinds and logical handler names.
- Scene transition observer events now use the same transition payload for scene command, step, and message-fallback flows.
- Observer error emission now also covers generic update handlers, callback payload decode failures, runner failures, and polling retries, including dedicated runner and polling handler kinds in `ErrorEvent`.
- `TODO.md` and the framework backlog pages now mark the observability model as completed for `v1.0.0-rc.13`.
- `tgapi.Chat.Type` now uses the typed `tgapi.ChatType` enum in public DTOs and tests instead of raw string casts.
### Tests
- Added regression coverage for the bot configuration freeze model, including ignored post-run mutations for core bot configuration methods and late registration paths.
- Added table-driven update-contract coverage for `prepareUpdateCtx(...)`, including message-backed, callback-backed, user-backed, and no-user update kinds.
- Added regression tests for policy middleware blocking, built-in private-chat policy decisions, normalized chat identity, and admin checks that use normalized `ChatID` and `FromID`.
- Added regression tests for policy composition semantics, including all-of, any-of, and deny inversion with preserved internal failures.
- Added regression tests for `SetObserver(...)`, `GetObserver()`, and clearing the observer with `SetObserver(nil)`.
- Added observer regression tests for generic update-handler errors, callback payload decode failures, runner failure events, and polling retry emission.
- Added observer regression tests for update and scene handler lifecycle events and `PolicyCheckedEvent` emission.
- Added regression tests proving that `edited_message` and `edited_channel_post` stay out of command routing and continue through generic update handlers.
- Added callback-routing regression tests for both chat-message and inline-message callback targets, including `CallbackQueryId`, `CallbackMsgId`, `InlineMsgId`, and payload-argument guarantees.
- Added regression tests for the new error-visibility model in both message and callback flows, including silent internal-only errors and explicit user-visible callback replies.
## 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.
+118 -31
View File
@@ -4,12 +4,14 @@
[![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
@@ -19,15 +21,16 @@ A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It s
* **Middleware Support:** Run code before or after commands (e.g., logging, access control).
* **Automatic Command Generation:** Generate help and command lists automatically.
* **Built-in Rate Limiting:** Protect your bot from hitting Telegram API limits (supports `retry_after` handling).
* **Context-Aware:** Pass custom database or state contexts to your handlers.
* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`).
* **Context-Aware:** Pass custom application data or state contexts to your handlers.
* **Configurable API:** Mix `Set...` and `Add...` helpers to configure bots clearly (for example, `bot.SetErrorTemplate(...).AddPlugins(...)`).
* **Polling and Webhook Runtime:** Run bots through long polling with `Run()` / `RunWithContext(...)` or through a bot-owned webhook server with `RunWebHookWithContext(...)`.
---
## 📦 Installation
```bash
go get git.nix13.pw/scuroneko/laniakea
go get git.scuroneko.dev/scuroneko/laniakea
```
or
@@ -45,17 +48,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) {
// - data: your shared application data (here we use NoData, a placeholder for no shared data)
func echo(ctx *laniakea.MsgContext, data laniakea.NoData) 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() {
@@ -63,14 +67,17 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"}
// 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)
// We use laniakea.NoData as the application data type (no shared data needed for this example).
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
// Ensure bot resources are cleaned up on exit.
defer bot.Close()
// 3. Create a new plugin named "ping".
// Plugins help group related commands and middlewares.
p := laniakea.NewPlugin[laniakea.NoDB]("ping")
p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Add a command to the plugin.
// p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command.
@@ -78,14 +85,15 @@ 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, data laniakea.NoData) error {
ctx.Answer("Pong")
return nil
}, "ping"))
// 6. Configure the bot with a custom error template and add the plugin.
// ErrorTemplate sets a format string for errors (where %s will be replaced by the actual error).
// SetErrorTemplate sets a format string for errors (where %s will be replaced by the actual error).
// AddPlugins(p) registers our "ping" plugin with the bot.
bot = bot.ErrorTemplate("Error\n\n%s").AddPlugins(p)
bot = bot.SetErrorTemplate("Error\n\n%s").AddPlugins(p)
// 7. Automatically generate commands like /start, /help, and a list of all registered commands.
// This is optional but very useful for most bots.
@@ -94,26 +102,48 @@ func main() {
}
// 8. Start the bot, listening for updates (long polling).
bot.Run()
if err := bot.Run(); err != nil {
log.Fatal(err)
}
}
```
### How It Works
1. `BotOpts`: Holds configuration like the API token.
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it.
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass custom shared application data (for example, *sql.DB or a service container) that will be available in all handlers. Use laniakea.NoData 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.
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.
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 application data T, and return an error for centralized error handling.
6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
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. `RunWebHookWithContext(...)`: Starts the bot-owned webhook runtime when Telegram should deliver updates over HTTP instead of long polling.
10. A `Bot` instance is single-use. After `Run()`, `RunWithContext()`, or `RunWebHookWithContext()` returns, create a new bot instance for the next session.
## Webhook Runtime
Laniakea also supports a bot-owned webhook runtime through `RunWebHookWithContext(...)` and `RunWebHook(...)`.
Use it when:
- Telegram should push updates to your HTTP endpoint instead of your bot polling for them.
- You want webhook-delivered updates to reuse the same internal queue, worker pool, runners, and single-use lifecycle as polling.
- You want Laniakea to register the webhook and own the local HTTP server.
Production notes:
- Set `BotWebHookOpts.SecretToken` for request authentication.
- `BotWebHookOpts.SecretToken` is required when `BotWebHookOpts.UseStatusPath` is enabled.
- Keep `BotWebHookOpts.Path` specific instead of serving webhook traffic on `/`.
- If you switch an existing deployment from webhook mode to long polling, delete the webhook first with `CloseWebHook()` or `tgapi.DeleteWebhook(...)`. Telegram keeps webhook delivery active until it is removed.
- Use `RunWebHookWithContext(...)` with a cancelable context, then call `Close()` after runtime shutdown.
See the full guide in the wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime)
## 📖 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)
```
@@ -122,9 +152,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
}
```
@@ -133,8 +164,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).
@@ -153,16 +186,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`.
### Database Context
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.
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.
### App Data
The `T` in `NewBot[T]` is a powerful feature. You can pass any type, but shared dependencies such as database pools, service containers, or API clients 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.SetAppData(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.
@@ -177,11 +253,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"))
```
@@ -210,16 +287,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(...)` and `RunWebHookWithContext(...)` do 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)
+130 -36
View File
@@ -4,12 +4,14 @@
[![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. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру.
[English](README.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
---
## ✨ Возможности
@@ -20,15 +22,16 @@
* **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа).
* **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически.
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики.
* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`).
* **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
* **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebHookWithContext(...)`.
---
## 📦 Установка
```bash
go get git.nix13.pw/scuroneko/laniakea
go get git.scuroneko.dev/scuroneko/laniakea
```
или
@@ -46,17 +49,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) {
// - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей)
func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
return nil
}
func main() {
@@ -64,14 +68,17 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера).
bot := laniakea.NewBot[laniakea.NoDB](opts)
// Используем laniakea.NoData как тип данных приложения (общие зависимости не нужны для примера).
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
// Гарантируем освобождение ресурсов бота при выходе.
defer bot.Close()
// 3. Создаём новый плагин с именем "ping".
// Плагины помогают группировать связанные команды и промежуточные обработчики.
p := laniakea.NewPlugin[laniakea.NoDB]("ping")
p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Добавляем команду в плагин.
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
@@ -79,14 +86,15 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) {
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong")
return nil
}, "ping"))
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
// ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
// SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
// AddPlugins(p) регистрирует наш плагин "ping" в боте.
bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
bot = bot.SetErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
// 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд.
// Это необязательно, но очень полезно для большинства ботов.
@@ -95,26 +103,48 @@ func main() {
}
// 8. Запускаем бота, начиная прослушивание обновлений (long polling).
bot.Run()
if err := bot.Run(); err != nil {
log.Fatal(err)
}
}
```
### Как это работает
1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд.
8. `Run()`: Запускает цикл опроса обновлений бота.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling.
10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот.
## Webhook Runtime
Laniakea также поддерживает bot-owned webhook runtime через `RunWebHookWithContext(...)` и `RunWebHook(...)`.
Используй его, когда:
- Telegram должен сам отправлять update на твой HTTP endpoint вместо polling.
- Ты хочешь, чтобы webhook-update проходили через ту же внутреннюю очередь, тот же worker pool, тех же runners и тот же single-use lifecycle, что и polling.
- Ты хочешь, чтобы Laniakea сама регистрировала webhook и владела локальным HTTP server.
Практические замечания:
- Задавай `BotWebHookOpts.SecretToken` для аутентификации запросов.
- Непустой `BotWebHookOpts.SecretToken` обязателен, если включён `BotWebHookOpts.UseStatusPath`.
- Используй явный `BotWebHookOpts.Path`, а не `/`.
- Если ты переводишь уже существующий deployment с webhook-режима на long polling, сначала удали webhook через `CloseWebHook()` или `tgapi.DeleteWebhook(...)`. Пока webhook не удалён, Telegram продолжает доставку через него.
- Запускай `RunWebHookWithContext(...)` с cancelable context и после остановки runtime всё равно вызывай `Close()`.
Полное руководство есть в wiki: [Webhook Runtime](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Webhook-Runtime-RU)
## 📖 Основные концепции
### Плагины (Plugins)
Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware.
```go
plugin := laniakea.NewPlugin[MyDB]("admin")
plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin)
```
@@ -123,9 +153,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
}
```
@@ -133,26 +164,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]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件.
### App Data
Параметр типа `T` в `NewBot[T]` — мощная возможность. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД, контейнера сервисов или API-клиента обычно стоит использовать 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.SetAppData(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 — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста.
@@ -167,11 +250,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"))
```
@@ -200,15 +284,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(...)` и `RunWebHookWithContext(...)` не вызывают `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.
+28
View File
@@ -0,0 +1,28 @@
# 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:
- `Partial`: none.
- `Ideas`: service layer and dependency graph model, plugin composition contract.
Completed former high-priority items:
- `[v1.0.0-rc.14] Webhook runtime model.`
- `[v1.0.0-rc.13] Observability model`: added first-class `Observer` events for update, command, payload, scene, policy, runner, polling, and centralized error flows, with safe event dispatch and regression coverage for the new runtime hooks.
- `[v1.0.0-rc.13] Authorization and policy model`: added first-class `Policy[T]`, middleware integration through `RequirePolicy(...)`, plugin and bot policy registration helpers, built-in Telegram-aware policies, and composable `AllPolicies(...)`, `AnyPolicy(...)`, and `NotPolicy(...)` helpers with regression coverage.
- `[v1.0.0-rc.13] Update schema contract`: documented and tested the normalized `MsgContext` update-routing contract, including routing categories and per-update field guarantees.
- `[v1.0.0-rc.13] User-facing vs internal error model`: added explicit user-visible vs internal-only error markers and updated centralized handler error routing accordingly.
- `[v1.0.0-rc.13] Configuration freeze model`: formalized bot configuration freeze after first run, documented lifecycle commit points, and added regression coverage for ignored late mutations.
- `[v1.0.0-rc.12] Conversation / Scene Model`.
- `[v1.0.0-rc.12] Typed Handler Input Model`.
- `[v1.0.0-rc.12] Request Context / Cancellation Model`.
+183 -342
View File
@@ -4,35 +4,45 @@ import (
"context"
"errors"
"fmt"
"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"
"github.com/alitto/pond/v2"
"git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
)
// DbContext is an interface representing the application's database context.
// It is injected into plugins and middleware via Bot.DatabaseContext().
// AppData is the generic shared application data type injected into bots,
// plugins, and handlers.
//
// Use it for long-lived shared dependencies such as database handles, service
// containers, API clients, or immutable configuration snapshots.
//
// Example:
//
// type MyDB struct { ... }
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB)
// myDB := &MyDB{}
// bot, err := NewBot[*MyDB](opts)
// if err != nil {
// return err
// }
// bot.SetAppData(myDB)
//
// Use NoDB if no database is needed.
type DbContext any
// Use NoData if no shared application data is needed.
type AppData any
// NoDB is a placeholder type for bots that do not use a database.
// Use Bot[NoDB] to indicate no dependency injection is required.
type NoDB struct{ DbContext }
// NoData is a placeholder type for bots that do not use shared application
// data.
//
// Use Bot[NoData] to indicate no shared dependency injection is required.
type NoData struct{ AppData }
// 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
// AppDataLogger builds a slog.LoggerWriter from injected application data.
//
// Use it when shared application data exposes a log sink or adapter that should
// receive framework logs.
type AppDataLogger[T AppData] func(data T) slog.LoggerWriter
// BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string
@@ -44,6 +54,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, RunWithContext, or RunWebHookWithContext 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:
@@ -53,17 +77,22 @@ var (
// - Logging and rate limiting
// - Localization and draft message support
//
// All methods are safe for concurrent use. Direct field access is not recommended.
type Bot[T DbContext] struct {
token string
debug bool
errorTemplate string
username string
payloadType BotPayloadType
maxWorkers int
// Runtime accessors are safe for concurrent use. Configure the bot before Run,
// RunWithContext, or RunWebHookWithContext.
// A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns,
// create a new Bot for the next session.
type Bot[T AppData] 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)
RequestLogger *slog.Logger // Optional request-level API logging
webHookLogger *slog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext.
extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers
plugins []Plugin[T] // Command/event handlers
@@ -73,9 +102,16 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader
dbContext *T // Injected database context
l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder
observer Observer // Optional event observer for instrumentation
appData T // Injected application data
hasAppData bool
warnedValueData bool
sessionStore SessionStore // Session store for scene management
sceneScopePriority []SceneScope
updateOffsetMu sync.Mutex
updateOffset int // Last processed update ID
@@ -83,6 +119,21 @@ type Bot[T DbContext] struct {
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
}
func (bot *Bot[T]) configMutable(method string) bool {
bot.runStateMu.Lock()
defer bot.runStateMu.Unlock()
if !bot.ran {
return true
}
if bot.logger != nil {
bot.logger.Warnln(fmt.Sprintf("%s called after bot configuration was frozen; ignoring", method))
}
return false
}
// NewBot creates and initializes a new Bot instance using the provided BotOpts.
@@ -93,13 +144,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)
@@ -130,22 +180,26 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
}
bot := &Bot[T]{
updateOffset: 0,
errorTemplate: "%s",
payloadType: BotPayloadBase64,
maxWorkers: workers,
updateQueue: updateQueue,
api: api,
uploader: uploader,
debug: opts.Debug,
prefixes: prefixes,
token: opts.Token,
plugins: make([]Plugin[T], 0),
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
runners: make([]Runner[T], 0),
extraLoggers: make([]*slog.Logger, 0),
l10n: &L10n{},
draftProvider: NewRandomDraftProvider(api),
updateOffset: 0,
errorTemplate: "%s",
payloadType: BotPayloadBase64,
strictPayloadType: opts.StrictPayloadType,
maxWorkers: workers,
updateQueue: updateQueue,
api: api,
uploader: uploader,
debug: opts.Debug,
prefixes: prefixes,
token: opts.Token,
plugins: make([]Plugin[T], 0),
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
runners: make([]Runner[T], 0),
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
@@ -163,7 +217,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 == "" {
@@ -171,46 +225,66 @@ 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.
//
// Close shuts down, in order:
// - Registered plugins via Plugin.Close
// - Webhook logger (if initialized)
// - Uploader (waits for pending uploads)
// - 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.
// RunWithContext and RunWebHookWithContext do not call Close automatically.
// The caller is responsible for invoking Close after runtime returns to release
// these resources.
//
// Close returns a joined error containing all shutdown failures, if any.
func (bot *Bot[T]) Close() error {
var e []error
logCloseErr := func(err error) {
if err == nil {
return
}
if bot.logger != nil {
bot.logger.Errorln(err)
}
e = append(e, err)
}
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 bot.webHookLogger != nil {
if err := bot.webHookLogger.Close(); err != nil {
logCloseErr(err)
}
bot.webHookLogger = nil
}
if err := bot.api.Close(); err != nil {
bot.logger.Errorln(err)
e = append(e, err)
if bot.uploader != nil {
if err := bot.uploader.Close(); err != nil {
logCloseErr(err)
}
}
if bot.api != nil {
if err := bot.api.Close(); err != nil {
logCloseErr(err)
}
}
if bot.RequestLogger != nil {
if err := bot.RequestLogger.Close(); err != nil {
bot.logger.Errorln(err)
e = append(e, err)
logCloseErr(err)
}
}
if err := bot.logger.Close(); err != nil {
e = append(e, err)
if bot.logger != nil {
if err := bot.logger.Close(); err != nil {
e = append(e, err)
}
}
return errors.Join(e...)
}
@@ -226,40 +300,6 @@ func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
return nil
}
// initLoggers configures the main and optional request loggers.
//
// 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.
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level := slog.FATAL
if opts.Debug {
level = slog.DEBUG
}
bot.logger = utils.CreateLogger("BOT", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("BOT", level, path)
if err != nil {
bot.logger.Fatal(err)
}
bot.logger = logger
}
if opts.UseRequestLogger {
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
if err != nil {
bot.logger.Fatal(err)
}
bot.RequestLogger = logger
}
}
}
// GetUpdateOffset returns the current update offset (thread-safe).
func (bot *Bot[T]) GetUpdateOffset() int {
bot.updateOffsetMu.Lock()
@@ -274,16 +314,9 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) {
bot.updateOffset = offset
}
// GetUpdateTypes returns the list of update types the bot is configured to receive.
func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType { return 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 }
// GetLoggerLevel returns the effective log level derived from the bot's debug
// flag.
func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
@@ -300,216 +333,6 @@ func (bot *Bot[T]) L10n(lang, key string) string {
return bot.l10n.Translate(lang, key)
}
// SetDraftProvider replaces the default DraftProvider with a custom one.
// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts.
func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
bot.draftProvider = p
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] {
bot.dbContext = ctx
return bot
}
// UpdateTypes sets the list of update types the bot will request from Telegram.
// Overwrites any previously set types.
func (bot *Bot[T]) UpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
bot.updateTypes = make([]tgapi.UpdateType, 0)
bot.updateTypes = append(bot.updateTypes, t...)
return bot
}
// SetPayloadType sets the 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.
func (bot *Bot[T]) SetPayloadType(t BotPayloadType) *Bot[T] {
bot.payloadType = t
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] {
bot.updateTypes = append(bot.updateTypes, t...)
return bot
}
// AddPrefixes adds one or more command prefixes (e.g., "/", "!").
// Must have at least one prefix before Run().
func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
bot.prefixes = append(bot.prefixes, prefixes...)
return bot
}
// ErrorTemplate sets the format string for error messages sent to users.
// Use "%s" to insert the error message.
// Example: "❌ Error: %s" → "❌ Error: Command not found".
func (bot *Bot[T]) ErrorTemplate(s string) *Bot[T] {
bot.errorTemplate = s
return bot
}
// 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 {
if p.logger == nil {
logger := utils.CreateLogger(p.name, level)
p.SetLogger(logger)
}
bot.plugins = append(bot.plugins, *p)
bot.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.name))
}
return bot
}
// AddMiddleware registers one or more middleware handlers.
//
// Middleware are executed in order of increasing .order value before plugins.
// If two middleware have the same order, they are sorted lexicographically by name.
//
// Middleware can:
// - Modify or reject updates before they reach plugins
// - Inject context (e.g., user auth state, rate limit status)
// - Log, validate, or transform incoming data
//
// Example:
//
// bot.AddMiddleware(&authMiddleware, &rateLimitMiddleware)
//
// Panics if any middleware has a nil name.
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.middlewares = append(bot.middlewares, m)
bot.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.name))
}
// Stable sort by order (ascending), then by name (lexicographic)
sort.Slice(bot.middlewares, func(i, j int) bool {
first := bot.middlewares[i]
second := bot.middlewares[j]
if first.order != second.order {
return first.order < second.order
}
return first.name < second.name
})
return bot
}
// AddRunner registers a background runner to execute concurrently with the bot.
//
// Runners are goroutines that run independently of update processing.
// Common use cases:
// - Periodic cleanup (e.g., expiring drafts, clearing temp files)
// - Metrics collection or health checks
// - Scheduled tasks (e.g., daily announcements)
//
// Runners are started immediately after Bot.Run() is called.
//
// Example:
//
// bot.AddRunner(&cleanupRunner)
//
// Panics if runner has a nil name.
func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
if runner.name == "" {
panic("laniakea: runner must have a non-empty name")
}
bot.runners = append(bot.runners, runner)
bot.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.name))
return bot
}
// AddL10n sets the localization (i18n) provider for the bot.
//
// The L10n instance must be pre-populated with translations.
// Translations are accessed via Bot.L10n(lang, key).
//
// Example:
//
// l10n := l10n.New()
// l10n.Add("en", "hello", "Hello!")
// l10n.Add("es", "hello", "¡Hola!")
// bot.AddL10n(l10n)
//
// Replaces any previously set L10n instance.
func (bot *Bot[T]) AddL10n(l *L10n) *Bot[T] {
if l == nil {
bot.logger.Warn("AddL10n called with nil L10n; localization will be disabled")
return bot
}
bot.l10n = l
return bot
}
// AddDatabaseLoggerWriter adds a database logger writer to all loggers.
//
// The writer will receive logs from:
// - 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:
//
// bot.AddDatabaseLoggerWriter(func(db *MyDB) slog.LoggerWriter {
// return db.QueryLogger()
// })
func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
w := writer(bot.dbContext)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
bot.RequestLogger.AddWriter(w)
}
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
for _, p := range bot.plugins {
if p.logger != nil {
p.logger.AddWriter(w)
}
}
return bot
}
// RunWithContext starts the bot with a given context for graceful shutdown.
//
// This is the main entry point for bot execution. It:
@@ -523,31 +346,29 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
// - Finishes processing currently queued updates
// - Waits for registered runners to exit
//
// If you are switching an existing deployment from webhook delivery to polling,
// delete the current webhook first with CloseWebHook or tgapi.DeleteWebhook.
// Telegram keeps webhook delivery active until the webhook is removed.
//
// RunWithContext does not close API, uploader, or logger resources on return.
// The caller must invoke Close after RunWithContext finishes.
//
// Example:
//
// ctx, cancel := context.WithCancel(context.Background())
// go bot.RunWithContext(ctx)
// // ... later ...
// cancel() // triggers graceful shutdown
// _ = bot.Close(context.Background())
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)
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
// Start update polling in a goroutine
go func() {
defer func() {
@@ -556,6 +377,8 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
}
close(bot.updateQueue)
}()
retryDelay := time.Duration(0)
retryCount := 0
for {
select {
case <-ctx.Done():
@@ -567,14 +390,36 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
return
}
bot.logger.Errorln("failed to fetch updates:", err)
retryDelay = nextPollRetryDelay(retryDelay)
retryCount++
bot.safeEmitEvent(ctx, PollingRetryEvent{
Attempt: retryCount,
Delay: retryDelay,
Err: err,
})
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerPollingKind,
HandlerName: "getUpdates",
Err: err,
UserFacing: false,
})
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return
case <-timer.C:
}
continue
}
retryDelay = 0
retryCount = 0
for _, update := range updates {
u := update // copy loop variable to avoid race condition
select {
case bot.updateQueue <- &u:
case <-ctx.Done():
if err := bot.enqueueUpdate(ctx, update); err != nil {
return
}
}
@@ -582,17 +427,13 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
}
}()
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
// Start worker pool for concurrent update handling
pool := pond.NewPool(bot.maxWorkers)
for update := range bot.updateQueue {
u := update // capture loop variable
pool.Submit(func() {
bot.handle(u)
})
}
pool.Stop() // Wait for all tasks to complete and stop the pool
bot.startUpdateWorkers(ctx)
bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait()
return nil
}
// Run starts the bot using a background context.
@@ -601,6 +442,6 @@ 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())
}
+224
View File
@@ -0,0 +1,224 @@
package laniakea
import (
"fmt"
"slices"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
// AddPrefixes adds one or more command prefixes (e.g., "/", "!").
// The bot must have at least one prefix before any runtime entry point starts.
func (bot *Bot[T]) AddPrefixes(prefixes ...string) *Bot[T] {
if !bot.configMutable("AddPrefixes") {
return bot
}
bot.prefixes = append(bot.prefixes, prefixes...)
return bot
}
// SetDraftProvider replaces the default DraftProvider with a custom one.
// Useful for using LinearDraftIdGenerator to persist draft IDs across restarts.
func (bot *Bot[T]) SetDraftProvider(p *DraftProvider) *Bot[T] {
if !bot.configMutable("SetDraftProvider") {
return bot
}
bot.draftProvider = p
return bot
}
// GetDraftProvider returns the draft provider currently used by the bot.
func (bot *Bot[T]) GetDraftProvider() *DraftProvider {
return bot.draftProvider
}
// SetObserver sets an event observer for instrumentation.
func (bot *Bot[T]) SetObserver(observer Observer) *Bot[T] {
if !bot.configMutable("SetObserver") {
return bot
}
if observer == nil {
if bot.logger != nil {
bot.logger.Warn("SetObserver called with nil observer; instrumentation will be disabled")
}
bot.observer = nil
return bot
}
bot.observer = observer
return bot
}
// GetObserver returns the bot's event observer, or nil if no observer is set.
func (bot *Bot[T]) GetObserver() Observer {
return bot.observer
}
// SetSessionStore replaces the session store used for scene management.
func (bot *Bot[T]) SetSessionStore(store SessionStore) *Bot[T] {
if !bot.configMutable("SetSessionStore") {
return bot
}
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] {
if !bot.configMutable("SetSceneScopePriority") {
return bot
}
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
}
// SetAppData injects shared application data into the bot.
//
// The data is accessible to commands, payload handlers, middleware, scenes,
// and runners through the generic type parameter T.
//
// For shared dependencies such as *sql.DB, prefer using a pointer type as T.
// Value-typed application data is supported, but the bot warns once because
// handlers receive T by value.
func (bot *Bot[T]) SetAppData(ctx T) *Bot[T] {
if !bot.configMutable("SetAppData") {
return bot
}
if !bot.warnedValueData && shouldWarnOnValueAppData[T]() && bot.logger != nil {
bot.logger.Warnln("app data uses a value type; shared dependencies should usually use a pointer type as T")
bot.warnedValueData = true
}
bot.appData = ctx
bot.hasAppData = true
return bot
}
// GetAppData returns the injected application data.
// If SetAppData was not called, it returns the zero value of T.
func (bot *Bot[T]) GetAppData() T { return bot.appData }
// SetUpdateTypes sets the list of update types the bot will request from Telegram.
// Overwrites any previously set types.
func (bot *Bot[T]) SetUpdateTypes(t ...tgapi.UpdateType) *Bot[T] {
if !bot.configMutable("UpdateTypes") {
return bot
}
bot.updateTypes = make([]tgapi.UpdateType, 0)
bot.updateTypes = append(bot.updateTypes, t...)
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] {
if !bot.configMutable("AddUpdateType") {
return bot
}
bot.updateTypes = append(bot.updateTypes, t...)
return bot
}
// GetUpdateTypes returns the list of update types the bot is configured to receive.
func (bot *Bot[T]) GetUpdateTypes() []tgapi.UpdateType {
return append([]tgapi.UpdateType(nil), bot.updateTypes...)
}
// 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] {
if !bot.configMutable("SetPayloadType") {
return bot
}
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] {
if !bot.configMutable("SetStrictPayloadType") {
return bot
}
bot.strictPayloadType = strict
return bot
}
// SetErrorTemplate sets the format string for error messages sent to users.
// Use "%s" to insert the error message.
// Example: "❌ Error: %s" → "❌ Error: Command not found".
func (bot *Bot[T]) SetErrorTemplate(s string) *Bot[T] {
if !bot.configMutable("ErrorTemplate") {
return bot
}
bot.errorTemplate = s
return bot
}
// SetDebug enables or disables debug logging.
func (bot *Bot[T]) SetDebug(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
}
// SetL10n sets the localization (i18n) provider for the bot.
//
// The L10n instance must be pre-populated with translations.
// Translations are accessed via Bot.L10n(lang, key).
//
// Replaces any previously set L10n instance.
func (bot *Bot[T]) SetL10n(l *L10n) *Bot[T] {
if !bot.configMutable("SetL10n") {
return bot
}
if l == nil {
bot.logger.Warn("SetL10n called with nil L10n; localization will be disabled")
return bot
}
bot.l10n = l
return bot
}
+35 -10
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{
@@ -106,8 +121,11 @@ func LoadOptsFromEnv() *BotOpts {
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"),
RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
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")
}
}
+157
View File
@@ -0,0 +1,157 @@
package laniakea
import (
"fmt"
"sort"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
// 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] {
if !bot.configMutable("AddPlugins") {
return bot
}
level := bot.GetLoggerLevel()
for _, p := range plugin {
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
}
// AddMiddleware registers one or more middleware handlers.
//
// Middleware are executed in order of increasing .order value before plugins.
// If two middleware have the same order, they are sorted lexicographically by name.
//
// Middleware can:
// - Modify or reject updates before they reach plugins
// - Inject context (e.g., user auth state, rate limit status)
// - Log, validate, or transform incoming data
//
// Example:
//
// bot.AddMiddleware(authMiddleware, rateLimitMiddleware)
//
// Middleware with an empty name are skipped with a warning.
func (bot *Bot[T]) AddMiddleware(middleware ...Middleware[T]) *Bot[T] {
if !bot.configMutable("AddMiddleware") {
return bot
}
for _, m := range middleware {
if m.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))
}
// Stable sort by order (ascending), then by name (lexicographic)
sort.Slice(bot.middlewares, func(i, j int) bool {
first := bot.middlewares[i]
second := bot.middlewares[j]
if first.order != second.order {
return first.order < second.order
}
return first.name < second.name
})
return bot
}
// UsePolicy registers a Policy as a bot-level middleware.
func (bot *Bot[T]) UsePolicy(name string, policy Policy[T]) *Bot[T] {
mw := RequirePolicy(name, policy)
return bot.AddMiddleware(mw)
}
// AddRunner registers a background runner to execute concurrently with the bot.
//
// Runners are goroutines that run independently of update processing.
// Common use cases:
// - Periodic cleanup (e.g., expiring drafts, clearing temp files)
// - Metrics collection or health checks
// - Scheduled tasks (e.g., daily announcements)
//
// Runners start from the bot runtime entry points, immediately after
// RunWithContext or RunWebHookWithContext begins.
//
// Example:
//
// bot.AddRunner(cleanupRunner)
//
// Runners with an empty name are skipped with a warning.
func (bot *Bot[T]) AddRunner(runner Runner[T]) *Bot[T] {
if !bot.configMutable("AddRunner") {
return bot
}
if runner.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))
return bot
}
// AddAppDataLoggerWriter adds an app-data-backed logger writer to all loggers.
//
// The writer will receive logs from:
// - 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
// writers; call AddAppDataLoggerWriter again after adding them.
//
// Example:
//
// bot.AddAppDataLoggerWriter(func(data *MyAppData) slog.LoggerWriter {
// return data.QueryLogger()
// })
func (bot *Bot[T]) AddAppDataLoggerWriter(writer AppDataLogger[T]) *Bot[T] {
if !bot.hasAppData {
bot.logger.Warnln("app data is not set; skipping app-data logger writer")
return bot
}
if isNilValue(bot.appData) {
bot.logger.Warnln("app data is nil; skipping app-data logger writer")
return bot
}
w := writer(bot.appData)
bot.logger.AddWriter(w)
if bot.RequestLogger != nil {
bot.RequestLogger.AddWriter(w)
}
for _, l := range bot.extraLoggers {
l.AddWriter(w)
}
for _, p := range bot.plugins {
if p.logger != nil {
p.logger.AddWriter(w)
}
}
return bot
}
+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)
}
+590
View File
@@ -0,0 +1,590 @@
package laniakea
import (
"context"
"errors"
"io"
"net/http"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
type pollingRoundTripFunc func(*http.Request) (*http.Response, error)
func (f pollingRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type pollingRetryObserver struct {
recordingObserver
cancel context.CancelFunc
}
func (o *pollingRetryObserver) OnPollingRetry(ctx context.Context, ev PollingRetryEvent) {
o.recordingObserver.OnPollingRetry(ctx, ev)
if o.cancel != nil {
o.cancel()
}
}
type testObserver struct{}
func (testObserver) OnReceiveUpdate(context.Context, UpdateReceivedEvent) {}
func (testObserver) OnHandledUpdate(context.Context, UpdateHandledEvent) {}
func (testObserver) OnHandlerStarted(context.Context, HandlerStartedEvent) {}
func (testObserver) OnHandlerFinished(context.Context, HandlerFinishedEvent) {
}
func (testObserver) OnSceneTransition(context.Context, SceneTransitionEvent) {}
func (testObserver) OnPolicyChecked(context.Context, PolicyCheckedEvent) {}
func (testObserver) OnRunnerFinished(context.Context, RunnerFinishedEvent) {}
func (testObserver) OnPollingRetry(context.Context, PollingRetryEvent) {}
func (testObserver) OnError(context.Context, ErrorEvent) {}
func TestGetUpdateTypesReturnsCopy(t *testing.T) {
bot := &Bot[NoData]{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[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoData]("demo")
cmd := plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "start")
plugin.AddMiddleware(NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true }))
bot.AddPlugins(plugin)
cmd.SetDescription("mutated after registration")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "late")
plugin.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) 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[NoData]{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[NoData]{logger: slog.CreateLogger()}
plugin := NewPlugin[NoData]("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[NoData]{}
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 TestAddDatabaseLoggerWriterSkipsWhenAppDataIsUnset(t *testing.T) {
bot := &Bot[NoData]{logger: slog.CreateLogger()}
called := false
bot.AddAppDataLoggerWriter(func(db NoData) slog.LoggerWriter {
called = true
return nil
})
if called {
t.Fatal("expected app-data logger writer to be skipped when app data is unset")
}
}
func TestAddDatabaseLoggerWriterSkipsWhenAppDataIsNil(t *testing.T) {
type testDB struct{}
bot := &Bot[*testDB]{logger: slog.CreateLogger()}
var db *testDB
bot.SetAppData(db)
called := false
bot.AddAppDataLoggerWriter(func(db *testDB) slog.LoggerWriter {
called = true
return nil
})
if called {
t.Fatal("expected app-data logger writer to be skipped when app data is nil")
}
}
func TestShouldWarnOnValueAppData(t *testing.T) {
type testDB struct{}
type dbIface interface{ Ping() error }
tests := []struct {
name string
got bool
want bool
}{
{name: "NoData", got: shouldWarnOnValueAppData[NoData](), want: false},
{name: "pointer", got: shouldWarnOnValueAppData[*testDB](), want: false},
{name: "interface", got: shouldWarnOnValueAppData[dbIface](), want: false},
{name: "map", got: shouldWarnOnValueAppData[map[string]int](), want: false},
{name: "struct", got: shouldWarnOnValueAppData[testDB](), want: true},
{name: "int", got: shouldWarnOnValueAppData[int](), want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got != tt.want {
t.Fatalf("shouldWarnOnValueAppData = %v, want %v", tt.got, tt.want)
}
})
}
}
func TestSetAppDataMarksValueWarningOnce(t *testing.T) {
type testDB struct{}
bot := &Bot[testDB]{logger: slog.CreateLogger()}
bot.SetAppData(testDB{})
if !bot.warnedValueData {
t.Fatal("expected value-typed app data to mark warning state")
}
ptrBot := &Bot[*testDB]{logger: slog.CreateLogger()}
ptrBot.SetAppData(&testDB{})
if ptrBot.warnedValueData {
t.Fatal("did not expect pointer-typed app data to mark warning state")
}
}
func TestSetObserverAndGetObserver(t *testing.T) {
bot := &Bot[NoData]{logger: slog.CreateLogger()}
observer := testObserver{}
if got := bot.GetObserver(); got != nil {
t.Fatalf("expected nil observer by default, got %#v", got)
}
bot.SetObserver(observer)
if got := bot.GetObserver(); got == nil {
t.Fatal("expected observer to be stored")
}
}
func TestSetObserverNilClearsObserver(t *testing.T) {
bot := &Bot[NoData]{logger: slog.CreateLogger()}
bot.SetObserver(testObserver{})
if bot.GetObserver() == nil {
t.Fatal("expected observer to be set")
}
bot.SetObserver(nil)
if got := bot.GetObserver(); got != nil {
t.Fatalf("expected nil observer after clearing, got %#v", got)
}
}
func TestRunWithContextRejectsSecondRun(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
plugins: []Plugin[NoData]{{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)
}
}
func TestCloseDoesNotDeleteWebhook(t *testing.T) {
requests := 0
client := &http.Client{
Transport: pollingRoundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("http://example.invalid").
SetHTTPClient(client),
)
uploader := tgapi.NewUploader(api)
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
webHookLogger: slog.CreateLogger(),
api: api,
uploader: uploader,
}
if err := bot.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
if requests != 0 {
t.Fatalf("Close performed unexpected remote requests: got %d want 0", requests)
}
}
func TestRunWithContextEmitsPollingRetryAndErrorEvents(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
observer := &pollingRetryObserver{cancel: cancel}
client := &http.Client{
Transport: pollingRoundTripFunc(func(r *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":false,"error_code":500,"description":"boom"}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("http://example.invalid").
SetHTTPClient(client),
)
defer func() {
_ = api.Close()
}()
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
api: api,
prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}},
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
observer: observer,
}
if err := bot.RunWithContext(ctx); err != nil {
t.Fatalf("RunWithContext returned error: %v", err)
}
if len(observer.retries) != 1 {
t.Fatalf("expected one polling retry event, got %d", len(observer.retries))
}
if got := observer.retries[0]; got.Attempt != 1 || got.Delay <= 0 || got.Err == nil {
t.Fatalf("unexpected polling retry event: %#v", got)
}
if len(observer.errors) != 1 {
t.Fatalf("expected one polling error event, got %d", len(observer.errors))
}
if got := observer.errors[0]; got.HandlerKind != HandlerPollingKind || got.HandlerName != "getUpdates" || got.Plugin != "bot" || got.Err == nil || got.UserFacing {
t.Fatalf("unexpected polling error event: %#v", got)
}
}
func TestBotConfigurationFreezesAfterRunStarts(t *testing.T) {
type testDB struct{ Name string }
makeBot := func() *Bot[*testDB] {
return &Bot[*testDB]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
payloadType: BotPayloadBase64,
strictPayloadType: false,
errorTemplate: "%s",
l10n: &L10n{},
draftProvider: &DraftProvider{},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
}
tests := []struct {
name string
check func(t *testing.T, bot *Bot[*testDB])
}{
{
name: "SetAppData",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := &testDB{Name: "before"}
bot.SetAppData(original)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
later := &testDB{Name: "after"}
bot.SetAppData(later)
if bot.appData != original {
t.Fatal("SetAppData mutated after configuration freeze")
}
},
},
{
name: "UpdateTypes",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]tgapi.UpdateType(nil), bot.updateTypes...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetUpdateTypes(tgapi.UpdateTypePoll)
if !reflect.DeepEqual(bot.updateTypes, original) {
t.Fatalf("UpdateTypes mutated after configuration freeze: got %v want %v", bot.updateTypes, original)
}
},
},
{
name: "AddUpdateType",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]tgapi.UpdateType(nil), bot.updateTypes...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.AddUpdateType(tgapi.UpdateTypePoll)
if !reflect.DeepEqual(bot.updateTypes, original) {
t.Fatalf("AddUpdateType mutated after configuration freeze: got %v want %v", bot.updateTypes, original)
}
},
},
{
name: "SetPayloadType",
check: func(t *testing.T, bot *Bot[*testDB]) {
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetPayloadType(BotPayloadJson)
if bot.payloadType != BotPayloadBase64 {
t.Fatalf("payloadType mutated after configuration freeze: got %q want %q", bot.payloadType, BotPayloadBase64)
}
},
},
{
name: "SetStrictPayloadType",
check: func(t *testing.T, bot *Bot[*testDB]) {
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetStrictPayloadType(true)
if bot.strictPayloadType {
t.Fatal("strictPayloadType mutated after configuration freeze")
}
},
},
{
name: "AddPrefixes",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]string(nil), bot.prefixes...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.AddPrefixes("!")
if !reflect.DeepEqual(bot.prefixes, original) {
t.Fatalf("prefixes mutated after configuration freeze: got %v want %v", bot.prefixes, original)
}
},
},
{
name: "ErrorTemplate",
check: func(t *testing.T, bot *Bot[*testDB]) {
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetErrorTemplate("changed")
if bot.errorTemplate != "%s" {
t.Fatalf("errorTemplate mutated after configuration freeze: got %q want %q", bot.errorTemplate, "%s")
}
},
},
{
name: "SetDraftProvider",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := bot.draftProvider
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetDraftProvider(&DraftProvider{})
if bot.draftProvider != original {
t.Fatal("draftProvider mutated after configuration freeze")
}
},
},
{
name: "SetSessionStore",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := bot.sessionStore
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetSessionStore(NewMemorySessionStore())
if bot.sessionStore != original {
t.Fatal("sessionStore mutated after configuration freeze")
}
},
},
{
name: "SetSceneScopePriority",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := append([]SceneScope(nil), bot.sceneScopePriority...)
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetSceneScopePriority([]SceneScope{SceneScopeUser})
if !reflect.DeepEqual(bot.sceneScopePriority, original) {
t.Fatalf("sceneScopePriority mutated after configuration freeze: got %v want %v", bot.sceneScopePriority, original)
}
},
},
{
name: "AddL10n",
check: func(t *testing.T, bot *Bot[*testDB]) {
original := bot.l10n
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
t.Cleanup(bot.finishRun)
bot.SetL10n(&L10n{})
if bot.l10n != original {
t.Fatal("l10n mutated after configuration freeze")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.check(t, makeBot())
})
}
}
func TestAddPluginsAndRuntimeRegistrationsNoOpAfterRunStarts(t *testing.T) {
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
middlewares: []Middleware[NoData]{NewMiddleware("base", func(ctx *MsgContext, db NoData) bool { return true })},
runners: []Runner[NoData]{NewRunner("base", func(bot *Bot[NoData]) error { return nil })},
}
plugin := NewPlugin[NoData]("late")
if err := bot.beginRun(); err != nil {
t.Fatalf("beginRun returned error: %v", err)
}
defer bot.finishRun()
bot.AddPlugins(plugin)
bot.AddMiddleware(NewMiddleware("late", func(ctx *MsgContext, db NoData) bool { return true }))
bot.AddRunner(NewRunner("late", func(bot *Bot[NoData]) error { return nil }))
if len(bot.plugins) != 0 {
t.Fatalf("expected AddPlugins to be ignored after configuration freeze, got %d plugins", len(bot.plugins))
}
if len(bot.middlewares) != 1 {
t.Fatalf("expected AddMiddleware to be ignored after configuration freeze, got %d middlewares", len(bot.middlewares))
}
if len(bot.runners) != 1 {
t.Fatalf("expected AddRunner to be ignored after configuration freeze, got %d runners", len(bot.runners))
}
}
+178
View File
@@ -0,0 +1,178 @@
package laniakea
import (
"context"
"fmt"
"maps"
"reflect"
"strings"
"time"
"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"
)
func (bot *Bot[T]) enqueueUpdate(ctx context.Context, update tgapi.Update) error {
select {
case <-ctx.Done():
return ctx.Err()
case bot.updateQueue <- new(update):
return nil
}
}
func (bot *Bot[T]) startUpdateWorkers(ctx context.Context) {
pool := pond.NewPool(bot.maxWorkers)
for update := range bot.updateQueue {
u := update // capture loop variable
pool.Submit(func() {
bot.handle(ctx, u)
})
}
pool.Stop() // Wait for all tasks to complete and stop the pool
}
func (bot *Bot[T]) initLoggers(opts *BotOpts) {
level := slog.FATAL
if opts.Debug {
level = slog.DEBUG
}
bot.logger = utils.CreateLogger("BOT", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("BOT", level, path)
if err != nil {
bot.logger.Errorln(err)
} else {
bot.logger = logger
}
}
if opts.UseRequestLogger {
bot.RequestLogger = utils.CreateLogger("REQUESTS", level)
if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
logger, err := utils.CreateFileLogger("REQUESTS", level, path)
if err != nil {
bot.logger.Errorln(err)
} else {
bot.RequestLogger = logger
}
}
}
}
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 shouldWarnOnValueAppData[T any]() bool {
t := reflect.TypeFor[T]()
if t == reflect.TypeFor[NoData]() {
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 AppData](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 AppData](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 AppData](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
}
+461
View File
@@ -0,0 +1,461 @@
package laniakea
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
)
// BotWebHookOpts configures Telegram webhook registration and the local HTTP server.
type BotWebHookOpts struct {
Path string
LocalPort int
UseStatusPath bool
URL string
Certificate []byte
IPAddress string
MaxConnections int8
AllowedUpdates []tgapi.UpdateType
DropPendingUpdates bool
SecretToken string
}
// NewBotWebHookOpts returns webhook options with the default path, local port, and max connections.
func NewBotWebHookOpts() *BotWebHookOpts {
return &BotWebHookOpts{
Path: "/",
LocalPort: 8080,
MaxConnections: 40,
}
}
// SetPath sets the local HTTP path that receives Telegram webhook requests.
func (opts *BotWebHookOpts) SetPath(path string) *BotWebHookOpts {
opts.Path = path
return opts
}
// SetLocalPort sets the local HTTP port used by the webhook server.
func (opts *BotWebHookOpts) SetLocalPort(port int) *BotWebHookOpts {
opts.LocalPort = port
return opts
}
// SetUseStatusPath enables or disables the optional /status endpoint.
// A non-empty SecretToken is required when this endpoint is enabled.
func (opts *BotWebHookOpts) SetUseStatusPath(use bool) *BotWebHookOpts {
opts.UseStatusPath = use
return opts
}
// SetURL sets the public base URL Telegram should call for incoming updates.
func (opts *BotWebHookOpts) SetURL(url string) *BotWebHookOpts {
opts.URL = url
return opts
}
// SetCertificate sets the self-signed webhook certificate bytes to upload.
func (opts *BotWebHookOpts) SetCertificate(certificate []byte) *BotWebHookOpts {
opts.Certificate = certificate
return opts
}
// MustLoadCertificate loads a webhook certificate from disk and panics on failure.
func (opts *BotWebHookOpts) MustLoadCertificate(filename string) *BotWebHookOpts {
f, err := os.Open(filename)
if err != nil {
panic(err)
}
defer func() {
_ = f.Close()
}()
opts.Certificate, err = io.ReadAll(f)
if err != nil {
panic(err)
}
return opts
}
// SetIPAddress sets the fixed IP address Telegram should use for webhook delivery.
func (opts *BotWebHookOpts) SetIPAddress(ip string) *BotWebHookOpts {
opts.IPAddress = ip
return opts
}
// SetMaxConnections sets Telegram's maximum number of simultaneous webhook connections.
func (opts *BotWebHookOpts) SetMaxConnections(max int8) *BotWebHookOpts {
opts.MaxConnections = max
return opts
}
// SetAllowedUpdates sets the Telegram update types that should be delivered to the webhook.
func (opts *BotWebHookOpts) SetAllowedUpdates(updates ...tgapi.UpdateType) *BotWebHookOpts {
opts.AllowedUpdates = append([]tgapi.UpdateType(nil), updates...)
return opts
}
// SetDropPendingUpdates configures whether Telegram should drop pending updates while setting the webhook.
func (opts *BotWebHookOpts) SetDropPendingUpdates(drop bool) *BotWebHookOpts {
opts.DropPendingUpdates = drop
return opts
}
// SetSecretToken sets the secret token expected in Telegram webhook requests.
// The same token is also required to access /status when that endpoint is enabled.
func (opts *BotWebHookOpts) SetSecretToken(secretToken string) *BotWebHookOpts {
opts.SecretToken = secretToken
return opts
}
// RunWebHookWithContext registers the webhook and serves incoming updates until ctx is canceled.
//
// The bot uses the same update queue, worker pool, runner startup, and single-use lifecycle
// guarantees as RunWithContext. When opts.AllowedUpdates is empty, the bot-level update types
// configured through SetUpdateTypes/AddUpdateType are used. When UseStatusPath is enabled,
// SecretToken must be non-empty so the operational endpoint is not left public.
//
// When two TLS files are provided, the method serves HTTPS using the existing key-then-cert
// argument order.
func (bot *Bot[T]) RunWebHookWithContext(ctx context.Context, opts *BotWebHookOpts, tlsFiles ...string) error {
if opts == nil {
return errors.New("nil BotWebHookOpts")
}
if len(bot.prefixes) == 0 {
return ErrNoPrefixes
}
if len(bot.plugins) == 0 {
return ErrNoPlugins
}
if opts.URL == "" {
return errors.New("empty BotWebHookOpts.URL")
}
if opts.MaxConnections > 100 || opts.MaxConnections <= 0 {
return errors.New("BotWebHookOpts.MaxConnections must between 1 and 100")
}
if err := validateWebhookPath(opts.Path, opts.UseStatusPath); err != nil {
return err
}
if opts.UseStatusPath && opts.SecretToken == "" {
return errors.New("BotWebHookOpts.SecretToken required when status path is enabled")
}
if err := validateWebhookTLSFiles(tlsFiles); err != nil {
return err
}
bot.webHookLogger = utils.CreateLogger("WEBHOOK", bot.GetLoggerLevel())
if opts.SecretToken == "" {
bot.webHookLogger.Warnln("Bot webhook secret token empty. It's VERY recommended to set secret.")
}
if opts.Certificate != nil && bot.uploader == nil {
return errors.New("bot uploader nil, but certificate set")
}
return bot.runWebhookRuntime(ctx, func(runCtx context.Context) error {
i, err := bot.api.GetWebhookInfoWithContext(runCtx)
if err != nil {
return err
}
if i.URL == "" {
bot.webHookLogger.Warnln("API returned webhook info with empty URL. There may be a long-poll")
} else {
_, err = bot.api.DeleteWebhookWithContext(runCtx, tgapi.DeleteWebhook{})
if err != nil {
return err
}
bot.webHookLogger.Infof("Bot webhook deleted: %s", i.URL)
}
allowedUpdates := bot.webhookAllowedUpdates(opts)
var ok bool
if opts.Certificate != nil {
ok, err = bot.uploader.SetWebhookWithContext(runCtx, tgapi.UploadSetWebhook{
URL: fmt.Sprintf("%s%s", opts.URL, opts.Path),
IPAddress: opts.IPAddress,
MaxConnections: opts.MaxConnections,
AllowedUpdates: allowedUpdates,
DropPendingUpdates: opts.DropPendingUpdates,
SecretToken: opts.SecretToken,
}, tgapi.NewUploaderFile("certificate", opts.Certificate))
} else {
ok, err = bot.api.SetWebhookWithContext(runCtx, tgapi.SetWebhook{
URL: fmt.Sprintf("%s%s", opts.URL, opts.Path),
IPAddress: opts.IPAddress,
MaxConnections: opts.MaxConnections,
AllowedUpdates: allowedUpdates,
DropPendingUpdates: opts.DropPendingUpdates,
SecretToken: opts.SecretToken,
})
}
if err != nil {
return err
}
if !ok {
return errors.New("failed to set webhook")
}
if len(tlsFiles) == 2 {
return bot.runWebHookTLS(runCtx, opts, tlsFiles[0], tlsFiles[1])
}
return bot.runWebHook(runCtx, opts)
})
}
// RunWebHook starts the webhook runtime with a background context.
//
// It is shorthand for RunWebHookWithContext(context.Background(), opts, tlsFiles...).
func (bot *Bot[T]) RunWebHook(opts *BotWebHookOpts, tlsFiles ...string) error {
return bot.RunWebHookWithContext(context.Background(), opts, tlsFiles...)
}
// CloseWebHook removes the current Telegram webhook registration.
//
// It is separate from Close, which only releases local resources.
// Call it before switching a deployment from webhook delivery to polling.
func (bot *Bot[T]) CloseWebHook() error {
var e []error
if bot.api == nil {
e = append(e, errors.New("bot api nil"))
} else {
if _, err := bot.api.DeleteWebhook(tgapi.DeleteWebhook{}); err != nil {
if bot.webHookLogger != nil {
bot.webHookLogger.Errorf("Failed to close webhook: %s", err.Error())
} else if bot.logger != nil {
bot.logger.Errorf("Failed to close webhook: %s", err.Error())
}
e = append(e, err)
}
}
if bot.webHookLogger != nil {
if err := bot.webHookLogger.Close(); err != nil {
e = append(e, err)
}
bot.webHookLogger = nil
}
return errors.Join(e...)
}
func (bot *Bot[T]) webhookAllowedUpdates(opts *BotWebHookOpts) []tgapi.UpdateType {
if len(opts.AllowedUpdates) > 0 {
return append([]tgapi.UpdateType(nil), opts.AllowedUpdates...)
}
return bot.GetUpdateTypes()
}
func (bot *Bot[T]) runWebhookRuntime(ctx context.Context, run func(context.Context) error) error {
if err := bot.beginRun(); err != nil {
return err
}
defer bot.finishRun()
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
bot.ExecRunners(runCtx)
workersDone := make(chan struct{})
go func() {
bot.startUpdateWorkers(runCtx)
close(workersDone)
}()
runErr := run(runCtx)
cancel()
close(bot.updateQueue)
<-workersDone
bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait()
return runErr
}
func updateHandler[T any](ctx context.Context, bot *Bot[T], secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
defer func() {
_ = r.Body.Close()
}()
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if secret != "" && r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != secret {
w.WriteHeader(http.StatusForbidden)
return
}
const maxWebhookBodySize = 256 << 10 // 256 KiB
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize)
data, err := io.ReadAll(r.Body)
if err != nil {
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusBadRequest)
return
}
if len(data) == 0 {
w.WriteHeader(http.StatusBadRequest)
return
}
var up tgapi.Update
if err := json.Unmarshal(data, &up); err != nil {
w.WriteHeader(http.StatusBadRequest)
bot.webHookLogger.Errorln(err)
return
}
bot.webHookLogger.Debugf("UPDATE id=%d type=%s size=%d from=%s", up.UpdateID, up.Type, len(data), r.RemoteAddr)
if err := bot.enqueueUpdate(ctx, up); err != nil {
bot.webHookLogger.Errorln(err)
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
}
func statusHandler[T any](bot *Bot[T], opts *BotWebHookOpts) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
auth := ""
if r.Header.Get("Authorization") != "" {
auth = r.Header.Get("Authorization")
} else if r.Header.Get("X-Telegram-Bot-Api-Secret-Token") != "" {
auth = r.Header.Get("X-Telegram-Bot-Api-Secret-Token")
}
if auth != opts.SecretToken {
w.WriteHeader(http.StatusNotFound)
return
}
i, err := bot.api.GetWebhookInfoWithContext(r.Context())
if err != nil {
bot.webHookLogger.Errorln(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
data, err := json.MarshalIndent(i, "", " ")
if err != nil {
bot.webHookLogger.Errorln(err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if _, err := fmt.Fprint(w, string(data)); err != nil {
bot.webHookLogger.Errorln(err)
}
}
}
func (bot *Bot[T]) newWebHookMux(ctx context.Context, opts *BotWebHookOpts) *http.ServeMux {
r := http.NewServeMux()
if opts.UseStatusPath {
r.HandleFunc("/status", statusHandler[T](bot, opts))
}
r.HandleFunc(opts.Path, updateHandler[T](ctx, bot, opts.SecretToken))
return r
}
func (bot *Bot[T]) runWebHook(ctx context.Context, opts *BotWebHookOpts) error {
srv := &http.Server{
Addr: fmt.Sprintf(":%d", opts.LocalPort),
Handler: bot.newWebHookMux(ctx, opts),
}
errCh := make(chan error, 1)
go func() {
err := srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
bot.webHookLogger.Infoln(fmt.Sprintf("Bot WebHook started at %s; waiting for updates at %s", srv.Addr, opts.URL))
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
return <-errCh
case err := <-errCh:
return err
}
}
func (bot *Bot[T]) runWebHookTLS(ctx context.Context, opts *BotWebHookOpts, key, cert string) error {
srv := &http.Server{
Addr: fmt.Sprintf(":%d", opts.LocalPort),
Handler: bot.newWebHookMux(ctx, opts),
}
errCh := make(chan error, 1)
go func() {
err := srv.ListenAndServeTLS(cert, key)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
return
}
errCh <- nil
}()
bot.webHookLogger.Infoln(fmt.Sprintf("Bot webhook started with TLS(%s, %s) at %s; waiting for updates at %s", key, cert, srv.Addr, opts.URL))
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return err
}
return <-errCh
case err := <-errCh:
return err
}
}
func validateWebhookPath(path string, useStatusPath bool) error {
if path == "" {
return errors.New("empty BotWebHookOpts.Path")
}
if !strings.HasPrefix(path, "/") {
return errors.New("BotWebHookOpts.Path must start with '/'")
}
if strings.Contains(path, "?") || strings.Contains(path, "#") {
return errors.New("BotWebHookOpts.Path must not contain query or fragment")
}
if useStatusPath && path == "/status" {
return errors.New("BotWebHookOpts.Path must not be '/status' when status path is enabled")
}
return nil
}
func validateWebhookTLSFiles(tlsFiles []string) error {
switch len(tlsFiles) {
case 0, 2:
return nil
case 1:
return errors.New("you must specify both private and public keys")
default:
return errors.New("too many files; you must specify only private and public keys")
}
}
+293
View File
@@ -0,0 +1,293 @@
package laniakea
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestEnqueueUpdateCopiesValue(t *testing.T) {
bot := &Bot[NoData]{
updateQueue: make(chan *tgapi.Update, 1),
}
update := tgapi.Update{UpdateID: 42}
if err := bot.enqueueUpdate(context.Background(), update); err != nil {
t.Fatalf("enqueueUpdate returned error: %v", err)
}
update.UpdateID = 99
got := <-bot.updateQueue
if got.UpdateID != 42 {
t.Fatalf("enqueueUpdate did not copy the update value: got %d want %d", got.UpdateID, 42)
}
}
func TestUpdateHandlerEnqueuesUpdate(t *testing.T) {
bot := &Bot[NoData]{
updateQueue: make(chan *tgapi.Update, 1),
webHookLogger: slog.CreateLogger(),
}
t.Cleanup(func() {
_ = bot.webHookLogger.Close()
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"update_id":7,"message":{"message_id":1,"date":1,"chat":{"id":1,"type":"private"},"text":"/start"}}`))
req.Header.Set("X-Telegram-Bot-Api-Secret-Token", "secret")
rec := httptest.NewRecorder()
updateHandler(context.Background(), bot, "secret").ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusOK {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusOK)
}
select {
case got := <-bot.updateQueue:
if got.UpdateID != 7 {
t.Fatalf("unexpected update id in queue: got %d want %d", got.UpdateID, 7)
}
if got.Type != tgapi.UpdateTypeMessage {
t.Fatalf("unexpected update type in queue: got %q want %q", got.Type, tgapi.UpdateTypeMessage)
}
default:
t.Fatal("expected webhook handler to enqueue an update")
}
}
func TestRunWebhookRuntimeRejectsSecondRun(t *testing.T) {
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
}
t.Cleanup(func() {
_ = bot.logger.Close()
})
if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil {
t.Fatalf("first runWebhookRuntime returned error: %v", err)
}
if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); !errors.Is(err, ErrBotAlreadyRun) {
t.Fatalf("expected ErrBotAlreadyRun on second run, got %v", err)
}
}
func TestRunWebhookRuntimeExecutesRunners(t *testing.T) {
var calls atomic.Int32
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
updateQueue: make(chan *tgapi.Update, 1),
maxWorkers: 1,
runners: []Runner[NoData]{
NewRunner("runner", func(bot *Bot[NoData]) error {
calls.Add(1)
return nil
}).Onetime(true).Async(false),
},
}
t.Cleanup(func() {
_ = bot.logger.Close()
})
if err := bot.runWebhookRuntime(context.Background(), func(context.Context) error { return nil }); err != nil {
t.Fatalf("runWebhookRuntime returned error: %v", err)
}
if got := calls.Load(); got != 1 {
t.Fatalf("expected runner to execute once, got %d", got)
}
}
func TestWebhookAllowedUpdatesUsesBotUpdateTypesByDefault(t *testing.T) {
bot := &Bot[NoData]{
updateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
}
opts := NewBotWebHookOpts()
got := bot.webhookAllowedUpdates(opts)
if len(got) != 2 {
t.Fatalf("unexpected allowed updates length: got %d want %d", len(got), 2)
}
if got[0] != tgapi.UpdateTypeMessage || got[1] != tgapi.UpdateTypeCallbackQuery {
t.Fatalf("unexpected allowed updates: %v", got)
}
got[0] = tgapi.UpdateTypePoll
if bot.updateTypes[0] != tgapi.UpdateTypeMessage {
t.Fatalf("webhookAllowedUpdates exposed internal slice: got %v", bot.updateTypes)
}
}
func TestValidateWebhookPath(t *testing.T) {
tests := []struct {
name string
path string
useStatusPath bool
wantErr bool
}{
{name: "root", path: "/", wantErr: false},
{name: "custom path", path: "/telegram", wantErr: false},
{name: "empty", path: "", wantErr: true},
{name: "missing slash", path: "telegram", wantErr: true},
{name: "query", path: "/telegram?x=1", wantErr: true},
{name: "fragment", path: "/telegram#main", wantErr: true},
{name: "status collision", path: "/status", useStatusPath: true, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateWebhookPath(tc.path, tc.useStatusPath)
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestValidateWebhookTLSFiles(t *testing.T) {
tests := []struct {
name string
files []string
wantErr bool
}{
{name: "no tls", files: nil, wantErr: false},
{name: "two files", files: []string{"key.pem", "cert.pem"}, wantErr: false},
{name: "one file", files: []string{"cert.pem"}, wantErr: true},
{name: "three files", files: []string{"a", "b", "c"}, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validateWebhookTLSFiles(tc.files)
if tc.wantErr && err == nil {
t.Fatal("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestUpdateHandlerRejectsOversizedBody(t *testing.T) {
bot := &Bot[NoData]{
updateQueue: make(chan *tgapi.Update, 1),
webHookLogger: slog.CreateLogger(),
}
t.Cleanup(func() {
_ = bot.webHookLogger.Close()
})
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(strings.Repeat("a", (256<<10)+1)))
rec := httptest.NewRecorder()
updateHandler(context.Background(), bot, "").ServeHTTP(rec, req)
if rec.Result().StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, http.StatusRequestEntityTooLarge)
}
}
func TestStatusHandlerRequiresMatchingSecret(t *testing.T) {
client := &http.Client{
Transport: pollingRoundTripFunc(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":{"url":"https://bot.example.com/telegram"}}`)),
}, nil
}),
}
api := tgapi.NewAPI(
tgapi.NewAPIOpts("token").
SetAPIUrl("http://example.invalid").
SetHTTPClient(client),
)
defer func() {
_ = api.Close()
}()
bot := &Bot[NoData]{
api: api,
webHookLogger: slog.CreateLogger(),
}
t.Cleanup(func() {
_ = bot.webHookLogger.Close()
})
handler := statusHandler(bot, &BotWebHookOpts{SecretToken: "secret"})
tests := []struct {
name string
headerName string
headerVal string
wantStatus int
}{
{name: "missing auth", wantStatus: http.StatusNotFound},
{name: "wrong auth", headerName: "Authorization", headerVal: "wrong", wantStatus: http.StatusNotFound},
{name: "matching telegram header", headerName: "X-Telegram-Bot-Api-Secret-Token", headerVal: "secret", wantStatus: http.StatusOK},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/status", nil)
if tc.headerName != "" {
req.Header.Set(tc.headerName, tc.headerVal)
}
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Result().StatusCode != tc.wantStatus {
t.Fatalf("unexpected status: got %d want %d", rec.Result().StatusCode, tc.wantStatus)
}
})
}
}
func TestRunWebHookWithContextRejectsInvalidTLSFilesBeforeRemoteSetup(t *testing.T) {
bot := &Bot[NoData]{
prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}},
}
opts := NewBotWebHookOpts().SetURL("https://bot.example.com")
err := bot.RunWebHookWithContext(context.Background(), opts, "cert.pem")
if err == nil {
t.Fatal("expected tls validation error, got nil")
}
if !strings.Contains(err.Error(), "both private and public keys") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRunWebHookWithContextRequiresSecretWhenStatusPathEnabled(t *testing.T) {
bot := &Bot[NoData]{
prefixes: []string{"/"},
plugins: []Plugin[NoData]{{name: "demo"}},
}
opts := NewBotWebHookOpts().
SetURL("https://bot.example.com").
SetUseStatusPath(true)
err := bot.RunWebHookWithContext(context.Background(), opts)
if err == nil {
t.Fatal("expected status-path secret validation error, got nil")
}
if !strings.Contains(err.Error(), "SecretToken required") {
t.Fatalf("unexpected error: %v", err)
}
}
+19 -13
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 {
@@ -106,7 +112,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
}
// Clear existing commands to avoid duplication or stale entries
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{})
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
@@ -119,7 +125,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
}
for _, scope := range scopes {
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
_, err = bot.api.SetMyCommands(tgapi.SetMyCommands{
Commands: commands,
Scope: scope,
})
@@ -153,12 +159,12 @@ func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) er
return ErrTooManyCommands
}
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope})
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{Scope: scope})
if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err)
}
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{
_, err = bot.api.SetMyCommands(tgapi.SetMyCommands{
Commands: commands,
Scope: scope,
})
+27 -6
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)
@@ -42,16 +43,16 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
}
}()
plugin := NewPlugin[NoDB]("overflow")
exec := func(ctx *MsgContext, db *NoDB) {}
plugin := NewPlugin[NoData]("overflow")
exec := func(ctx *MsgContext, db NoData) error { return nil }
for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
}
bot := &Bot[NoDB]{
bot := &Bot[NoData]{
api: api,
logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{*plugin},
plugins: []Plugin[NoData]{*plugin},
}
err := bot.AutoGenerateCommands()
@@ -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[NoData]("sorted")
exec := func(ctx *MsgContext, db NoData) 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)
}
}
+19 -41
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.AppData](laniakea.LoadOptsFromEnv())
if err != nil {
return err
}
bot.SetAppData(myDB).
AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(&authMiddleware, &logMiddleware).
AddRunner(&cleanupRunner).
AddL10n(l10n.New())
AddMiddleware(authMiddleware, logMiddleware).
AddRunner(cleanupRunner).
SetL10n(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, RunWithContext, or RunWebHookWithContext.
Runtime accessors are safe for concurrent use unless stated otherwise.
*/
package laniakea
+17 -18
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,8 +214,14 @@ 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{
params := tgapi.SendMessage{
ChatID: d.chatID,
ParseMode: d.parseMode,
Entities: d.entities,
@@ -242,13 +238,16 @@ 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
params := tgapi.SendMessageDraftP{
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageDraft{
ChatID: d.chatID,
DraftID: d.ID,
Text: d.Message,
+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: 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)
}
}
+59
View File
@@ -0,0 +1,59 @@
package laniakea
import "errors"
type classifiedError struct {
err error
userVisible bool
internalOnly bool
}
func (e *classifiedError) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *classifiedError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
// AsUserError marks err as safe to show to the user through the centralized
// handler error flow.
func AsUserError(err error) error {
if err == nil {
return nil
}
return &classifiedError{err: err, userVisible: true}
}
// AsInternalError marks err as internal-only so it will be logged but not sent
// to the user through the centralized handler error flow.
func AsInternalError(err error) error {
if err == nil {
return nil
}
return &classifiedError{err: err, internalOnly: true}
}
// IsUserError reports whether err was explicitly marked as user-visible.
func IsUserError(err error) bool {
var classified *classifiedError
if !errors.As(err, &classified) {
return false
}
return classified.userVisible
}
// IsInternalError reports whether err was explicitly marked as internal-only.
func IsInternalError(err error) bool {
var classified *classifiedError
if !errors.As(err, &classified) {
return false
}
return classified.internalOnly
}
+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
}
+3 -3
View File
@@ -1,12 +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=
+111 -135
View File
@@ -1,160 +1,116 @@
package laniakea
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"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))
}
}()
startTime := time.Now()
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,
observer: bot.observer,
payloadType: bot.payloadType,
ctx: ctx,
}
bot.prepareUpdateCtx(u, msgCtx)
bot.safeEmitEvent(ctx, UpdateReceivedEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
})
for _, middleware := range bot.middlewares {
if !middleware.Execute(ctx, bot.dbContext) {
if !middleware.Execute(msgCtx, bot.appData) {
return
}
}
if u.CallbackQuery != nil {
bot.handleCallback(u, ctx)
} else {
bot.handleMessage(u, ctx)
}
}
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) {
if update.Message == nil {
return
}
if update.Message.From == nil {
return
}
var text string
if len(update.Message.Text) > 0 {
text = update.Message.Text
} else {
text = update.Message.Caption
}
text = strings.TrimSpace(text)
prefix, hasPrefix := bot.checkPrefixes(text)
if !hasPrefix {
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
if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) {
cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname
}
}
// Ищем команду по точному совпадению
for _, plugin := range bot.plugins {
if _, exists := plugin.commands[cmd]; exists {
ctx.Text = args
ctx.Args = strings.Fields(args) // Убирает лишние пробелы
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = ctx.botLogger
}
plugin.executeCmd(cmd, ctx, bot.dbContext)
return
}
}
}
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
data, err := bot.decodePayload(update.CallbackQuery.Data)
sceneHandled, err := bot.tryHandleScene(msgCtx)
if err != nil {
bot.logger.Errorln(err)
bot.safeEmitEvent(ctx, UpdateHandledEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Duration: time.Since(startTime),
Handled: false,
})
bot.safeEmitEvent(ctx, ErrorEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: "bot",
HandlerKind: HandlerSceneKind,
HandlerName: "tryHandleScene",
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Err: err,
UserFacing: false,
})
return
}
if sceneHandled {
bot.safeEmitEvent(ctx, UpdateHandledEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Duration: time.Since(startTime),
Handled: true,
})
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 {
_, ok := plugin.payloads[data.Command]
if !ok {
continue
}
if !plugin.executeMiddlewares(ctx, bot.dbContext) {
return
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = ctx.botLogger
}
plugin.executePayload(data.Command, ctx, bot.dbContext)
return
handled := false
switch u.Type {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
handled = bot.handleMessage(u, msgCtx)
case tgapi.UpdateTypeCallbackQuery:
handled = bot.handleCallback(u, msgCtx)
default:
handled = bot.handleUpdate(u, msgCtx)
}
bot.safeEmitEvent(ctx, UpdateHandledEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
FromID: msgCtx.FromID,
ChatID: msgCtx.ChatID,
Duration: time.Since(startTime),
Handled: handled,
})
}
func (bot *Bot[T]) checkPrefixes(text string) (string, bool) {
for _, prefix := range bot.prefixes {
if prefix == "" {
continue
}
if strings.HasPrefix(text, prefix) {
return prefix, true
}
func cloneMsgContext(src *MsgContext) *MsgContext {
cloned := *src
if src.Args != nil {
cloned.Args = append([]string(nil), src.Args...)
}
return "", false
return &cloned
}
func encodeJsonPayload(d CallbackData) (string, error) {
@@ -164,11 +120,13 @@ func encodeJsonPayload(d CallbackData) (string, error) {
}
return string(b), nil
}
func decodeJsonPayload(s string) (CallbackData, error) {
var data CallbackData
err := json.Unmarshal([]byte(s), &data)
return data, err
}
func encodeBase64Payload(d CallbackData) (string, error) {
data, err := encodeJsonPayload(d)
if err != nil {
@@ -179,15 +137,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
return string(dst), nil
}
// func encodePayload(payloadType BotPayloadType, d CallbackData) (string, error) {
// switch payloadType {
// case BotPayloadBase64:
// return encodeBase64Payload(d)
// case BotPayloadJson:
// return encodeJsonPayload(d)
// }
// return "", ErrInvalidPayloadType
// }
func decodeBase64Payload(s string) (CallbackData, error) {
b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
@@ -195,19 +144,46 @@ 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)
data, err := decodeBase64Payload(s)
if err == nil {
return data, BotPayloadBase64, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
}
data, err = decodeJsonPayload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadJson, nil
case BotPayloadJson:
return decodeJsonPayload(s)
data, err := decodeJsonPayload(s)
if err == nil {
return data, BotPayloadJson, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson)
}
data, err = decodeBase64Payload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadBase64, nil
}
return CallbackData{}, ErrInvalidPayloadType
return CallbackData{}, "", ErrInvalidPayloadType
}
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
// 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
}
+1055 -2
View File
File diff suppressed because it is too large Load Diff
+27 -20
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 tgapi.KeyboardButtonStyle = "danger"
// ButtonStyleDanger marks a destructive inline keyboard action.
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger"
// ButtonStyleSuccess marks a confirmatory inline keyboard action.
ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success"
// 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,25 @@ 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 }
// SetMaxRow sets the maximum number of buttons appended to a row before the
// keyboard automatically starts a new line.
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 +242,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 +260,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 +271,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 +282,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)
}
}
+3 -3
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.
@@ -43,8 +43,8 @@ import (
func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
offset := bot.GetUpdateOffset()
params := tgapi.UpdateParams{
Offset: Ptr(offset),
Timeout: Ptr(30),
Offset: new(offset),
Timeout: new(30),
AllowedUpdates: bot.GetUpdateTypes(),
}
+393 -66
View File
@@ -2,40 +2,87 @@ package laniakea
import (
"context"
"errors"
"fmt"
"log"
"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.
// It provides methods to respond, edit, delete, and translate messages, as well as
// manage inline keyboards and message drafts.
// MsgContext holds the normalized per-update context passed to command, payload,
// scene, middleware, and generic update handlers.
//
// MsgContext is populated from the current Telegram update before handler routing.
// Not every field is guaranteed for every update kind. In particular:
// - Update is always present.
// - Msg is populated only for update kinds that carry a Telegram message object.
// - From and FromID are populated only when the update exposes a user identity.
// - Chat and ChatID are populated only when the update exposes a chat identity.
// - Text, Args, and Prefix are populated only by command or scene command routing.
// - CallbackQueryId, CallbackMsgId, and InlineMsgId are populated only for
// callback query handling when the corresponding callback targets exist.
//
// Helper methods on MsgContext may require a message-backed context. For example,
// reply helpers need Msg, while inline callback edit helpers can work through
// InlineMsgId when there is no chat message.
type MsgContext struct {
Api *tgapi.API
Update tgapi.Update
Msg *tgapi.Message
// Msg is the normalized Telegram message for message-backed update kinds.
// It is nil for updates that do not include a message object.
Msg *tgapi.Message
// From is the normalized Telegram user for update kinds that expose one.
// It stays nil for sender-chat-only updates and update kinds without a user.
From *tgapi.User
// Chat is the normalized Telegram chat for update kinds that expose one.
// It is nil for updates that do not include a chat identity.
Chat *tgapi.Chat
// 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
// InlineMsgId is the inline message identifier for callback queries that target
// an inline message instead of a chat message.
InlineMsgId string
// CallbackMsgId is the message ID targeted by the current callback query when
// the callback comes from a chat message.
CallbackMsgId int
// CallbackQueryId is the Telegram callback query ID for payload handlers and
// callback-backed scene handlers.
CallbackQueryId string
FromID int64
Prefix string
Text string
Args []string
// FromID is the normalized sender ID when the current update exposes a user.
// It is zero when the update has no user identity.
FromID int64
// ChatID is the normalized chat ID when the current update exposes a chat.
// It is zero when the update has no chat identity.
ChatID int64
// Prefix is the matched command prefix for command routing and scene-local
// command routing. It is empty outside those flows.
Prefix string
// Text is the parsed command tail for command routing, the parsed scene-command
// tail for scene-local command routing, or the trimmed message text seen by a
// scene step/message handler. It is empty when the current routing path does
// not derive text input.
Text string
// Args contains parsed command or payload arguments for the current routing
// path. It is nil or empty when no argument vector is derived.
Args []string
errorTemplate string
botLogger *slog.Logger
l10n *L10n
draftProvider *DraftProvider
payloadType BotPayloadType
sceneRuntime sceneRuntime
observer Observer
ctx context.Context
}
// AnswerMessage represents a message sent or edited via MsgContext.
@@ -47,10 +94,13 @@ 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 {
params := tgapi.EditMessageTextP{
if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageText{
Text: text,
ParseMode: parseMode,
}
@@ -61,15 +111,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
@@ -95,11 +145,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)
@@ -129,10 +178,13 @@ 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 {
params := tgapi.EditMessageCaptionP{
if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageCaption{
Caption: text,
ParseMode: parseMode,
}
@@ -143,16 +195,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
@@ -188,14 +240,17 @@ 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
}
params := tgapi.SendMessageP{
if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.SendMessage{
ChatID: ctx.Msg.Chat.ID,
Text: text,
ParseMode: parseMode,
@@ -210,9 +265,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{
@@ -225,6 +280,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.
@@ -237,6 +300,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.
@@ -249,6 +317,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.
@@ -256,13 +331,56 @@ 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) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't answer message without a message")
func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
if parseMode != tgapi.ParseNone {
ctx.Logger.Errorln(ErrMessageSplitImpossible)
return nil
}
params := tgapi.SendPhotoP{
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.Logger.Errorln(ErrMessageContextNil)
return nil
}
if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.SendPhoto{
ChatID: ctx.Msg.Chat.ID,
Caption: text,
ParseMode: parseMode,
@@ -278,9 +396,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{
@@ -324,22 +442,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.DeleteMessage{
ChatID: ctx.Msg.Chat.ID,
MessageID: messageId,
})
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
}
@@ -349,24 +467,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.AnswerCallbackQuery{
CallbackQueryID: ctx.CallbackQueryId,
Text: text, ShowAlert: showAlert, URL: url,
})
if err != nil {
ctx.botLogger.Errorln(err)
ctx.Logger.Errorln(err)
}
}
@@ -385,26 +502,30 @@ 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{
params := tgapi.SendChatAction{
ChatID: ctx.Msg.Chat.ID, Action: action,
}
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) {
if err == nil {
return
}
ctx.Logger.Errorln(err)
if IsInternalError(err) {
return
}
text := fmt.Sprintf(ctx.errorTemplate, err.Error())
if ctx.CallbackQueryId != "" {
@@ -412,7 +533,6 @@ func (ctx *MsgContext) error(err error) {
} else {
ctx.answer(text, nil, tgapi.ParseNone)
}
ctx.botLogger.Errorln(err)
}
// Error is an alias for error().
@@ -420,15 +540,25 @@ 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)
defer cancel()
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
ctx.botLogger.Errorln(err)
return nil
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.Logger.Errorln(err)
return nil
}
}
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
@@ -463,3 +593,200 @@ 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
}
func (ctx *MsgContext) emitPolicyChecked(event PolicyCheckedEvent) {
if ctx == nil || ctx.observer == nil {
return
}
defer func() {
if r := recover(); r != nil {
if ctx.Logger != nil {
ctx.Logger.Errorln(fmt.Sprintf("panic in observer policy event: %v", r))
return
}
log.Printf("panic in observer policy event: %v", r)
}
}()
ctx.observer.OnPolicyChecked(ctx.Context(), event)
}
// 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)
}
+408 -4
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) {
@@ -45,10 +47,10 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
ctx := &MsgContext{
Api: api,
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: string(tgapi.ChatTypePrivate)},
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
},
botLogger: slog.CreateLogger(),
Logger: slog.CreateLogger(),
}
answer := ctx.AnswerPhoto("photo-id", "caption")
@@ -62,3 +64,405 @@ 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 TestErrorDefaultRemainsUserVisibleForMessageFlow(t *testing.T) {
var requests int
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
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: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(),
errorTemplate: "Error: %s",
}
ctx.error(errors.New("boom"))
if requests != 1 {
t.Fatalf("expected one user-facing error reply, got %d requests", requests)
}
if got := gotBody["text"]; got != "Error: boom" {
t.Fatalf("unexpected error reply text: %v", got)
}
}
func TestErrorInternalSkipsUserReplyForMessageFlow(t *testing.T) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected HTTP request for internal-only error")
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: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(),
errorTemplate: "Error: %s",
}
ctx.error(AsInternalError(errors.New("boom")))
}
func TestErrorInternalSkipsCallbackAnswer(t *testing.T) {
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
t.Fatal("unexpected callback answer request for internal-only error")
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,
Logger: slog.CreateLogger(),
errorTemplate: "%s",
CallbackQueryId: "cb-1",
}
ctx.error(AsInternalError(errors.New("boom")))
}
func TestErrorUserVisibleAnswersCallback(t *testing.T) {
var requests int
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
}, 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,
Logger: slog.CreateLogger(),
errorTemplate: "Oops: %s",
CallbackQueryId: "cb-1",
}
ctx.error(AsUserError(errors.New("boom")))
if requests != 1 {
t.Fatalf("expected one callback error answer, got %d requests", requests)
}
if got := gotBody["text"]; got != "Oops: boom" {
t.Fatalf("unexpected callback error text: %v", got)
}
}
func TestAnswerRejectsEmptyMessage(t *testing.T) {
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 42, Type: 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: 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: 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)
}
}
+219
View File
@@ -0,0 +1,219 @@
package laniakea
import (
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
var msg *tgapi.Message
if update.Message != nil {
msg = update.Message
} else if update.ChannelPost != nil {
msg = update.ChannelPost
} else {
return false
}
var text string
if len(msg.Text) > 0 {
text = msg.Text
} else if len(msg.Caption) > 0 {
text = msg.Caption
} else {
return false
}
prefix, cmd, args := bot.parseCommand(text)
if cmd == "" {
return false
}
ctx.Prefix = prefix
if strings.Contains(cmd, "@") {
botUsername := bot.username
if botUsername != "" && strings.HasSuffix(cmd, "@"+botUsername) {
cmd = cmd[:len(cmd)-len("@"+botUsername)] // убираем @botname
}
}
// Ищем команду по точному совпадению
for _, plugin := range bot.plugins {
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.appData) {
return false
}
startTime := time.Now()
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerCommandKind,
HandlerName: cmd,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
err := plugin.executeCmd(cmd, ctx, bot.appData)
handlerEndEvent := HandlerFinishedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerCommandKind,
HandlerName: cmd,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Duration: time.Since(startTime),
}
var errorEvent *ErrorEvent = nil
if err != nil {
ctx.error(err)
handlerEndEvent.Err = err
handlerEndEvent.UserFacing = IsUserError(err)
errorEvent = &ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerCommandKind,
HandlerName: cmd,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: handlerEndEvent.UserFacing,
}
}
bot.safeEmitEvent(ctx.Context(), handlerEndEvent)
if errorEvent != nil {
bot.safeEmitEvent(ctx.Context(), *errorEvent)
}
return true
}
}
return false
}
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) bool {
data, err := bot.decodePayload(update.CallbackQuery.Data)
if err != nil {
bot.logger.Errorln(err)
bot.safeEmitEvent(ctx.Context(), ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: "bot",
HandlerKind: HandlerPayloadKind,
HandlerName: "decodePayload",
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: false,
})
return false
}
ctx.Args = data.Args
for _, plugin := range bot.plugins {
_, ok := plugin.payloads[data.Command]
if !ok {
continue
}
ctx.Logger = plugin.logger
if ctx.Logger == nil {
ctx.Logger = bot.logger
}
if !plugin.executeMiddlewares(ctx, bot.appData) {
return false
}
startTime := time.Now()
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerPayloadKind,
HandlerName: data.Command,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
err := plugin.executePayload(data.Command, ctx, bot.appData)
endEvent := HandlerFinishedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerPayloadKind,
HandlerName: data.Command,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Duration: time.Since(startTime),
}
var errorEvent *ErrorEvent = nil
if err != nil {
ctx.error(err)
errorEvent = &ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerPayloadKind,
HandlerName: data.Command,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: IsUserError(err),
}
endEvent.Err = err
endEvent.UserFacing = errorEvent.UserFacing
}
bot.safeEmitEvent(ctx.Context(), endEvent)
if errorEvent != nil {
bot.safeEmitEvent(ctx.Context(), *errorEvent)
}
return true
}
return false
}
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) {
return prefix, true
}
}
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 "", "", ""
}
+184
View File
@@ -0,0 +1,184 @@
package laniakea
import (
"context"
"fmt"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// HandlerEventKind identifies the kind of handler observed by runtime events.
type HandlerEventKind string
const (
// HandlerCommandKind identifies a command handler.
HandlerCommandKind HandlerEventKind = "command"
// HandlerPayloadKind identifies a callback payload handler.
HandlerPayloadKind HandlerEventKind = "payload"
// HandlerUpdateKind identifies a generic update handler.
HandlerUpdateKind HandlerEventKind = "update"
// HandlerRunnerKind identifies a background runner execution.
HandlerRunnerKind HandlerEventKind = "runner"
// HandlerPollingKind identifies polling and getUpdates runtime work.
HandlerPollingKind HandlerEventKind = "polling"
// HandlerSceneKind identifies a scene runtime handler wrapper.
HandlerSceneKind HandlerEventKind = "scene"
// HandlerSceneStepKind identifies a scene step handler.
HandlerSceneStepKind HandlerEventKind = "scene_step"
// HandlerSceneCommandKind identifies a scene-local command handler.
HandlerSceneCommandKind HandlerEventKind = "scene_command"
// HandlerSceneMessageKind identifies a scene message fallback handler.
HandlerSceneMessageKind HandlerEventKind = "scene_message"
)
// Event is the marker interface implemented by all observer runtime events.
type Event interface {
isEvent()
}
// UpdateReceivedEvent describes an update entering the bot runtime.
type UpdateReceivedEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
FromID int64
ChatID int64
}
// UpdateHandledEvent describes a completed update execution path.
type UpdateHandledEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
FromID int64
ChatID int64
Duration time.Duration
Handled bool
}
// HandlerStartedEvent describes a handler about to execute.
type HandlerStartedEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
Plugin string
HandlerKind HandlerEventKind
HandlerName string
FromID int64
ChatID int64
}
// HandlerFinishedEvent describes a handler that has completed.
type HandlerFinishedEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
Plugin string
HandlerKind HandlerEventKind
HandlerName string
FromID int64
ChatID int64
Duration time.Duration
Err error
UserFacing bool
}
// SceneTransitionEvent describes a scene state transition.
type SceneTransitionEvent struct {
Plugin string
Scene string
From string
To string
Action SceneAction
FromID int64
ChatID int64
}
// PolicyCheckedEvent describes the result of a policy evaluation.
type PolicyCheckedEvent struct {
Name string
Plugin string
FromID int64
ChatID int64
Passed bool
Err error
Internal bool
}
// RunnerFinishedEvent describes a completed background runner execution.
type RunnerFinishedEvent struct {
Name string
Duration time.Duration
Err error
}
// PollingRetryEvent describes a polling retry after a failed getUpdates call.
type PollingRetryEvent struct {
Attempt int
Delay time.Duration
Err error
}
// ErrorEvent describes an error routed through framework error handling.
type ErrorEvent struct {
UpdateID int
UpdateType tgapi.UpdateType
Plugin string
HandlerKind HandlerEventKind
HandlerName string
FromID int64
ChatID int64
Err error
UserFacing bool
}
func (UpdateReceivedEvent) isEvent() {}
func (UpdateHandledEvent) isEvent() {}
func (HandlerStartedEvent) isEvent() {}
func (HandlerFinishedEvent) isEvent() {}
func (SceneTransitionEvent) isEvent() {}
func (PolicyCheckedEvent) isEvent() {}
func (RunnerFinishedEvent) isEvent() {}
func (PollingRetryEvent) isEvent() {}
func (ErrorEvent) isEvent() {}
// Observer receives best-effort runtime instrumentation events.
type Observer interface {
OnReceiveUpdate(ctx context.Context, event UpdateReceivedEvent)
OnHandledUpdate(ctx context.Context, event UpdateHandledEvent)
OnHandlerStarted(ctx context.Context, event HandlerStartedEvent)
OnHandlerFinished(ctx context.Context, event HandlerFinishedEvent)
OnSceneTransition(ctx context.Context, event SceneTransitionEvent)
OnPolicyChecked(ctx context.Context, event PolicyCheckedEvent)
OnRunnerFinished(ctx context.Context, event RunnerFinishedEvent)
OnPollingRetry(ctx context.Context, event PollingRetryEvent)
OnError(ctx context.Context, event ErrorEvent)
}
func (bot *Bot[T]) safeEmitEvent(ctx context.Context, event Event) {
if bot.observer == nil {
return
}
defer func() {
if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in observer: %v", r))
}
}()
switch e := event.(type) {
case UpdateReceivedEvent:
bot.observer.OnReceiveUpdate(ctx, e)
case UpdateHandledEvent:
bot.observer.OnHandledUpdate(ctx, e)
case HandlerStartedEvent:
bot.observer.OnHandlerStarted(ctx, e)
case HandlerFinishedEvent:
bot.observer.OnHandlerFinished(ctx, e)
case SceneTransitionEvent:
bot.observer.OnSceneTransition(ctx, e)
case PolicyCheckedEvent:
bot.observer.OnPolicyChecked(ctx, e)
case RunnerFinishedEvent:
bot.observer.OnRunnerFinished(ctx, e)
case PollingRetryEvent:
bot.observer.OnPollingRetry(ctx, e)
case ErrorEvent:
bot.observer.OnError(ctx, e)
}
}
+109 -52
View File
@@ -4,11 +4,13 @@ import (
"errors"
"regexp"
"git.nix13.pw/scuroneko/extypes"
"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"
)
// CommandValueType defines the expected type of a command argument.
// CommandValueType defines the expected type of command argument.
type CommandValueType string
const (
@@ -16,7 +18,7 @@ const (
CommandValueStringType CommandValueType = "string"
// CommandValueIntType expects a decimal integer (digits only).
CommandValueIntType CommandValueType = "int"
// CommandValueBoolType is reserved for future use (not implemented).
// CommandValueBoolType expects a exact "true" or "false".
CommandValueBoolType CommandValueType = "bool"
// CommandValueAnyType accepts any input without validation.
CommandValueAnyType CommandValueType = "any"
@@ -38,6 +40,11 @@ var ErrCmdArgCountMismatch = errors.New("command arg count mismatch")
// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation.
var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch")
var (
errCommandNotFound = errors.New("command not found")
errPayloadNotFound = errors.New("payload not found")
)
// CommandArg defines a single argument for a command, including type, regex,
// and whether it is required.
type CommandArg struct {
@@ -50,12 +57,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:
@@ -72,18 +79,19 @@ 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)
// It receives the message context and injected application data.
// Returning a non-nil error routes it through the bot's error handler.
type CommandExecutor[T AppData] 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.
type Command[T DbContext] struct {
type Command[T AppData] struct {
command string // The command trigger (e.g., "/start")
description string // Human-readable description for help
exec CommandExecutor[T] // Function to execute when command is triggered
@@ -123,14 +131,12 @@ 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 {
return ErrCmdArgCountMismatch
for i := range c.args.Len() {
if i >= len(args) && c.args.Get(i).required {
return ErrCmdArgCountMismatch
}
}
// Validate each argument against its regex
@@ -156,32 +162,43 @@ func (c *Command[T]) validateArgs(args []string) error {
// A Plugin is intended to be fully configured before it is passed to Bot.AddPlugins.
// After registration, treat the plugin as committed and do not mutate it further.
// Post-registration changes through the original *Plugin are not a supported API.
type Plugin[T DbContext] struct {
type Plugin[T AppData] 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] {
func NewPlugin[T AppData](name string) *Plugin[T] {
return &Plugin[T]{
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]),
}
}
// AddCommand registers a command in the plugin.
// The command's .command field is used as the key.
func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] {
if command == nil {
if p.logger != nil {
p.logger.Warnln("trying to add nil command")
}
return p
}
p.commands[command.command] = command
return p
}
@@ -197,6 +214,12 @@ func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...
// AddPayload registers a payload (e.g., callback query data) in the plugin.
// Payloads are triggered by inline button callback_data, not by message text.
func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] {
if command == nil {
if p.logger != nil {
p.logger.Warnln("trying to add nil command")
}
return p
}
p.payloads[command.command] = command
return p
}
@@ -209,6 +232,49 @@ 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
}
// UsePolicy registers a Policy as plugin middleware for all plugin handlers.
func (p *Plugin[T]) UsePolicy(name string, policy Policy[T]) *Plugin[T] {
mw := RequirePolicy(name, policy)
return p.AddMiddleware(mw)
}
// 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] {
@@ -267,61 +333,52 @@ func (p *Plugin[T]) Close() error {
return errors.Join(e...)
}
// 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) {
// Internal helper that validates and executes a command handler.
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error {
command, exists := p.commands[cmd]
if !exists {
ctx.error(errors.New("command not found"))
return
return AsInternalError(errCommandNotFound)
}
if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err)
return
return AsUserError(err)
}
// Run command-specific middlewares
for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) {
return
if !m.Execute(ctx, db) {
return AsInternalError(errors.New("middleware blocked call"))
}
}
// Execute command
command.exec(ctx, dbContext)
return command.exec(ctx, db)
}
// 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) error {
command, exists := p.payloads[payload]
if !exists {
ctx.error(errors.New("payload not found"))
return
return AsInternalError(errPayloadNotFound)
}
if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err)
return
return AsUserError(err)
}
// Run command-specific middlewares
for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) {
return
if !m.Execute(ctx, db) {
return AsInternalError(errors.New("middleware blocked call"))
}
}
// Execute payload
command.exec(ctx, dbContext)
return command.exec(ctx, db)
}
// 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
@@ -333,11 +390,11 @@ 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 AppData] func(ctx *MsgContext, db T) bool
// Middleware represents a reusable execution interceptor.
// Can be synchronous (blocking) or asynchronous (non-blocking).
type Middleware[T DbContext] struct {
type Middleware[T AppData] struct {
name string // Human-readable name for logging/debugging
executor MiddlewareExecutor[T] // Function to execute
order int // Optional sort order (not used yet)
@@ -345,19 +402,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 AppData](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
}
@@ -365,7 +422,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[NoData](func(ctx *MsgContext, db NoData) 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[NoData](func(ctx *MsgContext, db NoData) 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[NoData](
func(ctx *MsgContext, db NoData) 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)
}
}
+224
View File
@@ -0,0 +1,224 @@
package laniakea
import (
"errors"
"fmt"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// Policy defines a reusable authorization rule for the current update context.
type Policy[T AppData] func(ctx *MsgContext, data T) error
// RequirePolicy adapts a Policy into a blocking middleware.
func RequirePolicy[T AppData](name string, p Policy[T]) Middleware[T] {
return NewMiddleware(name, func(ctx *MsgContext, data T) bool {
if err := p(ctx, data); err != nil {
ctx.emitPolicyChecked(PolicyCheckedEvent{
Name: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Passed: false,
Err: err,
Internal: IsInternalError(err),
})
ctx.error(err)
return false
}
ctx.emitPolicyChecked(PolicyCheckedEvent{
Name: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Passed: true,
})
return true
})
}
// AllPolicies composes policies that all must succeed.
func AllPolicies[T AppData](policies ...Policy[T]) Policy[T] {
return func(ctx *MsgContext, data T) error {
for _, p := range policies {
if err := p(ctx, data); err != nil {
return err
}
}
return nil
}
}
// AnyPolicy composes policies where at least one must succeed.
func AnyPolicy[T AppData](policies ...Policy[T]) Policy[T] {
return func(ctx *MsgContext, data T) error {
var firstDeny error
var internalErr error
for _, p := range policies {
err := p(ctx, data)
if err == nil {
return nil
}
if IsInternalError(err) {
if internalErr == nil {
internalErr = err
}
continue
}
if firstDeny == nil {
firstDeny = err
}
}
if internalErr != nil {
return internalErr
}
if firstDeny != nil {
return firstDeny
}
return AsUserError(errors.New("no policy matched"))
}
}
// NotPolicy inverts a policy deny result while preserving internal failures.
func NotPolicy[T AppData](policy Policy[T]) Policy[T] {
return func(ctx *MsgContext, data T) error {
var err error
if err = policy(ctx, data); err == nil {
return AsUserError(errors.New("the action is not allowed due to policy violation"))
}
if IsInternalError(err) {
return err
}
return nil
}
}
// RequirePrivateChat allows execution only in private chats.
func RequirePrivateChat[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return AsInternalError(errors.New("private-chat policy requires message chat context"))
}
if ctx.Msg.Chat.Type != tgapi.ChatTypePrivate {
return AsUserError(errors.New("this action is only available in private chat"))
}
return nil
}
}
// RequireGroupChat allows execution only in group or supergroup chats.
func RequireGroupChat[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return AsInternalError(errors.New("group-chat policy requires message chat context"))
}
if ctx.Msg.Chat.Type != tgapi.ChatTypeGroup && ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup {
return AsUserError(errors.New("this action is only available in group chats"))
}
return nil
}
}
// RequireSupergroupChat allows execution only in supergroup chats.
func RequireSupergroupChat[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Msg == nil || ctx.Msg.Chat == nil {
return AsInternalError(errors.New("supergroup-chat policy requires message chat context"))
}
if ctx.Msg.Chat.Type != tgapi.ChatTypeSupergroup {
return AsUserError(errors.New("this action is only available in supergroup chats"))
}
return nil
}
}
// RequireChatAdmin allows execution only for chat administrators or owners.
func RequireChatAdmin[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.FromID == 0 || ctx.ChatID == 0 {
return AsInternalError(errors.New("chat-admin policy requires message chat context"))
}
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{
ChatID: ctx.ChatID,
UserID: ctx.FromID,
})
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch chat member status: %w", err))
}
if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner {
return AsUserError(errors.New("this action is only available to chat admins"))
}
return nil
}
}
// RequireChatCreator allows execution only for the chat owner.
func RequireChatCreator[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.FromID == 0 || ctx.ChatID == 0 {
return AsInternalError(errors.New("chat-creator policy requires message chat context"))
}
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{
ChatID: ctx.ChatID,
UserID: ctx.FromID,
})
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch chat creator: %w", err))
}
if member.Status != tgapi.ChatMemberStatusOwner {
return AsUserError(errors.New("this action is only available to the chat creator"))
}
return nil
}
}
// RequireBotAdmin allows execution only when the bot is an admin in the chat.
func RequireBotAdmin[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.ChatID == 0 {
return AsInternalError(errors.New("bot-admin policy requires message chat context"))
}
bot, err := ctx.Api.GetMe()
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch bot info: %w", err))
}
member, err := ctx.Api.GetChatMember(tgapi.GetChatMember{
ChatID: ctx.ChatID,
UserID: bot.ID,
})
if err != nil {
return AsInternalError(fmt.Errorf("failed to fetch bot member status: %w", err))
}
if member.Status != tgapi.ChatMemberStatusAdministrator && member.Status != tgapi.ChatMemberStatusOwner {
return AsUserError(errors.New("this action requires the bot to be an admin in the chat"))
}
return nil
}
}
// RequireCallbackFromUser allows execution only for callback queries sent by non-bot users.
func RequireCallbackFromUser[T AppData]() Policy[T] {
return func(ctx *MsgContext, data T) error {
if ctx.Update.CallbackQuery == nil {
return AsInternalError(errors.New("callback-user policy requires callback query context"))
}
if ctx.Update.CallbackQuery.From.IsBot {
return AsUserError(errors.New("this action is only available to human users"))
}
return nil
}
}
+281
View File
@@ -0,0 +1,281 @@
package laniakea
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/slog"
)
func TestRequirePolicyStopsExecutionOnDeniedPolicy(t *testing.T) {
var requests int
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
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: tgapi.ChatTypePrivate}},
Logger: slog.CreateLogger(),
errorTemplate: "Error: %s",
}
mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error {
return AsUserError(errors.New("blocked"))
})
if mw.Execute(ctx, NoData{}) {
t.Fatal("expected denied policy middleware to stop execution")
}
if requests != 1 {
t.Fatalf("expected one user-facing error reply, got %d requests", requests)
}
if got := gotBody["text"]; got != "Error: blocked" {
t.Fatalf("unexpected policy error reply text: %v", got)
}
}
func TestRequirePrivateChatAllowsPrivateChat(t *testing.T) {
ctx := &MsgContext{
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: 42, Type: tgapi.ChatTypePrivate},
},
Logger: slog.CreateLogger(),
}
if err := RequirePrivateChat[NoData]()(ctx, NoData{}); err != nil {
t.Fatalf("RequirePrivateChat returned error: %v", err)
}
}
func TestRequirePrivateChatDeniesNonPrivateChat(t *testing.T) {
ctx := &MsgContext{
Msg: &tgapi.Message{
Chat: &tgapi.Chat{ID: -100, Type: tgapi.ChatTypeSupergroup},
},
Logger: slog.CreateLogger(),
}
err := RequirePrivateChat[NoData]()(ctx, NoData{})
if err == nil {
t.Fatal("expected RequirePrivateChat to deny non-private chats")
}
if !IsUserError(err) {
t.Fatalf("expected user-visible deny error, got %v", err)
}
}
func TestRequireChatAdminUsesNormalizedIDs(t *testing.T) {
var sawGetChatMember bool
var gotBody map[string]any
client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "getChatMember") {
t.Fatalf("unexpected API method: %s", req.URL.Path)
}
sawGetChatMember = true
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("failed to read request body: %v", err)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"ok":true,"result":{"status":"administrator","user":{"id":55,"is_bot":false,"first_name":"tester"}}}`,
)),
}, 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,
ChatID: -2001,
FromID: 55,
Logger: slog.CreateLogger(),
}
if err := RequireChatAdmin[NoData]()(ctx, NoData{}); err != nil {
t.Fatalf("RequireChatAdmin returned error: %v", err)
}
if !sawGetChatMember {
t.Fatal("expected GetChatMember to be called")
}
if got := gotBody["chat_id"]; got != float64(-2001) {
t.Fatalf("unexpected chat_id in request: %v", got)
}
if got := gotBody["user_id"]; got != float64(55) {
t.Fatalf("unexpected user_id in request: %v", got)
}
}
func TestAllPoliciesReturnsFirstError(t *testing.T) {
want := AsUserError(errors.New("blocked"))
policy := AllPolicies[NoData](
func(ctx *MsgContext, data NoData) error { return nil },
func(ctx *MsgContext, data NoData) error { return want },
func(ctx *MsgContext, data NoData) error {
t.Fatal("unexpected evaluation after first failure")
return nil
},
)
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, want) {
t.Fatalf("expected first policy error, got %v", err)
}
}
func TestAnyPolicyAllowsLaterSuccessAfterInternalError(t *testing.T) {
policy := AnyPolicy[NoData](
func(ctx *MsgContext, data NoData) error { return AsInternalError(errors.New("temporary")) },
func(ctx *MsgContext, data NoData) error { return nil },
)
if err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
t.Fatalf("expected later success to allow access, got %v", err)
}
}
func TestAnyPolicyReturnsInternalErrorWhenNonePass(t *testing.T) {
internal := AsInternalError(errors.New("temporary"))
policy := AnyPolicy[NoData](
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("denied")) },
func(ctx *MsgContext, data NoData) error { return internal },
)
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, internal) {
t.Fatalf("expected internal error, got %v", err)
}
}
func TestAnyPolicyReturnsFirstDenyWhenNoPolicyPasses(t *testing.T) {
first := AsUserError(errors.New("first deny"))
policy := AnyPolicy[NoData](
func(ctx *MsgContext, data NoData) error { return first },
func(ctx *MsgContext, data NoData) error { return AsUserError(errors.New("second deny")) },
)
err := policy(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, first) {
t.Fatalf("expected first deny error, got %v", err)
}
}
func TestNotPolicyInvertsUserDenyButPreservesInternalErrors(t *testing.T) {
inverted := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error {
return AsUserError(errors.New("denied"))
})
if err := inverted(&MsgContext{Logger: slog.CreateLogger()}, NoData{}); err != nil {
t.Fatalf("expected inverted deny to succeed, got %v", err)
}
internal := AsInternalError(errors.New("temporary"))
preserve := NotPolicy[NoData](func(ctx *MsgContext, data NoData) error {
return internal
})
err := preserve(&MsgContext{Logger: slog.CreateLogger()}, NoData{})
if !errors.Is(err, internal) {
t.Fatalf("expected internal error to be preserved, got %v", err)
}
}
func TestRequirePolicyEmitsObserverEvents(t *testing.T) {
t.Run("allow", func(t *testing.T) {
observer := &recordingObserver{}
ctx := &MsgContext{
Logger: slog.CreateLogger(),
ctx: context.Background(),
observer: observer,
FromID: 10,
ChatID: 20,
}
mw := RequirePolicy[NoData]("allow", func(ctx *MsgContext, data NoData) error {
return nil
})
if !mw.Execute(ctx, NoData{}) {
t.Fatal("expected allowed policy middleware to continue execution")
}
if len(observer.policies) != 1 {
t.Fatalf("expected one policy event, got %d", len(observer.policies))
}
if got := observer.policies[0]; got.Name != "allow" || !got.Passed || got.Err != nil || got.Internal {
t.Fatalf("unexpected policy event: %#v", got)
}
})
t.Run("deny", func(t *testing.T) {
observer := &recordingObserver{}
ctx := &MsgContext{
Logger: slog.CreateLogger(),
ctx: context.Background(),
observer: observer,
errorTemplate: "%s",
}
mw := RequirePolicy[NoData]("deny", func(ctx *MsgContext, data NoData) error {
return AsInternalError(errors.New("blocked"))
})
if mw.Execute(ctx, NoData{}) {
t.Fatal("expected denied policy middleware to stop execution")
}
if len(observer.policies) != 1 {
t.Fatalf("expected one policy event, got %d", len(observer.policies))
}
if got := observer.policies[0]; got.Name != "deny" || got.Passed || got.Err == nil || !got.Internal {
t.Fatalf("unexpected policy event: %#v", got)
}
})
}
+48 -9
View File
@@ -7,7 +7,7 @@ import (
// RunnerFn is the function type for a runner. It receives a pointer to
// the Bot and returns an error if execution fails.
type RunnerFn[T DbContext] func(*Bot[T]) error
type RunnerFn[T AppData] func(*Bot[T]) error
// Runner represents a configurable background or one-time task to be
// executed by a Bot.
@@ -20,7 +20,7 @@ type RunnerFn[T DbContext] func(*Bot[T]) error
// - onetime=true, async=true: Run once in a goroutine (non-blocking).
// - onetime=false, async=true: Run repeatedly in a goroutine with timeout.
// - onetime=false, async=false: Invalid configuration — ignored with warning.
type Runner[T DbContext] struct {
type Runner[T AppData] struct {
name string // Human-readable name for logging
onetime bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously
@@ -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 AppData](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
}
@@ -88,7 +88,8 @@ func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] {
//
// Background runners listen for ctx.Done() and gracefully shut down when the context is canceled.
//
// This method is typically called once during bot startup in RunWithContext.
// This method is typically called once during bot startup from RunWithContext or
// RunWebHookWithContext.
func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Infoln("Executing runners...")
for _, runner := range bot.runners {
@@ -107,8 +108,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.runnerOnceWG.Add(1)
go func(r Runner[T]) {
defer bot.runnerOnceWG.Done()
startedAt := time.Now()
err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
if err != nil {
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerRunnerKind,
HandlerName: r.name,
Err: err,
UserFacing: false,
})
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
}
}(runner)
@@ -116,10 +130,22 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
// One-time sync: block until done
t := time.Now()
err := runner.fn(bot)
elapsed := time.Since(t)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: runner.name,
Duration: elapsed,
Err: err,
})
if err != nil {
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerRunnerKind,
HandlerName: runner.name,
Err: err,
UserFacing: false,
})
bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err)
}
elapsed := time.Since(t)
if elapsed > time.Second*2 {
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed)
}
@@ -135,8 +161,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
case <-ctx.Done():
return
case <-ticker.C:
startedAt := time.Now()
err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
if err != nil {
bot.safeEmitEvent(ctx, ErrorEvent{
Plugin: "bot",
HandlerKind: HandlerRunnerKind,
HandlerName: r.name,
Err: err,
UserFacing: false,
})
bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
}
}
+97
View File
@@ -0,0 +1,97 @@
package laniakea
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"git.scuroneko.dev/scuroneko/slog"
)
type runnerObserver struct {
recordingObserver
}
func TestExecRunnersRunsOnetimeSyncRunner(t *testing.T) {
var calls atomic.Int32
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) 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[NoData]{
logger: slog.CreateLogger(),
runners: []Runner[NoData]{
NewRunner("background", func(*Bot[NoData]) 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")
}
}
func TestExecRunnersEmitObserverEvents(t *testing.T) {
observer := &runnerObserver{}
wantErr := errors.New("runner failed")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
observer: observer,
runners: []Runner[NoData]{
NewRunner("sync-once", func(*Bot[NoData]) error {
return wantErr
}).Onetime(true).Async(false),
},
}
bot.ExecRunners(context.Background())
if len(observer.runners) != 1 {
t.Fatalf("expected one runner-finished event, got %d", len(observer.runners))
}
if got := observer.runners[0]; got.Name != "sync-once" || !errors.Is(got.Err, wantErr) {
t.Fatalf("unexpected runner-finished event: %#v", got)
}
if len(observer.errors) != 1 {
t.Fatalf("expected one error event, got %d", len(observer.errors))
}
if got := observer.errors[0]; got.HandlerKind != HandlerRunnerKind || got.HandlerName != "sync-once" || !errors.Is(got.Err, wantErr) {
t.Fatalf("unexpected runner error event: %#v", got)
}
}
+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)
}
+255
View File
@@ -0,0 +1,255 @@
package laniakea
import (
"errors"
"fmt"
"strings"
"time"
)
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.appData) {
return false, nil
}
sceneCtx := &SceneContext{
MsgContext: ctx,
sess: session,
key: key,
}
return bot.executeScene(sceneCtx, scene)
}
return false, ErrSceneNotFound
}
func (bot *Bot[T]) executeScene(ctx *SceneContext, scene *Scene[T]) (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)
if _, ok := scene.commands[cmd]; ok {
startTime := time.Now()
bot.emitSceneStarted(ctx, scene, HandlerSceneCommandKind, cmd)
res, _, err := scene.executeCommand(cmd, ctx, bot.appData)
if err != nil {
bot.emitSceneFinished(ctx, scene, HandlerSceneCommandKind, cmd, startTime, err)
bot.emitSceneError(ctx, scene, HandlerSceneCommandKind, cmd, err)
return false, err
}
from := ctx.sess.Step
ok, err := bot.applySceneResult(scene, ctx, res)
bot.emitSceneFinished(ctx, scene, HandlerSceneCommandKind, cmd, startTime, err)
if err != nil {
bot.emitSceneError(ctx, scene, HandlerSceneCommandKind, cmd, err)
}
if ok {
bot.emitSceneTransition(ctx, scene, from, res)
}
return ok, err
}
}
ctx.Text = text
ctx.Args = nil
ctx.Prefix = ""
if ctx.sess.Step != "" {
step := ctx.sess.Step
if _, ok := scene.steps[step]; ok {
startTime := time.Now()
bot.emitSceneStarted(ctx, scene, HandlerSceneStepKind, step)
res, _, err := scene.executeStep(step, ctx, bot.appData)
if err != nil {
bot.emitSceneFinished(ctx, scene, HandlerSceneStepKind, step, startTime, err)
bot.emitSceneError(ctx, scene, HandlerSceneStepKind, step, err)
return false, err
}
from := step
ok, err := bot.applySceneResult(scene, ctx, res)
bot.emitSceneFinished(ctx, scene, HandlerSceneStepKind, step, startTime, err)
if err != nil {
bot.emitSceneError(ctx, scene, HandlerSceneStepKind, from, err)
}
if ok {
bot.emitSceneTransition(ctx, scene, from, res)
}
return ok, err
}
}
if scene.message != nil {
startTime := time.Now()
bot.emitSceneStarted(ctx, scene, HandlerSceneMessageKind, "message_fallback")
res, _, err := scene.executeMessage(ctx, bot.appData)
if err != nil {
bot.emitSceneFinished(ctx, scene, HandlerSceneMessageKind, "message_fallback", startTime, err)
bot.emitSceneError(ctx, scene, HandlerSceneMessageKind, "message_fallback", err)
return false, err
}
from := ctx.sess.Step
ok, err := bot.applySceneResult(scene, ctx, res)
bot.emitSceneFinished(ctx, scene, HandlerSceneMessageKind, "message_fallback", startTime, err)
if err != nil {
bot.emitSceneError(ctx, scene, HandlerSceneMessageKind, "message_fallback", err)
}
if ok {
bot.emitSceneTransition(ctx, scene, from, res)
}
return ok, err
}
return false, nil
}
func (bot *Bot[T]) emitSceneStarted(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string) {
bot.safeEmitEvent(ctx.Context(), HandlerStartedEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
}
func (bot *Bot[T]) emitSceneFinished(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string, startedAt time.Time, err error) {
bot.safeEmitEvent(ctx.Context(), HandlerFinishedEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Duration: time.Since(startedAt),
Err: err,
UserFacing: IsUserError(err),
})
}
func (bot *Bot[T]) emitSceneError(ctx *SceneContext, scene *Scene[T], kind HandlerEventKind, name string, err error) {
bot.safeEmitEvent(ctx.Context(), ErrorEvent{
UpdateID: ctx.Update.UpdateID,
UpdateType: ctx.Update.Type,
Plugin: scene.PluginName,
HandlerKind: kind,
HandlerName: name,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
Err: err,
UserFacing: IsUserError(err),
})
}
func (bot *Bot[T]) emitSceneTransition(ctx *SceneContext, scene *Scene[T], from string, result SceneResult) {
if result.Action == SceneActionPass {
return
}
to := from
switch result.Action {
case SceneActionNext:
to = result.Next
case SceneActionExit:
to = ""
}
bot.safeEmitEvent(ctx.Context(), SceneTransitionEvent{
Plugin: scene.PluginName,
Scene: scene.Name,
From: from,
To: to,
Action: result.Action,
FromID: ctx.FromID,
ChatID: ctx.ChatID,
})
}
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
}
}
+632
View File
@@ -0,0 +1,632 @@
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[NoData]("wizard")
scene := NewScene[NoData]("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[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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: 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: 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[NoData]("wizard")
plugin.NewScene("signup")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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[NoData]("wizard")
plugin.NewScene("signup").SetEntry("start")
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
}
bot.AddPlugins(plugin)
ctx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
stepCalled = true
return ctx.Stay(), nil
}).
OnCommand("cancel", func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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 TestSceneCommandObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnCommand("cancel", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer,
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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: 22,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 9,
Text: "/cancel",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one scene started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerSceneCommandKind || got.HandlerName != "cancel" || got.Plugin != "wizard" {
t.Fatalf("unexpected scene started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerSceneCommandKind || got.HandlerName != "cancel" || got.Plugin != "wizard" || got.Err != nil {
t.Fatalf("unexpected scene finished event: %#v", got)
}
}
func TestSceneStepObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer,
}
bot.AddPlugins(plugin)
enterCtx := &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: 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: 23,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 10,
Text: "hello there",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one scene started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerSceneStepKind || got.HandlerName != "start" || got.Plugin != "wizard" {
t.Fatalf("unexpected scene step started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerSceneStepKind || got.HandlerName != "start" || got.Plugin != "wizard" || got.Err != nil {
t.Fatalf("unexpected scene step finished event: %#v", got)
}
}
func TestSceneMessageObserverEmitsLifecycleEvents(t *testing.T) {
observer := &recordingObserver{}
plugin := NewPlugin[NoData]("wizard")
scene := plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnMessage(func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Exit(), nil
})
bot := &Bot[NoData]{
logger: slog.CreateLogger(),
prefixes: []string{"/"},
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
observer: observer,
}
bot.AddPlugins(plugin)
key, ok := buildSceneKey(SceneScopeUserChat, &MsgContext{
Msg: &tgapi.Message{Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate}},
FromID: 42,
})
if !ok {
t.Fatal("expected scene key to be built")
}
if err := bot.sessionStore.Set(key, SceneSession{Scene: scene.Name}); err != nil {
t.Fatalf("failed to seed scene session: %v", err)
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 24,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 11,
Text: "hello there",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if len(observer.started) != 1 {
t.Fatalf("expected one scene started event, got %d", len(observer.started))
}
if got := observer.started[0]; got.HandlerKind != HandlerSceneMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "wizard" {
t.Fatalf("unexpected scene message started event: %#v", got)
}
if len(observer.finished) != 1 {
t.Fatalf("expected one scene finished event, got %d", len(observer.finished))
}
if got := observer.finished[0]; got.HandlerKind != HandlerSceneMessageKind || got.HandlerName != "message_fallback" || got.Plugin != "wizard" || got.Err != nil {
t.Fatalf("unexpected scene message finished event: %#v", got)
}
}
func TestScenePassDoesNotPersistSessionData(t *testing.T) {
commandCalled := false
plugin := NewPlugin[NoData]("wizard")
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "ping")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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: 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[NoData]("wizard")
plugin.NewScene("signup").
SetEntry("start").
OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
}).
OnMessage(func(ctx *SceneContext, db NoData) (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[NoData]{
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: 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: 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: 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[NoData]{
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[NoData]{
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[NoData]("signup").OnStep("start", func(ctx *SceneContext, db NoData) (SceneResult, error) {
return ctx.Stay(), nil
})
bot := &Bot[NoData]{
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
}
+19 -21
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.
@@ -98,6 +98,11 @@ type API struct {
// Always call Close() when done to release resources.
func NewAPI(opts *APIOpts) *API {
l := utils.CreateLogger("API", utils.GetLoggerLevel())
if opts == nil {
l.Errorln("Set API options")
_ = l.Close()
return nil
}
client := opts.client
if client == nil {
@@ -124,6 +129,9 @@ func NewAPI(opts *APIOpts) *API {
// See https://core.telegram.org/bots/api
func (api *API) Close() error {
api.pool.stop()
if api.client != nil {
api.client.CloseIdleConnections()
}
return api.logger.Close()
}
@@ -149,37 +157,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)
@@ -296,15 +296,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)
+34
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
@@ -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")
}
}
+36 -36
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// SendPhotoP holds parameters for the sendPhoto method.
// SendPhoto holds parameters for the sendPhoto method.
// See https://core.telegram.org/bots/api#sendphoto
type SendPhotoP struct {
type SendPhoto struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -29,7 +29,7 @@ type SendPhotoP struct {
// SendPhoto sends a photo.
// See https://core.telegram.org/bots/api#sendphoto
func (api *API) SendPhoto(params SendPhotoP) (Message, error) {
func (api *API) SendPhoto(params SendPhoto) (Message, error) {
req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID)
return req.Do(api)
}
@@ -37,14 +37,14 @@ func (api *API) SendPhoto(params SendPhotoP) (Message, error) {
// 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) {
func (api *API) SendPhotoWithContext(ctx context.Context, params SendPhoto) (Message, error) {
req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAudioP holds parameters for the sendAudio method.
// SendAudio holds parameters for the sendAudio method.
// See https://core.telegram.org/bots/api#sendaudio
type SendAudioP struct {
type SendAudio struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -71,7 +71,7 @@ type SendAudioP struct {
// SendAudio sends an audio file.
// See https://core.telegram.org/bots/api#sendaudio
func (api *API) SendAudio(params SendAudioP) (Message, error) {
func (api *API) SendAudio(params SendAudio) (Message, error) {
req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID)
return req.Do(api)
}
@@ -79,14 +79,14 @@ func (api *API) SendAudio(params SendAudioP) (Message, error) {
// 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) {
func (api *API) SendAudioWithContext(ctx context.Context, params SendAudio) (Message, error) {
req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDocumentP holds parameters for the sendDocument method.
// SendDocument holds parameters for the sendDocument method.
// See https://core.telegram.org/bots/api#senddocument
type SendDocumentP struct {
type SendDocument struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -111,7 +111,7 @@ type SendDocumentP struct {
// SendDocument sends a document.
// See https://core.telegram.org/bots/api#senddocument
func (api *API) SendDocument(params SendDocumentP) (Message, error) {
func (api *API) SendDocument(params SendDocument) (Message, error) {
req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID)
return req.Do(api)
}
@@ -119,14 +119,14 @@ func (api *API) SendDocument(params SendDocumentP) (Message, error) {
// 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) {
func (api *API) SendDocumentWithContext(ctx context.Context, params SendDocument) (Message, error) {
req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideoP holds parameters for the sendVideo method.
// SendVideo holds parameters for the sendVideo method.
// See https://core.telegram.org/bots/api#sendvideo
type SendVideoP struct {
type SendVideo struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -159,7 +159,7 @@ type SendVideoP struct {
// SendVideo sends a video.
// See https://core.telegram.org/bots/api#sendvideo
func (api *API) SendVideo(params SendVideoP) (Message, error) {
func (api *API) SendVideo(params SendVideo) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID)
return req.Do(api)
}
@@ -167,14 +167,14 @@ func (api *API) SendVideo(params SendVideoP) (Message, error) {
// 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) {
func (api *API) SendVideoWithContext(ctx context.Context, params SendVideo) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAnimationP holds parameters for the sendAnimation method.
// SendAnimation holds parameters for the sendAnimation method.
// See https://core.telegram.org/bots/api#sendanimation
type SendAnimationP struct {
type SendAnimation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -203,7 +203,7 @@ type SendAnimationP struct {
// SendAnimation sends an animation file (GIF or H.264/MPEG-4 AVC video without sound).
// See https://core.telegram.org/bots/api#sendanimation
func (api *API) SendAnimation(params SendAnimationP) (Message, error) {
func (api *API) SendAnimation(params SendAnimation) (Message, error) {
req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID)
return req.Do(api)
}
@@ -211,14 +211,14 @@ func (api *API) SendAnimation(params SendAnimationP) (Message, error) {
// 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) {
func (api *API) SendAnimationWithContext(ctx context.Context, params SendAnimation) (Message, error) {
req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVoiceP holds parameters for the sendVoice method.
// SendVoice holds parameters for the sendVoice method.
// See https://core.telegram.org/bots/api#sendvoice
type SendVoiceP struct {
type SendVoice struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -241,7 +241,7 @@ 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 SendVoice) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.Do(api)
}
@@ -249,14 +249,14 @@ func (api *API) SendVoice(params *SendVoiceP) (Message, error) {
// 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) {
func (api *API) SendVoiceWithContext(ctx context.Context, params SendVoice) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideoNoteP holds parameters for the sendVideoNote method.
// SendVideoNote holds parameters for the sendVideoNote method.
// See https://core.telegram.org/bots/api#sendvideonote
type SendVideoNoteP struct {
type SendVideoNote struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -278,7 +278,7 @@ type SendVideoNoteP struct {
// SendVideoNote sends a video note (rounded video message).
// See https://core.telegram.org/bots/api#sendvideonote
func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) {
func (api *API) SendVideoNote(params SendVideoNote) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID)
return req.Do(api)
}
@@ -286,14 +286,14 @@ func (api *API) SendVideoNote(params SendVideoNoteP) (Message, error) {
// 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) {
func (api *API) SendVideoNoteWithContext(ctx context.Context, params SendVideoNote) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPaidMediaP holds parameters for the sendPaidMedia method.
// SendPaidMedia holds parameters for the sendPaidMedia method.
// See https://core.telegram.org/bots/api#sendpaidmedia
type SendPaidMediaP struct {
type SendPaidMedia struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -317,7 +317,7 @@ type SendPaidMediaP struct {
// SendPaidMedia sends paid media.
// See https://core.telegram.org/bots/api#sendpaidmedia
func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) {
func (api *API) SendPaidMedia(params SendPaidMedia) (Message, error) {
req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID)
return req.Do(api)
}
@@ -325,14 +325,14 @@ func (api *API) SendPaidMedia(params SendPaidMediaP) (Message, error) {
// 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) {
func (api *API) SendPaidMediaWithContext(ctx context.Context, params SendPaidMedia) (Message, error) {
req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMediaGroupP holds parameters for the sendMediaGroup method.
// SendMediaGroup holds parameters for the sendMediaGroup method.
// See https://core.telegram.org/bots/api#sendmediagroup
type SendMediaGroupP struct {
type SendMediaGroup struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -348,7 +348,7 @@ type SendMediaGroupP struct {
// SendMediaGroup sends a group of photos, videos, documents or audios as an album.
// See https://core.telegram.org/bots/api#sendmediagroup
func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) {
func (api *API) SendMediaGroup(params SendMediaGroup) ([]Message, error) {
req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
return req.Do(api)
}
@@ -356,7 +356,7 @@ func (api *API) SendMediaGroup(params SendMediaGroupP) ([]Message, error) {
// 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) {
func (api *API) SendMediaGroupWithContext(ctx context.Context, params SendMediaGroup) ([]Message, error) {
req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+254 -6
View File
@@ -1,5 +1,253 @@
package tgapi
type Animation struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
FileName string `json:"file_name"`
MimeType string `json:"mime_type"`
FileSize int `json:"file_size"`
}
// Audio represents an audio file to be treated as music by the Telegram clients.
// See https://core.telegram.org/bots/api#audio
type Audio struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Duration int `json:"duration"`
Performer string `json:"performer,omitempty"`
Title string `json:"title,omitempty"`
FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
}
type Document struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
FileName string `json:"file_name"`
MimeType string `json:"mime_type"`
FileSize int `json:"file_size,omitempty"`
}
// Story represents a story.
type Story struct {
Chat Chat `json:"chat"`
ID int `json:"id"`
}
type Video struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Width int `json:"width"`
Height int `json:"height"`
Duration int `json:"duration"`
Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
Cover []PhotoSize `json:"cover,omitempty"`
StartTimestamp int64 `json:"start_timestamp"`
Qualities []VideoQuality `json:"qualities,omitempty"`
FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
}
// VideoQuality describes an alternative quality for a video.
// See https://core.telegram.org/bots/api#videoquality
type VideoQuality struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Width int `json:"width"`
Height int `json:"height"`
Codec string `json:"codec"`
FileSize int64 `json:"file_size,omitempty"`
}
type VideoNote struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Length int `json:"length"`
Duration int `json:"duration"`
Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
}
type Voice struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Duration int `json:"duration"`
MimeType string `json:"mime_type,omitempty"`
FileSize int `json:"file_size,omitempty"`
}
type PaidMediaInfo struct {
StarCount int `json:"star_count"`
PaidMedia []PaidMedia `json:"paid_media"`
}
type PaidMediaType string
const (
PaidMediaPreviewType PaidMediaType = "preview"
PaidMediaPhotoType PaidMediaType = "photo"
PaidMediaVideoType PaidMediaType = "video"
)
type PaidMedia struct {
Type PaidMediaType `json:"type,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Duration int `json:"duration,omitempty"`
Photo []PhotoSize `json:"photo,omitempty"`
Video *Video `json:"video,omitempty"`
}
type Contact struct {
PhoneNumber string `json:"phone_number"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
UserID int64 `json:"user_id,omitempty"`
Vcard string `json:"vcard,omitempty"`
}
type Dice struct {
Emoji string `json:"emoji"`
Value int `json:"value"`
}
// PollOption contains information about one answer option in a poll.
// See https://core.telegram.org/bots/api#polloption
type PollOption struct {
PersistentID string `json:"persistent_id"`
Text string `json:"text"`
TextEntities []MessageEntity `json:"text_entities"`
VoterCount int `json:"voter_count"`
AddedByUser *User `json:"added_by_user,omitempty"`
AddedByChat *Chat `json:"added_by_chat,omitempty"`
AdditionDate int `json:"addition_date,omitempty"`
}
// InputPollOption contains information about one answer option in a poll to be sent.
// See https://core.telegram.org/bots/api#inputpolloption
type InputPollOption struct {
Text string `json:"text"`
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
TextEntities []MessageEntity `json:"text_entities,omitempty"`
}
type PollOptionAdded struct {
PollMessage *InaccessibleMessage `json:"poll_message,omitempty"`
OptionPersistentID string `json:"option_persistent_id"`
OptionText string `json:"option_text"`
OptionTextEntities []MessageEntity `json:"option_text_entities,omitempty"`
}
type PollOptionDeleted struct {
PollMessage *InaccessibleMessage `json:"poll_message,omitempty"`
OptionPersistentID string `json:"option_persistent_id"`
OptionText string `json:"option_text"`
OptionTextEntities []MessageEntity `json:"option_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"
)
// PollAnswer represents an answer of a user in a poll.
// See https://core.telegram.org/bots/api#pollanswer
type PollAnswer struct {
PollID string `json:"poll_id"`
VoterChat Chat `json:"voter_chat"`
User User `json:"user"`
OptionIDs []int `json:"option_ids"`
OptionPersistentIDs []string `json:"option_persistent_ids"`
}
// Poll contains information about a poll.
// See https://core.telegram.org/bots/api#poll
type Poll struct {
ID string `json:"id"`
Question string `json:"question"`
QuestionEntities []MessageEntity `json:"question_entities"`
Options []PollOption `json:"options"`
TotalVoterCount int `json:"total_voter_count"`
IsClosed bool `json:"is_closed"`
IsAnonymous bool `json:"is_anonymous"`
Type PollType `json:"type"`
AllowsMultipleAnswers bool `json:"allows_multiple_answers"`
AllowsRevoting bool `json:"allows_revoting"`
CorrectOptionIDs []int `json:"correct_option_ids,omitempty"`
Explanation string `json:"explanation,omitempty"`
ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"`
OpenPeriod int `json:"open_period,omitempty"`
CloseDate int `json:"close_date,omitempty"`
Description string `json:"description,omitempty"`
DescriptionEntities []MessageEntity `json:"description_entities,omitempty"`
}
type ChecklistTask struct {
ID int `json:"id"`
Text string `json:"text"`
TextEntities []MessageEntity `json:"text_entities,omitempty"`
CompletedByUser *User `json:"completed_by_user,omitempty"`
CompletedByChat *Chat `json:"completed_by_chat,omitempty"`
CompletionDate int `json:"completion_date,omitempty"`
}
type Checklist struct {
Title string `json:"title"`
TitleEntities []MessageEntity `json:"title_entities,omitempty"`
Tasks []ChecklistTask `json:"tasks"`
OthersCanAddTasks bool `json:"others_can_add_tasks,omitempty"`
OthersCanMarkTasksAsDone bool `json:"others_can_mark_tasks_as_done,omitempty"`
}
// InputChecklistTask describes a task in a checklist.
type InputChecklistTask struct {
ID int `json:"id"`
Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,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"`
Tasks []InputChecklistTask `json:"tasks"`
OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"`
OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"`
}
type ChecklistTaskDone struct {
ChecklistMessage *Message `json:"checklist_message,omitempty"`
MarkedAsDoneTaskIDs []int `json:"marked_as_done_task_ids,omitempty"`
MarkedAsNotDoneTaskIDs []int `json:"marked_as_not_done_task_ids,omitempty"`
}
type ChecklistTasksAdded struct {
ChecklistMessage *Message `json:"checklist_message,omitempty"`
Tasks []ChecklistTask `json:"tasks"`
}
// InputMediaType represents the type of input media.
type InputMediaType string
@@ -55,12 +303,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.
+41 -41
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// SetMyCommandsP holds parameters for the setMyCommands method.
// SetMyCommands holds parameters for the setMyCommands method.
// See https://core.telegram.org/bots/api#setmycommands
type SetMyCommandsP struct {
type SetMyCommands struct {
Commands []BotCommand `json:"commands"`
Scope *BotCommandScope `json:"scope,omitempty"`
Language string `json:"language_code,omitempty"`
@@ -13,7 +13,7 @@ type SetMyCommandsP struct {
// SetMyCommands changes the list of the bot's commands.
// Returns true on success.
// See https://core.telegram.org/bots/api#setmycommands
func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) {
func (api *API) SetMyCommands(params SetMyCommands) (bool, error) {
req := NewRequest[bool]("setMyCommands", params)
return req.Do(api)
}
@@ -21,14 +21,14 @@ func (api *API) SetMyCommands(params SetMyCommandsP) (bool, error) {
// 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) {
func (api *API) SetMyCommandsWithContext(ctx context.Context, params SetMyCommands) (bool, error) {
req := NewRequest[bool]("setMyCommands", params)
return req.DoWithContext(ctx, api)
}
// DeleteMyCommandsP holds parameters for the deleteMyCommands method.
// DeleteMyCommands holds parameters for the deleteMyCommands method.
// See https://core.telegram.org/bots/api#deletemycommands
type DeleteMyCommandsP struct {
type DeleteMyCommands struct {
Scope *BotCommandScope `json:"scope,omitempty"`
Language string `json:"language_code,omitempty"`
}
@@ -36,7 +36,7 @@ type DeleteMyCommandsP struct {
// DeleteMyCommands deletes the list of the bot's commands for the given scope and user language.
// Returns true on success.
// See https://core.telegram.org/bots/api#deletemycommands
func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) {
func (api *API) DeleteMyCommands(params DeleteMyCommands) (bool, error) {
req := NewRequest[bool]("deleteMyCommands", params)
return req.Do(api)
}
@@ -44,7 +44,7 @@ func (api *API) DeleteMyCommands(params DeleteMyCommandsP) (bool, error) {
// 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) {
func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommands) (bool, error) {
req := NewRequest[bool]("deleteMyCommands", params)
return req.DoWithContext(ctx, api)
}
@@ -203,16 +203,16 @@ func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params Get
return req.DoWithContext(ctx, api)
}
// SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method.
// SetMyProfilePhoto holds parameters for the setMyProfilePhoto method.
// See https://core.telegram.org/bots/api#setmyprofilephoto
type SetMyProfilePhotoP struct {
type SetMyProfilePhoto struct {
Photo InputProfilePhoto `json:"photo"`
}
// SetMyProfilePhoto changes the bot's profile photo.
// Returns true on success.
// See https://core.telegram.org/bots/api#setmyprofilephoto
func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) {
func (api *API) SetMyProfilePhoto(params SetMyProfilePhoto) (bool, error) {
req := NewRequest[bool]("setMyProfilePhoto", params)
return req.Do(api)
}
@@ -220,7 +220,7 @@ func (api *API) SetMyProfilePhoto(params SetMyProfilePhotoP) (bool, error) {
// 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) {
func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhoto) (bool, error) {
req := NewRequest[bool]("setMyProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
@@ -241,17 +241,17 @@ func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, erro
return req.DoWithContext(ctx, api)
}
// SetChatMenuButtonP holds parameters for the setChatMenuButton method.
// SetChatMenuButton holds parameters for the setChatMenuButton method.
// See https://core.telegram.org/bots/api#setchatmenubutton
type SetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"`
MenuButton MenuButtonType `json:"menu_button"`
type SetChatMenuButton struct {
ChatID int64 `json:"chat_id,omitempty"`
MenuButton *MenuButton `json:"menu_button,omitempty"`
}
// SetChatMenuButton changes the menu button for a given chat or the default menu button.
// Returns true on success.
// See https://core.telegram.org/bots/api#setchatmenubutton
func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) {
func (api *API) SetChatMenuButton(params SetChatMenuButton) (bool, error) {
req := NewRequest[bool]("setChatMenuButton", params)
return req.Do(api)
}
@@ -259,35 +259,35 @@ func (api *API) SetChatMenuButton(params SetChatMenuButtonP) (bool, error) {
// 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) {
func (api *API) SetChatMenuButtonWithContext(ctx context.Context, params SetChatMenuButton) (bool, error) {
req := NewRequest[bool]("setChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// GetChatMenuButtonP holds parameters for the getChatMenuButton method.
// GetChatMenuButton holds parameters for the getChatMenuButton method.
// See https://core.telegram.org/bots/api#getchatmenubutton
type GetChatMenuButtonP struct {
ChatID int64 `json:"chat_id"`
type GetChatMenuButton struct {
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 GetChatMenuButton) (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) (BaseMenuButton, error) {
req := NewRequest[BaseMenuButton]("getChatMenuButton", params)
func (api *API) GetChatMenuButtonWithContext(ctx context.Context, params GetChatMenuButton) (MenuButton, error) {
req := NewRequest[MenuButton]("getChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method.
// SetMyDefaultAdministratorRights holds parameters for the setMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights
type SetMyDefaultAdministratorRightsP struct {
type SetMyDefaultAdministratorRights struct {
Rights *ChatAdministratorRights `json:"rights"`
ForChannels bool `json:"for_channels"`
}
@@ -295,7 +295,7 @@ type SetMyDefaultAdministratorRightsP struct {
// SetMyDefaultAdministratorRights changes the default administrator rights for the bot.
// Returns true on success.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights
func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRightsP) (bool, error) {
func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministratorRights) (bool, error) {
req := NewRequest[bool]("setMyDefaultAdministratorRights", params)
return req.Do(api)
}
@@ -303,20 +303,20 @@ func (api *API) SetMyDefaultAdministratorRights(params SetMyDefaultAdministrator
// 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) {
func (api *API) SetMyDefaultAdministratorRightsWithContext(ctx context.Context, params SetMyDefaultAdministratorRights) (bool, error) {
req := NewRequest[bool]("setMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
// GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method.
// GetMyDefaultAdministratorRights holds parameters for the getMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights
type GetMyDefaultAdministratorRightsP struct {
type GetMyDefaultAdministratorRights struct {
ForChannels bool `json:"for_channels"`
}
// GetMyDefaultAdministratorRights returns the current default administrator rights for the bot.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights
func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRightsP) (ChatAdministratorRights, error) {
func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministratorRights) (ChatAdministratorRights, error) {
req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params)
return req.Do(api)
}
@@ -324,7 +324,7 @@ func (api *API) GetMyDefaultAdministratorRights(params GetMyDefaultAdministrator
// 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) {
func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRights) (ChatAdministratorRights, error) {
req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
@@ -344,9 +344,9 @@ func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error)
return req.DoWithContext(ctx, api)
}
// SendGiftP holds parameters for the sendGift method.
// SendGift holds parameters for the sendGift method.
// See https://core.telegram.org/bots/api#sendgift
type SendGiftP struct {
type SendGift struct {
UserID int64 `json:"user_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
GiftID string `json:"gift_id"`
@@ -359,7 +359,7 @@ type SendGiftP struct {
// SendGift sends a gift to the given user or chat.
// Returns true on success.
// See https://core.telegram.org/bots/api#sendgift
func (api *API) SendGift(params SendGiftP) (bool, error) {
func (api *API) SendGift(params SendGift) (bool, error) {
req := NewRequest[bool]("sendGift", params)
return req.Do(api)
}
@@ -367,14 +367,14 @@ func (api *API) SendGift(params SendGiftP) (bool, error) {
// 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) {
func (api *API) SendGiftWithContext(ctx context.Context, params SendGift) (bool, error) {
req := NewRequest[bool]("sendGift", params)
return req.DoWithContext(ctx, api)
}
// GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method.
// GiftPremiumSubscription holds parameters for the giftPremiumSubscription method.
// See https://core.telegram.org/bots/api#giftpremiumsubscription
type GiftPremiumSubscriptionP struct {
type GiftPremiumSubscription struct {
UserID int64 `json:"user_id"`
MonthCount int `json:"month_count"`
StarCount int `json:"star_count"`
@@ -386,7 +386,7 @@ type GiftPremiumSubscriptionP struct {
// GiftPremiumSubscription gifts a Telegram Premium subscription to the user.
// Returns true on success.
// See https://core.telegram.org/bots/api#giftpremiumsubscription
func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool, error) {
func (api *API) GiftPremiumSubscription(params GiftPremiumSubscription) (bool, error) {
req := NewRequest[bool]("giftPremiumSubscription", params)
return req.Do(api)
}
@@ -394,7 +394,7 @@ func (api *API) GiftPremiumSubscription(params GiftPremiumSubscriptionP) (bool,
// 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) {
func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params GiftPremiumSubscription) (bool, error) {
req := NewRequest[bool]("giftPremiumSubscription", params)
return req.DoWithContext(ctx, api)
}
+12 -7
View File
@@ -54,7 +54,9 @@ type BotShortDescription struct {
type InputProfilePhotoType string
const (
InputProfilePhotoStaticType InputProfilePhotoType = "static"
// 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 MenuButtonType = "web_app"
MenuButtonDefaultType MenuButtonType = "default"
// 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"`
}
+90 -105
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// VerifyUserP holds parameters for the verifyUser method.
// VerifyUser holds parameters for the verifyUser method.
// See https://core.telegram.org/bots/api#verifyuser
type VerifyUserP struct {
type VerifyUser struct {
UserID int64 `json:"user_id"`
CustomDescription string `json:"custom_description,omitempty"`
}
@@ -12,7 +12,7 @@ type VerifyUserP struct {
// VerifyUser verifies a user.
// Returns true on success.
// See https://core.telegram.org/bots/api#verifyuser
func (api *API) VerifyUser(params VerifyUserP) (bool, error) {
func (api *API) VerifyUser(params VerifyUser) (bool, error) {
req := NewRequest[bool]("verifyUser", params)
return req.Do(api)
}
@@ -20,14 +20,14 @@ func (api *API) VerifyUser(params VerifyUserP) (bool, error) {
// 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) {
func (api *API) VerifyUserWithContext(ctx context.Context, params VerifyUser) (bool, error) {
req := NewRequest[bool]("verifyUser", params)
return req.DoWithContext(ctx, api)
}
// VerifyChatP holds parameters for the verifyChat method.
// VerifyChat holds parameters for the verifyChat method.
// See https://core.telegram.org/bots/api#verifychat
type VerifyChatP struct {
type VerifyChat struct {
ChatID int64 `json:"chat_id"`
CustomDescription string `json:"custom_description,omitempty"`
}
@@ -35,7 +35,7 @@ type VerifyChatP struct {
// VerifyChat verifies a chat.
// Returns true on success.
// See https://core.telegram.org/bots/api#verifychat
func (api *API) VerifyChat(params VerifyChatP) (bool, error) {
func (api *API) VerifyChat(params VerifyChat) (bool, error) {
req := NewRequest[bool]("verifyChat", params)
return req.Do(api)
}
@@ -43,21 +43,21 @@ func (api *API) VerifyChat(params VerifyChatP) (bool, error) {
// 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) {
func (api *API) VerifyChatWithContext(ctx context.Context, params VerifyChat) (bool, error) {
req := NewRequest[bool]("verifyChat", params)
return req.DoWithContext(ctx, api)
}
// RemoveUserVerificationP holds parameters for the removeUserVerification method.
// RemoveUserVerification holds parameters for the removeUserVerification method.
// See https://core.telegram.org/bots/api#removeuserverification
type RemoveUserVerificationP struct {
type RemoveUserVerification struct {
UserID int64 `json:"user_id"`
}
// RemoveUserVerification removes a user's verification.
// Returns true on success.
// See https://core.telegram.org/bots/api#removeuserverification
func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, error) {
func (api *API) RemoveUserVerification(params RemoveUserVerification) (bool, error) {
req := NewRequest[bool]("removeUserVerification", params)
return req.Do(api)
}
@@ -65,21 +65,21 @@ func (api *API) RemoveUserVerification(params RemoveUserVerificationP) (bool, er
// 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) {
func (api *API) RemoveUserVerificationWithContext(ctx context.Context, params RemoveUserVerification) (bool, error) {
req := NewRequest[bool]("removeUserVerification", params)
return req.DoWithContext(ctx, api)
}
// RemoveChatVerificationP holds parameters for the removeChatVerification method.
// RemoveChatVerification holds parameters for the removeChatVerification method.
// See https://core.telegram.org/bots/api#removechatverification
type RemoveChatVerificationP struct {
type RemoveChatVerification struct {
ChatID int64 `json:"chat_id"`
}
// RemoveChatVerification removes a chat's verification.
// Returns true on success.
// See https://core.telegram.org/bots/api#removechatverification
func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, error) {
func (api *API) RemoveChatVerification(params RemoveChatVerification) (bool, error) {
req := NewRequest[bool]("removeChatVerification", params)
return req.Do(api)
}
@@ -87,14 +87,14 @@ func (api *API) RemoveChatVerification(params RemoveChatVerificationP) (bool, er
// 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) {
func (api *API) RemoveChatVerificationWithContext(ctx context.Context, params RemoveChatVerification) (bool, error) {
req := NewRequest[bool]("removeChatVerification", params)
return req.DoWithContext(ctx, api)
}
// ReadBusinessMessageP holds parameters for the readBusinessMessage method.
// ReadBusinessMessage holds parameters for the readBusinessMessage method.
// See https://core.telegram.org/bots/api#readbusinessmessage
type ReadBusinessMessageP struct {
type ReadBusinessMessage struct {
BusinessConnectionID string `json:"business_connection_id"`
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
@@ -103,7 +103,7 @@ type ReadBusinessMessageP struct {
// ReadBusinessMessage marks a business message as read.
// Returns true on success.
// See https://core.telegram.org/bots/api#readbusinessmessage
func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) {
func (api *API) ReadBusinessMessage(params ReadBusinessMessage) (bool, error) {
req := NewRequest[bool]("readBusinessMessage", params)
return req.Do(api)
}
@@ -111,20 +111,20 @@ func (api *API) ReadBusinessMessage(params ReadBusinessMessageP) (bool, error) {
// 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) {
func (api *API) ReadBusinessMessageWithContext(ctx context.Context, params ReadBusinessMessage) (bool, error) {
req := NewRequest[bool]("readBusinessMessage", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessConnectionP holds parameters for the getBusinessConnection method.
// GetBusinessConnection holds parameters for the getBusinessConnection method.
// See https://core.telegram.org/bots/api#getbusinessconnection
type GetBusinessConnectionP struct {
type GetBusinessConnection struct {
BusinessConnectionID string `json:"business_connection_id"`
}
// GetBusinessConnection returns information about a business connection.
// See https://core.telegram.org/bots/api#getbusinessconnection
func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessConnection, error) {
func (api *API) GetBusinessConnection(params GetBusinessConnection) (BusinessConnection, error) {
req := NewRequest[BusinessConnection]("getBusinessConnection", params)
return req.Do(api)
}
@@ -132,14 +132,14 @@ func (api *API) GetBusinessConnection(params GetBusinessConnectionP) (BusinessCo
// 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) {
func (api *API) GetBusinessConnectionWithContext(ctx context.Context, params GetBusinessConnection) (BusinessConnection, error) {
req := NewRequest[BusinessConnection]("getBusinessConnection", params)
return req.DoWithContext(ctx, api)
}
// DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method.
// DeleteBusinessMessages holds parameters for the deleteBusinessMessages method.
// See https://core.telegram.org/bots/api#deletebusinessmessages
type DeleteBusinessMessagesP struct {
type DeleteBusinessMessages struct {
BusinessConnectionID string `json:"business_connection_id"`
MessageIDs []int `json:"message_ids"`
}
@@ -147,7 +147,7 @@ type DeleteBusinessMessagesP struct {
// DeleteBusinessMessages deletes business messages.
// Returns true on success.
// See https://core.telegram.org/bots/api#deletebusinessmessages
func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, error) {
func (api *API) DeleteBusinessMessages(params DeleteBusinessMessages) (bool, error) {
req := NewRequest[bool]("deleteBusinessMessages", params)
return req.Do(api)
}
@@ -155,14 +155,14 @@ func (api *API) DeleteBusinessMessages(params DeleteBusinessMessagesP) (bool, er
// 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) {
func (api *API) DeleteBusinessMessagesWithContext(ctx context.Context, params DeleteBusinessMessages) (bool, error) {
req := NewRequest[bool]("deleteBusinessMessages", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountNameP holds parameters for the setBusinessAccountName method.
// SetBusinessAccountName holds parameters for the setBusinessAccountName method.
// See https://core.telegram.org/bots/api#setbusinessaccountname
type SetBusinessAccountNameP struct {
type SetBusinessAccountName struct {
BusinessConnectionID string `json:"business_connection_id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"`
@@ -171,7 +171,7 @@ type SetBusinessAccountNameP struct {
// SetBusinessAccountName sets the first and last name of a business account.
// Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountname
func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, error) {
func (api *API) SetBusinessAccountName(params SetBusinessAccountName) (bool, error) {
req := NewRequest[bool]("setBusinessAccountName", params)
return req.Do(api)
}
@@ -179,14 +179,14 @@ func (api *API) SetBusinessAccountName(params SetBusinessAccountNameP) (bool, er
// 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) {
func (api *API) SetBusinessAccountNameWithContext(ctx context.Context, params SetBusinessAccountName) (bool, error) {
req := NewRequest[bool]("setBusinessAccountName", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method.
// SetBusinessAccountUsername holds parameters for the setBusinessAccountUsername method.
// See https://core.telegram.org/bots/api#setbusinessaccountusername
type SetBusinessAccountUsernameP struct {
type SetBusinessAccountUsername struct {
BusinessConnectionID string `json:"business_connection_id"`
Username string `json:"username,omitempty"`
}
@@ -194,7 +194,7 @@ type SetBusinessAccountUsernameP struct {
// SetBusinessAccountUsername sets the username of a business account.
// Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountusername
func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (bool, error) {
func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsername) (bool, error) {
req := NewRequest[bool]("setBusinessAccountUsername", params)
return req.Do(api)
}
@@ -202,14 +202,14 @@ func (api *API) SetBusinessAccountUsername(params SetBusinessAccountUsernameP) (
// 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) {
func (api *API) SetBusinessAccountUsernameWithContext(ctx context.Context, params SetBusinessAccountUsername) (bool, error) {
req := NewRequest[bool]("setBusinessAccountUsername", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method.
// SetBusinessAccountBio holds parameters for the setBusinessAccountBio method.
// See https://core.telegram.org/bots/api#setbusinessaccountbio
type SetBusinessAccountBioP struct {
type SetBusinessAccountBio struct {
BusinessConnectionID string `json:"business_connection_id"`
Bio string `json:"bio,omitempty"`
}
@@ -217,7 +217,7 @@ type SetBusinessAccountBioP struct {
// SetBusinessAccountBio sets the bio of a business account.
// Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountbio
func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, error) {
func (api *API) SetBusinessAccountBio(params SetBusinessAccountBio) (bool, error) {
req := NewRequest[bool]("setBusinessAccountBio", params)
return req.Do(api)
}
@@ -225,7 +225,7 @@ func (api *API) SetBusinessAccountBio(params SetBusinessAccountBioP) (bool, erro
// 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) {
func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBio) (bool, error) {
req := NewRequest[bool]("setBusinessAccountBio", params)
return req.DoWithContext(ctx, api)
}
@@ -254,9 +254,9 @@ func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, p
return req.DoWithContext(ctx, api)
}
// RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method.
// RemoveBusinessAccountProfilePhoto holds parameters for the removeBusinessAccountProfilePhoto method.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
type RemoveBusinessAccountProfilePhotoP struct {
type RemoveBusinessAccountProfilePhoto struct {
BusinessConnectionID string `json:"business_connection_id"`
IsPublic bool `json:"is_public,omitempty"`
}
@@ -264,7 +264,7 @@ type RemoveBusinessAccountProfilePhotoP struct {
// RemoveBusinessAccountProfilePhoto removes the profile photo of a business account.
// Returns true on success.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhotoP) (bool, error) {
func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountProfilePhoto) (bool, error) {
req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params)
return req.Do(api)
}
@@ -272,14 +272,14 @@ func (api *API) RemoveBusinessAccountProfilePhoto(params RemoveBusinessAccountPr
// 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) {
func (api *API) RemoveBusinessAccountProfilePhotoWithContext(ctx context.Context, params RemoveBusinessAccountProfilePhoto) (bool, error) {
req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method.
// SetBusinessAccountGiftSettings holds parameters for the setBusinessAccountGiftSettings method.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
type SetBusinessAccountGiftSettingsP struct {
type SetBusinessAccountGiftSettings struct {
BusinessConnectionID string `json:"business_connection_id"`
ShowGiftButton bool `json:"show_gift_button"`
AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types"`
@@ -288,7 +288,7 @@ type SetBusinessAccountGiftSettingsP struct {
// SetBusinessAccountGiftSettings sets gift settings for a business account.
// Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettingsP) (bool, error) {
func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSettings) (bool, error) {
req := NewRequest[bool]("setBusinessAccountGiftSettings", params)
return req.Do(api)
}
@@ -296,20 +296,20 @@ func (api *API) SetBusinessAccountGiftSettings(params SetBusinessAccountGiftSett
// 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) {
func (api *API) SetBusinessAccountGiftSettingsWithContext(ctx context.Context, params SetBusinessAccountGiftSettings) (bool, error) {
req := NewRequest[bool]("setBusinessAccountGiftSettings", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method.
// GetBusinessAccountStarBalance holds parameters for the getBusinessAccountStarBalance method.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
type GetBusinessAccountStarBalanceP struct {
type GetBusinessAccountStarBalance struct {
BusinessConnectionID string `json:"business_connection_id"`
}
// GetBusinessAccountStarBalance returns the star balance of a business account.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalanceP) (StarAmount, error) {
func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalance) (StarAmount, error) {
req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params)
return req.Do(api)
}
@@ -317,14 +317,14 @@ func (api *API) GetBusinessAccountStarBalance(params GetBusinessAccountStarBalan
// 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) {
func (api *API) GetBusinessAccountStarBalanceWithContext(ctx context.Context, params GetBusinessAccountStarBalance) (StarAmount, error) {
req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params)
return req.DoWithContext(ctx, api)
}
// TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method.
// TransferBusinessAccountStars holds parameters for the transferBusinessAccountStars method.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars
type TransferBusinessAccountStarsP struct {
type TransferBusinessAccountStars struct {
BusinessConnectionID string `json:"business_connection_id"`
StarCount int `json:"star_count"`
}
@@ -332,7 +332,7 @@ type TransferBusinessAccountStarsP struct {
// TransferBusinessAccountStars transfers stars from a business account.
// Returns true on success.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars
func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStarsP) (bool, error) {
func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars) (bool, error) {
req := NewRequest[bool]("transferBusinessAccountStars", params)
return req.Do(api)
}
@@ -340,14 +340,14 @@ func (api *API) TransferBusinessAccountStars(params TransferBusinessAccountStars
// 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) {
func (api *API) TransferBusinessAccountStarsWithContext(ctx context.Context, params TransferBusinessAccountStars) (bool, error) {
req := NewRequest[bool]("transferBusinessAccountStars", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method.
// GetBusinessAccountGifts holds parameters for the getBusinessAccountGifts method.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts
type GetBusinessAccountGiftsP struct {
type GetBusinessAccountGifts struct {
BusinessConnectionID string `json:"business_connection_id"`
ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"`
ExcludeSaved bool `json:"exclude_saved,omitempty"`
@@ -363,7 +363,7 @@ type GetBusinessAccountGiftsP struct {
// GetBusinessAccountGifts returns gifts owned by a business account.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts
func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedGifts, error) {
func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGifts) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params)
return req.Do(api)
}
@@ -371,14 +371,14 @@ func (api *API) GetBusinessAccountGifts(params GetBusinessAccountGiftsP) (OwnedG
// 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) {
func (api *API) GetBusinessAccountGiftsWithContext(ctx context.Context, params GetBusinessAccountGifts) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params)
return req.DoWithContext(ctx, api)
}
// ConvertGiftToStarsP holds parameters for the convertGiftToStars method.
// ConvertGiftToStars holds parameters for the convertGiftToStars method.
// See https://core.telegram.org/bots/api#convertgifttostars
type ConvertGiftToStarsP struct {
type ConvertGiftToStars struct {
BusinessConnectionID string `json:"business_connection_id"`
OwnedGiftID string `json:"owned_gift_id"`
}
@@ -386,7 +386,7 @@ type ConvertGiftToStarsP struct {
// ConvertGiftToStars converts a gift to Telegram Stars.
// Returns true on success.
// See https://core.telegram.org/bots/api#convertgifttostars
func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) {
func (api *API) ConvertGiftToStars(params ConvertGiftToStars) (bool, error) {
req := NewRequest[bool]("convertGiftToStars", params)
return req.Do(api)
}
@@ -394,14 +394,14 @@ func (api *API) ConvertGiftToStars(params ConvertGiftToStarsP) (bool, error) {
// 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) {
func (api *API) ConvertGiftToStarsWithContext(ctx context.Context, params ConvertGiftToStars) (bool, error) {
req := NewRequest[bool]("convertGiftToStars", params)
return req.DoWithContext(ctx, api)
}
// UpgradeGiftP holds parameters for the upgradeGift method.
// UpgradeGift holds parameters for the upgradeGift method.
// See https://core.telegram.org/bots/api#upgradegift
type UpgradeGiftP struct {
type UpgradeGift struct {
BusinessConnectionID string `json:"business_connection_id"`
OwnedGiftID string `json:"owned_gift_id"`
KeepOriginalDetails bool `json:"keep_original_details,omitempty"`
@@ -411,7 +411,7 @@ type UpgradeGiftP struct {
// UpgradeGift upgrades a gift.
// Returns true on success.
// See https://core.telegram.org/bots/api#upgradegift
func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) {
func (api *API) UpgradeGift(params UpgradeGift) (bool, error) {
req := NewRequest[bool]("upgradeGift", params)
return req.Do(api)
}
@@ -419,14 +419,14 @@ func (api *API) UpgradeGift(params UpgradeGiftP) (bool, error) {
// 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) {
func (api *API) UpgradeGiftWithContext(ctx context.Context, params UpgradeGift) (bool, error) {
req := NewRequest[bool]("upgradeGift", params)
return req.DoWithContext(ctx, api)
}
// TransferGiftP holds parameters for the transferGift method.
// TransferGift holds parameters for the transferGift method.
// See https://core.telegram.org/bots/api#transfergift
type TransferGiftP struct {
type TransferGift struct {
BusinessConnectionID string `json:"business_connection_id"`
OwnedGiftID string `json:"owned_gift_id"`
NewOwnerChatID int64 `json:"new_owner_chat_id"`
@@ -436,7 +436,7 @@ type TransferGiftP struct {
// TransferGift transfers a gift to another chat.
// Returns true on success.
// See https://core.telegram.org/bots/api#transfergift
func (api *API) TransferGift(params TransferGiftP) (bool, error) {
func (api *API) TransferGift(params TransferGift) (bool, error) {
req := NewRequest[bool]("transferGift", params)
return req.Do(api)
}
@@ -444,14 +444,14 @@ func (api *API) TransferGift(params TransferGiftP) (bool, error) {
// 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) {
func (api *API) TransferGiftWithContext(ctx context.Context, params TransferGift) (bool, error) {
req := NewRequest[bool]("transferGift", params)
return req.DoWithContext(ctx, api)
}
// PostStoryP holds parameters for the postStory method.
// PostStory holds parameters for the postStory method.
// See https://core.telegram.org/bots/api#poststory
type PostStoryP struct {
type PostStory struct {
BusinessConnectionID string `json:"business_connection_id"`
Content InputStoryContent `json:"content"`
ActivePeriod int `json:"active_period"`
@@ -465,39 +465,24 @@ type PostStoryP struct {
ProtectContent bool `json:"protect_content,omitempty"`
}
// PostStoryPhoto posts a story with a photo.
// PostStory posts a story with a photo.
// See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryPhoto(params PostStoryP) (Story, error) {
func (api *API) PostStory(params PostStory) (Story, error) {
req := NewRequest[Story]("postStory", params)
return req.Do(api)
}
// PostStoryPhotoWithContext is the context-aware variant of PostStoryPhoto.
// PostStoryWithContext 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) {
func (api *API) PostStoryWithContext(ctx context.Context, params PostStory) (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) {
req := NewRequest[Story]("postStory", params)
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.
// RepostStory holds parameters for the repostStory method.
// See https://core.telegram.org/bots/api#repoststory
type RepostStoryP struct {
type RepostStory struct {
BusinessConnectionID string `json:"business_connection_id"`
FromChatID int64 `json:"from_chat_id"`
FromStoryID int `json:"from_story_id"`
@@ -509,7 +494,7 @@ type RepostStoryP struct {
// RepostStory reposts a story from another chat.
// Returns the reposted story.
// See https://core.telegram.org/bots/api#repoststory
func (api *API) RepostStory(params RepostStoryP) (Story, error) {
func (api *API) RepostStory(params RepostStory) (Story, error) {
req := NewRequest[Story]("repostStory", params)
return req.Do(api)
}
@@ -517,14 +502,14 @@ func (api *API) RepostStory(params RepostStoryP) (Story, error) {
// 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) {
func (api *API) RepostStoryWithContext(ctx context.Context, params RepostStory) (Story, error) {
req := NewRequest[Story]("repostStory", params)
return req.DoWithContext(ctx, api)
}
// EditStoryP holds parameters for the editStory method.
// EditStory holds parameters for the editStory method.
// See https://core.telegram.org/bots/api#editstory
type EditStoryP struct {
type EditStory struct {
BusinessConnectionID string `json:"business_connection_id"`
StoryID int `json:"story_id"`
Content InputStoryContent `json:"content"`
@@ -538,7 +523,7 @@ type EditStoryP struct {
// EditStory edits an existing story.
// Returns the updated story.
// See https://core.telegram.org/bots/api#editstory
func (api *API) EditStory(params EditStoryP) (Story, error) {
func (api *API) EditStory(params EditStory) (Story, error) {
req := NewRequest[Story]("editStory", params)
return req.Do(api)
}
@@ -546,14 +531,14 @@ func (api *API) EditStory(params EditStoryP) (Story, error) {
// 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) {
func (api *API) EditStoryWithContext(ctx context.Context, params EditStory) (Story, error) {
req := NewRequest[Story]("editStory", params)
return req.DoWithContext(ctx, api)
}
// DeleteStoryP holds parameters for the deleteStory method.
// DeleteStory holds parameters for the deleteStory method.
// See https://core.telegram.org/bots/api#deletestory
type DeleteStoryP struct {
type DeleteStory struct {
BusinessConnectionID string `json:"business_connection_id"`
StoryID int `json:"story_id"`
}
@@ -561,7 +546,7 @@ type DeleteStoryP struct {
// DeleteStory deletes a story.
// Returns true on success.
// See https://core.telegram.org/bots/api#deletestory
func (api *API) DeleteStory(params DeleteStoryP) (bool, error) {
func (api *API) DeleteStory(params DeleteStory) (bool, error) {
req := NewRequest[bool]("deleteStory", params)
return req.Do(api)
}
@@ -569,7 +554,7 @@ func (api *API) DeleteStory(params DeleteStoryP) (bool, error) {
// 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) {
func (api *API) DeleteStoryWithContext(ctx context.Context, params DeleteStory) (bool, error) {
req := NewRequest[bool]("deleteStory", params)
return req.DoWithContext(ctx, api)
}
+11 -4
View File
@@ -72,7 +72,9 @@ type BusinessMessagesDeleted struct {
type InputStoryContentType string
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 StoryAreaTypeType = "location"
StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction"
StoryAreaTypeLinkType StoryAreaTypeType = "link"
StoryAreaTypeWeatherType StoryAreaTypeType = "weather"
// 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"
)
+127 -127
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// BanChatMemberP holds parameters for the banChatMember method.
// BanChatMember holds parameters for the banChatMember method.
// See https://core.telegram.org/bots/api#banchatmember
type BanChatMemberP struct {
type BanChatMember struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
UntilDate int `json:"until_date,omitempty"`
@@ -14,7 +14,7 @@ type BanChatMemberP struct {
// BanChatMember bans a user in a chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#banchatmember
func (api *API) BanChatMember(params BanChatMemberP) (bool, error) {
func (api *API) BanChatMember(params BanChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID)
return req.Do(api)
}
@@ -22,14 +22,14 @@ func (api *API) BanChatMember(params BanChatMemberP) (bool, error) {
// 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) {
func (api *API) BanChatMemberWithContext(ctx context.Context, params BanChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatMemberP holds parameters for the unbanChatMember method.
// UnbanChatMember holds parameters for the unbanChatMember method.
// See https://core.telegram.org/bots/api#unbanchatmember
type UnbanChatMemberP struct {
type UnbanChatMember struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
OnlyIfBanned bool `json:"only_if_banned"`
@@ -38,7 +38,7 @@ type UnbanChatMemberP struct {
// UnbanChatMember unbans a previously banned user in a chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#unbanchatmember
func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) {
func (api *API) UnbanChatMember(params UnbanChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID)
return req.Do(api)
}
@@ -46,14 +46,14 @@ func (api *API) UnbanChatMember(params UnbanChatMemberP) (bool, error) {
// 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) {
func (api *API) UnbanChatMemberWithContext(ctx context.Context, params UnbanChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RestrictChatMemberP holds parameters for the restrictChatMember method.
// RestrictChatMember holds parameters for the restrictChatMember method.
// See https://core.telegram.org/bots/api#restrictchatmember
type RestrictChatMemberP struct {
type RestrictChatMember struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
Permissions ChatPermissions `json:"permissions"`
@@ -64,7 +64,7 @@ type RestrictChatMemberP struct {
// RestrictChatMember restricts a user in a chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#restrictchatmember
func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) {
func (api *API) RestrictChatMember(params RestrictChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID)
return req.Do(api)
}
@@ -72,7 +72,7 @@ func (api *API) RestrictChatMember(params RestrictChatMemberP) (bool, error) {
// 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) {
func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -118,9 +118,9 @@ func (api *API) PromoteChatMemberWithContext(ctx context.Context, params Promote
return req.DoWithContext(ctx, api)
}
// SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method.
// SetChatAdministratorCustomTitle holds parameters for the setChatAdministratorCustomTitle method.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
type SetChatAdministratorCustomTitleP struct {
type SetChatAdministratorCustomTitle struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
CustomTitle string `json:"custom_title"`
@@ -129,7 +129,7 @@ type SetChatAdministratorCustomTitleP struct {
// SetChatAdministratorCustomTitle sets a custom title for an administrator.
// Returns True on success.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitleP) (bool, error) {
func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCustomTitle) (bool, error) {
req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID)
return req.Do(api)
}
@@ -137,14 +137,14 @@ func (api *API) SetChatAdministratorCustomTitle(params SetChatAdministratorCusto
// 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) {
func (api *API) SetChatAdministratorCustomTitleWithContext(ctx context.Context, params SetChatAdministratorCustomTitle) (bool, error) {
req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatMemberTagP holds parameters for the setChatMemberTag method.
// SetChatMemberTag holds parameters for the setChatMemberTag method.
// See https://core.telegram.org/bots/api#setchatmembertag
type SetChatMemberTagP struct {
type SetChatMemberTag struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
Tag string `json:"tag,omitempty"`
@@ -153,7 +153,7 @@ type SetChatMemberTagP struct {
// SetChatMemberTag sets a tag for a chat member.
// Returns True on success.
// See https://core.telegram.org/bots/api#setchatmembertag
func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) {
func (api *API) SetChatMemberTag(params SetChatMemberTag) (bool, error) {
req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID)
return req.Do(api)
}
@@ -161,14 +161,14 @@ func (api *API) SetChatMemberTag(params SetChatMemberTagP) (bool, error) {
// 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) {
func (api *API) SetChatMemberTagWithContext(ctx context.Context, params SetChatMemberTag) (bool, error) {
req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// BanChatSenderChatP holds parameters for the banChatSenderChat method.
// BanChatSenderChat holds parameters for the banChatSenderChat method.
// See https://core.telegram.org/bots/api#banchatsenderchat
type BanChatSenderChatP struct {
type BanChatSenderChat struct {
ChatID int64 `json:"chat_id"`
SenderChatID int64 `json:"sender_chat_id"`
}
@@ -176,7 +176,7 @@ type BanChatSenderChatP struct {
// BanChatSenderChat bans a channel chat in a supergroup or channel.
// Returns True on success.
// See https://core.telegram.org/bots/api#banchatsenderchat
func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) {
func (api *API) BanChatSenderChat(params BanChatSenderChat) (bool, error) {
req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID)
return req.Do(api)
}
@@ -184,14 +184,14 @@ func (api *API) BanChatSenderChat(params BanChatSenderChatP) (bool, error) {
// 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) {
func (api *API) BanChatSenderChatWithContext(ctx context.Context, params BanChatSenderChat) (bool, error) {
req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method.
// UnbanChatSenderChat holds parameters for the unbanChatSenderChat method.
// See https://core.telegram.org/bots/api#unbanchatsenderchat
type UnbanChatSenderChatP struct {
type UnbanChatSenderChat struct {
ChatID int64 `json:"chat_id"`
SenderChatID int64 `json:"sender_chat_id"`
}
@@ -199,7 +199,7 @@ type UnbanChatSenderChatP struct {
// UnbanChatSenderChat unbans a previously banned channel chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#unbanchatsenderchat
func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) {
func (api *API) UnbanChatSenderChat(params UnbanChatSenderChat) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID)
return req.Do(api)
}
@@ -207,14 +207,14 @@ func (api *API) UnbanChatSenderChat(params UnbanChatSenderChatP) (bool, error) {
// 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) {
func (api *API) UnbanChatSenderChatWithContext(ctx context.Context, params UnbanChatSenderChat) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPermissionsP holds parameters for the setChatPermissions method.
// SetChatPermissions holds parameters for the setChatPermissions method.
// See https://core.telegram.org/bots/api#setchatpermissions
type SetChatPermissionsP struct {
type SetChatPermissions struct {
ChatID int64 `json:"chat_id"`
Permissions ChatPermissions `json:"permissions"`
UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"`
@@ -223,7 +223,7 @@ type SetChatPermissionsP struct {
// SetChatPermissions sets default chat permissions for all members.
// Returns True on success.
// See https://core.telegram.org/bots/api#setchatpermissions
func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) {
func (api *API) SetChatPermissions(params SetChatPermissions) (bool, error) {
req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID)
return req.Do(api)
}
@@ -231,21 +231,21 @@ func (api *API) SetChatPermissions(params SetChatPermissionsP) (bool, error) {
// 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) {
func (api *API) SetChatPermissionsWithContext(ctx context.Context, params SetChatPermissions) (bool, error) {
req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ExportChatInviteLinkP holds parameters for the exportChatInviteLink method.
// ExportChatInviteLink holds parameters for the exportChatInviteLink method.
// See https://core.telegram.org/bots/api#exportchatinvitelink
type ExportChatInviteLinkP struct {
type ExportChatInviteLink struct {
ChatID int64 `json:"chat_id"`
}
// ExportChatInviteLink generates a new primary invite link for a chat.
// Returns the new invite link as string.
// See https://core.telegram.org/bots/api#exportchatinvitelink
func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, error) {
func (api *API) ExportChatInviteLink(params ExportChatInviteLink) (string, error) {
req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID)
return req.Do(api)
}
@@ -253,14 +253,14 @@ func (api *API) ExportChatInviteLink(params ExportChatInviteLinkP) (string, erro
// 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) {
func (api *API) ExportChatInviteLinkWithContext(ctx context.Context, params ExportChatInviteLink) (string, error) {
req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatInviteLinkP holds parameters for the createChatInviteLink method.
// CreateChatInviteLink holds parameters for the createChatInviteLink method.
// See https://core.telegram.org/bots/api#createchatinvitelink
type CreateChatInviteLinkP struct {
type CreateChatInviteLink struct {
ChatID int64 `json:"chat_id"`
Name *string `json:"name,omitempty"`
ExpireDate int `json:"expire_date,omitempty"`
@@ -271,7 +271,7 @@ type CreateChatInviteLinkP struct {
// CreateChatInviteLink creates an additional invite link for a chat.
// Returns the created invite link.
// See https://core.telegram.org/bots/api#createchatinvitelink
func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLink, error) {
func (api *API) CreateChatInviteLink(params CreateChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID)
return req.Do(api)
}
@@ -279,14 +279,14 @@ func (api *API) CreateChatInviteLink(params CreateChatInviteLinkP) (ChatInviteLi
// 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) {
func (api *API) CreateChatInviteLinkWithContext(ctx context.Context, params CreateChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatInviteLinkP holds parameters for the editChatInviteLink method.
// EditChatInviteLink holds parameters for the editChatInviteLink method.
// See https://core.telegram.org/bots/api#editchatinvitelink
type EditChatInviteLinkP struct {
type EditChatInviteLink struct {
ChatID int64 `json:"chat_id"`
InviteLink string `json:"invite_link"`
@@ -299,7 +299,7 @@ type EditChatInviteLinkP struct {
// EditChatInviteLink edits a nonprimary invite link.
// Returns the edited invite link.
// See https://core.telegram.org/bots/api#editchatinvitelink
func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink, error) {
func (api *API) EditChatInviteLink(params EditChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID)
return req.Do(api)
}
@@ -307,14 +307,14 @@ func (api *API) EditChatInviteLink(params EditChatInviteLinkP) (ChatInviteLink,
// 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) {
func (api *API) EditChatInviteLinkWithContext(ctx context.Context, params EditChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method.
// CreateChatSubscriptionInviteLink holds parameters for the createChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
type CreateChatSubscriptionInviteLinkP struct {
type CreateChatSubscriptionInviteLink struct {
ChatID int64 `json:"chat_id"`
Name string `json:"name,omitempty"`
SubscriptionPeriod int `json:"subscription_period,omitempty"`
@@ -324,7 +324,7 @@ type CreateChatSubscriptionInviteLinkP struct {
// CreateChatSubscriptionInviteLink creates a subscription invite link for a channel chat.
// Returns the created invite link.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLinkP) (ChatInviteLink, error) {
func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID)
return req.Do(api)
}
@@ -332,14 +332,14 @@ func (api *API) CreateChatSubscriptionInviteLink(params CreateChatSubscriptionIn
// 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) {
func (api *API) CreateChatSubscriptionInviteLinkWithContext(ctx context.Context, params CreateChatSubscriptionInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method.
// EditChatSubscriptionInviteLink holds parameters for the editChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
type EditChatSubscriptionInviteLinkP struct {
type EditChatSubscriptionInviteLink struct {
ChatID int64 `json:"chat_id"`
InviteLink string `json:"invite_link"`
Name string `json:"name,omitempty"`
@@ -348,7 +348,7 @@ type EditChatSubscriptionInviteLinkP struct {
// EditChatSubscriptionInviteLink edits a subscription invite link.
// Returns the edited invite link.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLinkP) (ChatInviteLink, error) {
func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID)
return req.Do(api)
}
@@ -356,14 +356,14 @@ func (api *API) EditChatSubscriptionInviteLink(params EditChatSubscriptionInvite
// 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) {
func (api *API) EditChatSubscriptionInviteLinkWithContext(ctx context.Context, params EditChatSubscriptionInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method.
// RevokeChatInviteLink holds parameters for the revokeChatInviteLink method.
// See https://core.telegram.org/bots/api#revokechatinvitelink
type RevokeChatInviteLinkP struct {
type RevokeChatInviteLink struct {
ChatID int64 `json:"chat_id"`
InviteLink string `json:"invite_link"`
}
@@ -371,7 +371,7 @@ type RevokeChatInviteLinkP struct {
// RevokeChatInviteLink revokes an invite link.
// Returns the revoked invite link object.
// See https://core.telegram.org/bots/api#revokechatinvitelink
func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLink, error) {
func (api *API) RevokeChatInviteLink(params RevokeChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID)
return req.Do(api)
}
@@ -379,14 +379,14 @@ func (api *API) RevokeChatInviteLink(params RevokeChatInviteLinkP) (ChatInviteLi
// 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) {
func (api *API) RevokeChatInviteLinkWithContext(ctx context.Context, params RevokeChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method.
// ApproveChatJoinRequest holds parameters for the approveChatJoinRequest method.
// See https://core.telegram.org/bots/api#approvechatjoinrequest
type ApproveChatJoinRequestP struct {
type ApproveChatJoinRequest struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
}
@@ -394,7 +394,7 @@ type ApproveChatJoinRequestP struct {
// ApproveChatJoinRequest approves a chat join request.
// Returns True on success.
// See https://core.telegram.org/bots/api#approvechatjoinrequest
func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, error) {
func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequest) (bool, error) {
req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID)
return req.Do(api)
}
@@ -402,14 +402,14 @@ func (api *API) ApproveChatJoinRequest(params ApproveChatJoinRequestP) (bool, er
// 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) {
func (api *API) ApproveChatJoinRequestWithContext(ctx context.Context, params ApproveChatJoinRequest) (bool, error) {
req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method.
// DeclineChatJoinRequest holds parameters for the declineChatJoinRequest method.
// See https://core.telegram.org/bots/api#declinechatjoinrequest
type DeclineChatJoinRequestP struct {
type DeclineChatJoinRequest struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
}
@@ -417,7 +417,7 @@ type DeclineChatJoinRequestP struct {
// DeclineChatJoinRequest declines a chat join request.
// Returns True on success.
// See https://core.telegram.org/bots/api#declinechatjoinrequest
func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, error) {
func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequest) (bool, error) {
req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID)
return req.Do(api)
}
@@ -425,14 +425,14 @@ func (api *API) DeclineChatJoinRequest(params DeclineChatJoinRequestP) (bool, er
// 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) {
func (api *API) DeclineChatJoinRequestWithContext(ctx context.Context, params DeclineChatJoinRequest) (bool, error) {
req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPhotoP holds parameters for the setChatPhoto method.
// SetChatPhoto holds parameters for the setChatPhoto method.
// See https://core.telegram.org/bots/api#setchatphoto
type SetChatPhotoP struct {
type SetChatPhoto struct {
ChatID int64 `json:"chat_id"`
}
@@ -440,7 +440,7 @@ type SetChatPhotoP struct {
// photo is the file to upload as the new photo.
// Returns True on success.
// See https://core.telegram.org/bots/api#setchatphoto
func (api *API) SetChatPhoto(params SetChatPhotoP, photo UploaderFile) (bool, error) {
func (api *API) SetChatPhoto(params SetChatPhoto, photo UploaderFile) (bool, error) {
uploader := NewUploader(api)
defer func() {
_ = uploader.Close()
@@ -449,16 +449,16 @@ func (api *API) SetChatPhoto(params SetChatPhotoP, photo UploaderFile) (bool, er
return req.Do(uploader)
}
// DeleteChatPhotoP holds parameters for the deleteChatPhoto method.
// DeleteChatPhoto holds parameters for the deleteChatPhoto method.
// See https://core.telegram.org/bots/api#deletechatphoto
type DeleteChatPhotoP struct {
type DeleteChatPhoto struct {
ChatID int64 `json:"chat_id"`
}
// DeleteChatPhoto deletes a chat photo.
// Returns True on success.
// See https://core.telegram.org/bots/api#deletechatphoto
func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) {
func (api *API) DeleteChatPhoto(params DeleteChatPhoto) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID)
return req.Do(api)
}
@@ -466,14 +466,14 @@ func (api *API) DeleteChatPhoto(params DeleteChatPhotoP) (bool, error) {
// 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) {
func (api *API) DeleteChatPhotoWithContext(ctx context.Context, params DeleteChatPhoto) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatTitleP holds parameters for the setChatTitle method.
// SetChatTitle holds parameters for the setChatTitle method.
// See https://core.telegram.org/bots/api#setchattitle
type SetChatTitleP struct {
type SetChatTitle struct {
ChatID int64 `json:"chat_id"`
Title string `json:"title"`
}
@@ -481,7 +481,7 @@ type SetChatTitleP struct {
// SetChatTitle changes the chat title.
// Returns True on success.
// See https://core.telegram.org/bots/api#setchattitle
func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) {
func (api *API) SetChatTitle(params SetChatTitle) (bool, error) {
req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID)
return req.Do(api)
}
@@ -489,14 +489,14 @@ func (api *API) SetChatTitle(params SetChatTitleP) (bool, error) {
// 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) {
func (api *API) SetChatTitleWithContext(ctx context.Context, params SetChatTitle) (bool, error) {
req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatDescriptionP holds parameters for the setChatDescription method.
// SetChatDescription holds parameters for the setChatDescription method.
// See https://core.telegram.org/bots/api#setchatdescription
type SetChatDescriptionP struct {
type SetChatDescription struct {
ChatID int64 `json:"chat_id"`
Description string `json:"description"`
}
@@ -504,7 +504,7 @@ type SetChatDescriptionP struct {
// SetChatDescription changes the chat description.
// Returns True on success.
// See https://core.telegram.org/bots/api#setchatdescription
func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) {
func (api *API) SetChatDescription(params SetChatDescription) (bool, error) {
req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID)
return req.Do(api)
}
@@ -512,14 +512,14 @@ func (api *API) SetChatDescription(params SetChatDescriptionP) (bool, error) {
// 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) {
func (api *API) SetChatDescriptionWithContext(ctx context.Context, params SetChatDescription) (bool, error) {
req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// PinChatMessageP holds parameters for the pinChatMessage method.
// PinChatMessage holds parameters for the pinChatMessage method.
// See https://core.telegram.org/bots/api#pinchatmessage
type PinChatMessageP struct {
type PinChatMessage struct {
BusinessConnectionID *string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
@@ -529,7 +529,7 @@ type PinChatMessageP struct {
// PinChatMessage pins a message in a chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#pinchatmessage
func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) {
func (api *API) PinChatMessage(params PinChatMessage) (bool, error) {
req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID)
return req.Do(api)
}
@@ -537,14 +537,14 @@ func (api *API) PinChatMessage(params PinChatMessageP) (bool, error) {
// 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) {
func (api *API) PinChatMessageWithContext(ctx context.Context, params PinChatMessage) (bool, error) {
req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinChatMessageP holds parameters for the unpinChatMessage method.
// UnpinChatMessage holds parameters for the unpinChatMessage method.
// See https://core.telegram.org/bots/api#unpinchatmessage
type UnpinChatMessageP struct {
type UnpinChatMessage struct {
BusinessConnectionID *string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
@@ -553,7 +553,7 @@ type UnpinChatMessageP struct {
// UnpinChatMessage unpins a message in a chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#unpinchatmessage
func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) {
func (api *API) UnpinChatMessage(params UnpinChatMessage) (bool, error) {
req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID)
return req.Do(api)
}
@@ -561,21 +561,21 @@ func (api *API) UnpinChatMessage(params UnpinChatMessageP) (bool, error) {
// 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) {
func (api *API) UnpinChatMessageWithContext(ctx context.Context, params UnpinChatMessage) (bool, error) {
req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method.
// UnpinAllChatMessages holds parameters for the unpinAllChatMessages method.
// See https://core.telegram.org/bots/api#unpinallchatmessages
type UnpinAllChatMessagesP struct {
type UnpinAllChatMessages struct {
ChatID int64 `json:"chat_id"`
}
// UnpinAllChatMessages unpins all pinned messages in a chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#unpinallchatmessages
func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error) {
func (api *API) UnpinAllChatMessages(params UnpinAllChatMessages) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID)
return req.Do(api)
}
@@ -583,21 +583,21 @@ func (api *API) UnpinAllChatMessages(params UnpinAllChatMessagesP) (bool, error)
// 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) {
func (api *API) UnpinAllChatMessagesWithContext(ctx context.Context, params UnpinAllChatMessages) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// LeaveChatP holds parameters for the leaveChat method.
// LeaveChat holds parameters for the leaveChat method.
// See https://core.telegram.org/bots/api#leavechat
type LeaveChatP struct {
type LeaveChat struct {
ChatID int64 `json:"chat_id"`
}
// LeaveChat makes the bot leave a chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#leavechat
func (api *API) LeaveChat(params LeaveChatP) (bool, error) {
func (api *API) LeaveChat(params LeaveChat) (bool, error) {
req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name
return req.Do(api)
}
@@ -605,20 +605,20 @@ func (api *API) LeaveChat(params LeaveChatP) (bool, error) {
// 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) {
func (api *API) LeaveChatWithContext(ctx context.Context, params LeaveChat) (bool, error) {
req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChatP holds parameters for the getChat method.
// GetChat holds parameters for the getChat method.
// See https://core.telegram.org/bots/api#getchat
type GetChatP struct {
type GetChat struct {
ChatID int64 `json:"chat_id"`
}
// GetChat gets uptodate information about a chat.
// See https://core.telegram.org/bots/api#getchat
func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) {
func (api *API) GetChat(params GetChat) (ChatFullInfo, error) {
req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name
return req.Do(api)
}
@@ -626,20 +626,20 @@ func (api *API) GetChat(params GetChatP) (ChatFullInfo, error) {
// 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) {
func (api *API) GetChatWithContext(ctx context.Context, params GetChat) (ChatFullInfo, error) {
req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChatAdministratorsP holds parameters for the getChatAdministrators method.
// GetChatAdministrators holds parameters for the getChatAdministrators method.
// See https://core.telegram.org/bots/api#getchatadministrators
type GetChatAdministratorsP struct {
type GetChatAdministrators struct {
ChatID int64 `json:"chat_id"`
}
// GetChatAdministrators returns a list of administrators in a chat.
// See https://core.telegram.org/bots/api#getchatadministrators
func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMember, error) {
func (api *API) GetChatAdministrators(params GetChatAdministrators) ([]ChatMember, error) {
req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID)
return req.Do(api)
}
@@ -647,20 +647,20 @@ func (api *API) GetChatAdministrators(params GetChatAdministratorsP) ([]ChatMemb
// 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) {
func (api *API) GetChatAdministratorsWithContext(ctx context.Context, params GetChatAdministrators) ([]ChatMember, error) {
req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMembersCountP holds parameters for the getChatMemberCount method.
// GetChatMembersCount holds parameters for the getChatMemberCount method.
// See https://core.telegram.org/bots/api#getchatmembercount
type GetChatMembersCountP struct {
type GetChatMembersCount struct {
ChatID int64 `json:"chat_id"`
}
// GetChatMemberCount returns the number of members in a chat.
// See https://core.telegram.org/bots/api#getchatmembercount
func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) {
func (api *API) GetChatMemberCount(params GetChatMembersCount) (int, error) {
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.Do(api)
}
@@ -668,21 +668,21 @@ func (api *API) GetChatMemberCount(params GetChatMembersCountP) (int, error) {
// 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) {
func (api *API) GetChatMemberCountWithContext(ctx context.Context, params GetChatMembersCount) (int, error) {
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMemberP holds parameters for the getChatMember method.
// GetChatMember holds parameters for the getChatMember method.
// See https://core.telegram.org/bots/api#getchatmember
type GetChatMemberP struct {
type GetChatMember struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
}
// GetChatMember returns information about a member of a chat.
// See https://core.telegram.org/bots/api#getchatmember
func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) {
func (api *API) GetChatMember(params GetChatMember) (ChatMember, error) {
req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID)
return req.Do(api)
}
@@ -690,14 +690,14 @@ func (api *API) GetChatMember(params GetChatMemberP) (ChatMember, error) {
// 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) {
func (api *API) GetChatMemberWithContext(ctx context.Context, params GetChatMember) (ChatMember, error) {
req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatStickerSetP holds parameters for the setChatStickerSet method.
// SetChatStickerSet holds parameters for the setChatStickerSet method.
// See https://core.telegram.org/bots/api#setchatstickerset
type SetChatStickerSetP struct {
type SetChatStickerSet struct {
ChatID int64 `json:"chat_id"`
StickerSetName string `json:"sticker_set_name"`
}
@@ -705,7 +705,7 @@ type SetChatStickerSetP struct {
// SetChatStickerSet associates a sticker set with a supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#setchatstickerset
func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) {
func (api *API) SetChatStickerSet(params SetChatStickerSet) (bool, error) {
req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID)
return req.Do(api)
}
@@ -713,21 +713,21 @@ func (api *API) SetChatStickerSet(params SetChatStickerSetP) (bool, error) {
// 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) {
func (api *API) SetChatStickerSetWithContext(ctx context.Context, params SetChatStickerSet) (bool, error) {
req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method.
// DeleteChatStickerSet holds parameters for the deleteChatStickerSet method.
// See https://core.telegram.org/bots/api#deletechatstickerset
type DeleteChatStickerSetP struct {
type DeleteChatStickerSet struct {
ChatID int64 `json:"chat_id"`
}
// DeleteChatStickerSet deletes a sticker set from a supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#deletechatstickerset
func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error) {
func (api *API) DeleteChatStickerSet(params DeleteChatStickerSet) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID)
return req.Do(api)
}
@@ -735,21 +735,21 @@ func (api *API) DeleteChatStickerSet(params DeleteChatStickerSetP) (bool, error)
// 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) {
func (api *API) DeleteChatStickerSetWithContext(ctx context.Context, params DeleteChatStickerSet) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetUserChatBoostsP holds parameters for the getUserChatBoosts method.
// GetUserChatBoosts holds parameters for the getUserChatBoosts method.
// See https://core.telegram.org/bots/api#getuserchatboosts
type GetUserChatBoostsP struct {
type GetUserChatBoosts struct {
ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"`
}
// GetUserChatBoosts returns the list of boosts a user has given to a chat.
// See https://core.telegram.org/bots/api#getuserchatboosts
func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, error) {
func (api *API) GetUserChatBoosts(params GetUserChatBoosts) (UserChatBoosts, error) {
req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID)
return req.Do(api)
}
@@ -757,14 +757,14 @@ func (api *API) GetUserChatBoosts(params GetUserChatBoostsP) (UserChatBoosts, er
// 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) {
func (api *API) GetUserChatBoostsWithContext(ctx context.Context, params GetUserChatBoosts) (UserChatBoosts, error) {
req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatGiftsP holds parameters for the getChatGifts method.
// GetChatGifts holds parameters for the getChatGifts method.
// See https://core.telegram.org/bots/api#getchatgifts
type GetChatGiftsP struct {
type GetChatGifts struct {
ChatID int64 `json:"chat_id"`
ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"`
ExcludeSaved bool `json:"exclude_saved,omitempty"`
@@ -780,7 +780,7 @@ type GetChatGiftsP struct {
// GetChatGifts returns gifts owned by a chat.
// See https://core.telegram.org/bots/api#getchatgifts
func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) {
func (api *API) GetChatGifts(params GetChatGifts) (OwnedGifts, error) {
req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
return req.Do(api)
}
@@ -788,7 +788,7 @@ func (api *API) GetChatGifts(params GetChatGiftsP) (OwnedGifts, error) {
// 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) {
func (api *API) GetChatGiftsWithContext(ctx context.Context, params GetChatGifts) (OwnedGifts, error) {
req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+50 -19
View File
@@ -3,24 +3,28 @@ package tgapi
// Chat represents a chat (private, group, supergroup, channel).
// See https://core.telegram.org/bots/api#chat
type Chat struct {
ID int64 `json:"id"`
Type string `json:"type"`
Title *string `json:"title,omitempty"`
Username *string `json:"username,omitempty"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
IsForum *bool `json:"is_forum,omitempty"`
IsDirectMessages *bool `json:"is_direct_messages,omitempty"`
ID int64 `json:"id"`
Type ChatType `json:"type"`
Title *string `json:"title,omitempty"`
Username *string `json:"username,omitempty"`
FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"`
IsForum *bool `json:"is_forum,omitempty"`
IsDirectMessages *bool `json:"is_direct_messages,omitempty"`
}
// ChatType represents the type of a chat.
type ChatType string
const (
ChatTypePrivate ChatType = "private"
ChatTypeGroup ChatType = "group"
// 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 ChatType = "channel"
// ChatTypeChannel identifies a channel chat.
ChatTypeChannel ChatType = "channel"
)
// ChatFullInfo contains full information about a chat.
@@ -103,11 +107,12 @@ type ChatPermissions struct {
CanSendAudios bool `json:"can_send_audios"`
CanSendDocuments bool `json:"can_send_documents"`
CanSendPhotos bool `json:"can_send_photos"`
CanSendVideos bool `json:"can_send_videos"`
CanSendVideoNotes bool `json:"can_send_video_notes"`
CanSendVoiceNotes bool `json:"can_send_voice_notes"`
CanSendPolls bool `json:"can_send_polls"`
CanSendOtherMessages bool `json:"can_send_other_messages"`
CanAddWebPagePreview bool `json:"can_add_web_page_preview"`
CanAddWebPagePreview bool `json:"can_add_web_page_previews"`
CanEditTag bool `json:"can_edit_tag"`
CanChangeInfo bool `json:"can_change_info"`
CanInviteUsers bool `json:"can_invite_users"`
@@ -143,12 +148,18 @@ type ChatInviteLink struct {
type ChatMemberStatusType string
const (
ChatMemberStatusOwner ChatMemberStatusType = "owner"
// ChatMemberStatusOwner identifies a chat owner.
ChatMemberStatusOwner ChatMemberStatusType = "owner"
// ChatMemberStatusAdministrator identifies a chat administrator.
ChatMemberStatusAdministrator ChatMemberStatusType = "administrator"
ChatMemberStatusMember ChatMemberStatusType = "member"
ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
ChatMemberStatusLeft ChatMemberStatusType = "left"
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
// 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"
)
// ChatMember contains information about one member of a chat.
@@ -190,12 +201,13 @@ type ChatMember struct {
CanSendMessages *bool `json:"can_send_messages,omitempty"`
CanSendAudios *bool `json:"can_send_audios,omitempty"`
CanSendDocuments *bool `json:"can_send_documents,omitempty"`
CanSendPhotos *bool `json:"can_send_photos,omitempty"`
CanSendVideos *bool `json:"can_send_videos,omitempty"`
CanSendVideoNotes *bool `json:"can_send_video_notes,omitempty"`
CanSendVoiceNotes *bool `json:"can_send_voice_notes,omitempty"`
CanSendPolls *bool `json:"can_send_polls,omitempty"`
CanSendOtherMessages *bool `json:"can_send_other_messages,omitempty"`
CanAddWebPagePreview *bool `json:"can_add_web_page_preview,omitempty"`
CanAddWebPagePreview *bool `json:"can_add_web_page_previews,omitempty"`
CanEditTag *bool `json:"can_edit_tag,omitempty"`
}
@@ -214,7 +226,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"`
@@ -225,6 +237,25 @@ type ChatBoost struct {
type UserChatBoosts struct {
Boosts []ChatBoost `json:"boosts"`
}
type ChatBoostAdded struct {
BoostCount int `json:"boost_count"`
}
type ChatBackground struct {
Type BackgroundType `json:"type"`
}
// ChatOwnerLeft describes a service message about a chat owner leaving.
// See https://core.telegram.org/bots/api#chatownerleft
type ChatOwnerLeft struct {
NewOwner *User `json:"new_owner,omitempty"`
}
// ChatOwnerChanged describes a service message about a chat owner change.
// See https://core.telegram.org/bots/api#chatownerchanged
type ChatOwnerChanged struct {
NewOwner User `json:"new_owner"`
}
// ChatAdministratorRights represents the rights of an administrator in a chat.
// See https://core.telegram.org/bots/api#chatadministratorrights
+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")
+35 -35
View File
@@ -2,8 +2,8 @@ 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 {
// BaseForumTopic contains common fields for forum topic operations that require a chat ID and a message thread ID.
type BaseForumTopic struct {
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id"`
}
@@ -23,9 +23,9 @@ func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sti
return req.DoWithContext(ctx, api)
}
// CreateForumTopicP holds parameters for the createForumTopic method.
// CreateForumTopic holds parameters for the createForumTopic method.
// See https://core.telegram.org/bots/api#createforumtopic
type CreateForumTopicP struct {
type CreateForumTopic struct {
ChatID int64 `json:"chat_id"`
Name string `json:"name"`
IconColor ForumTopicIconColor `json:"icon_color"`
@@ -35,7 +35,7 @@ type CreateForumTopicP struct {
// CreateForumTopic creates a topic in a forum supergroup.
// Returns the created ForumTopic on success.
// See https://core.telegram.org/bots/api#createforumtopic
func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) {
func (api *API) CreateForumTopic(params CreateForumTopic) (ForumTopic, error) {
req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -43,15 +43,15 @@ func (api *API) CreateForumTopic(params CreateForumTopicP) (ForumTopic, error) {
// 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) {
func (api *API) CreateForumTopicWithContext(ctx context.Context, params CreateForumTopic) (ForumTopic, error) {
req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditForumTopicP holds parameters for the editForumTopic method.
// EditForumTopic holds parameters for the editForumTopic method.
// See https://core.telegram.org/bots/api#editforumtopic
type EditForumTopicP struct {
BaseForumTopicP
type EditForumTopic struct {
BaseForumTopic
Name string `json:"name"`
IconCustomEmojiID string `json:"icon_custom_emoji_id"`
}
@@ -59,7 +59,7 @@ type EditForumTopicP struct {
// EditForumTopic edits name and icon of a forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#editforumtopic
func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) {
func (api *API) EditForumTopic(params EditForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -67,7 +67,7 @@ func (api *API) EditForumTopic(params EditForumTopicP) (bool, error) {
// 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) {
func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -75,7 +75,7 @@ func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumT
// CloseForumTopic closes an open forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#closeforumtopic
func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) {
func (api *API) CloseForumTopic(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -83,7 +83,7 @@ func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) {
// 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) {
func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -91,7 +91,7 @@ func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForum
// ReopenForumTopic reopens a closed forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#reopenforumtopic
func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) {
func (api *API) ReopenForumTopic(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -99,7 +99,7 @@ func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) {
// 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) {
func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -107,7 +107,7 @@ func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForu
// DeleteForumTopic deletes a forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#deleteforumtopic
func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) {
func (api *API) DeleteForumTopic(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -115,7 +115,7 @@ func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) {
// 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) {
func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -123,7 +123,7 @@ func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForu
// UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#unpinallforumtopicmessages
func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) {
func (api *API) UnpinAllForumTopicMessages(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID)
return req.Do(api)
}
@@ -131,19 +131,19 @@ func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error)
// 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) {
func (api *API) UnpinAllForumTopicMessagesWithContext(ctx context.Context, params BaseForumTopic) (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 {
// BaseGeneralForumTopic contains common fields for general forum topic operations that require a chat ID.
type BaseGeneralForumTopic struct {
ChatID int64 `json:"chat_id"`
}
// EditGeneralForumTopicP holds parameters for the editGeneralForumTopic method.
// EditGeneralForumTopic holds parameters for the editGeneralForumTopic method.
// See https://core.telegram.org/bots/api#editgeneralforumtopic
type EditGeneralForumTopicP struct {
type EditGeneralForumTopic struct {
ChatID int64 `json:"chat_id"`
Name string `json:"name"`
}
@@ -151,7 +151,7 @@ type EditGeneralForumTopicP struct {
// EditGeneralForumTopic edits the name of the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#editgeneralforumtopic
func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, error) {
func (api *API) EditGeneralForumTopic(params EditGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -159,7 +159,7 @@ func (api *API) EditGeneralForumTopic(params EditGeneralForumTopicP) (bool, erro
// 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) {
func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -167,7 +167,7 @@ func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params Edi
// CloseGeneralForumTopic closes the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#closegeneralforumtopic
func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) {
func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -175,7 +175,7 @@ func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, err
// 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) {
func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -183,7 +183,7 @@ func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params Ba
// ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic
func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) {
func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -191,7 +191,7 @@ func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
// 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) {
func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -199,7 +199,7 @@ func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params B
// HideGeneralForumTopic hides the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic
func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) {
func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -207,7 +207,7 @@ func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, erro
// 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) {
func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -215,7 +215,7 @@ func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params Bas
// UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup.
// Returns True on success.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic
func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) {
func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID)
return req.Do(api)
}
@@ -223,7 +223,7 @@ func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, er
// 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) {
func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
@@ -231,7 +231,7 @@ func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params B
// UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic.
// Returns True on success.
// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages
func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) (bool, error) {
func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID)
return req.Do(api)
}
@@ -239,7 +239,7 @@ func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP)
// 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) {
func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+16
View File
@@ -19,3 +19,19 @@ const (
// ForumTopicIconColorBlue is the blue color for forum topic icons (value 7322096).
ForumTopicIconColorBlue ForumTopicIconColor = 7322096
)
type ForumTopicCreated struct {
Name string `json:"name"`
IconColor int `json:"icon_color"`
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
IsNameImplicit bool `json:"is_name_implicit,omitempty"`
}
type ForumTopicEdited struct {
Name string `json:"name,omitempty"`
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
}
type ForumTopicClosed struct{}
type ForumTopicReopened struct{}
type GeneralForumTopicHidden struct{}
type GeneralForumTopicUnhidden struct {
}
+12 -12
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// SendGameP holds parameters for the sendGame method.
// SendGame holds parameters for the sendGame method.
// See https://core.telegram.org/bots/api#sendgame
type SendGameP struct {
type SendGame struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -21,7 +21,7 @@ type SendGameP struct {
// SendGame sends a game message.
// See https://core.telegram.org/bots/api#sendgame
func (api *API) SendGame(params SendGameP) (Message, error) {
func (api *API) SendGame(params SendGame) (Message, error) {
req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID)
return req.Do(api)
}
@@ -29,14 +29,14 @@ func (api *API) SendGame(params SendGameP) (Message, error) {
// 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) {
func (api *API) SendGameWithContext(ctx context.Context, params SendGame) (Message, error) {
req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetGameScoreP holds parameters for the setGameScore method.
// SetGameScore holds parameters for the setGameScore method.
// See https://core.telegram.org/bots/api#setgamescore
type SetGameScoreP struct {
type SetGameScore struct {
UserID int64 `json:"user_id"`
Score int `json:"score"`
Force bool `json:"force,omitempty"`
@@ -50,7 +50,7 @@ type SetGameScoreP struct {
// If inline_message_id is provided, returns a boolean success flag.
// Otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#setgamescore
func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) {
func (api *API) SetGameScore(params SetGameScore) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID)
@@ -65,7 +65,7 @@ func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) {
// 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) {
func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScore) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID)
@@ -77,9 +77,9 @@ func (api *API) SetGameScoreWithContext(ctx context.Context, params SetGameScore
return res, false, err
}
// GetGameHighScoresP holds parameters for the getGameHighScores method.
// GetGameHighScores holds parameters for the getGameHighScores method.
// See https://core.telegram.org/bots/api#getgamehighscores
type GetGameHighScoresP struct {
type GetGameHighScores struct {
UserID int64 `json:"user_id"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
@@ -88,7 +88,7 @@ type GetGameHighScoresP struct {
// GetGameHighScores returns game high score data for a user.
// See https://core.telegram.org/bots/api#getgamehighscores
func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, error) {
func (api *API) GetGameHighScores(params GetGameHighScores) ([]GameHighScore, error) {
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.Do(api)
}
@@ -96,7 +96,7 @@ func (api *API) GetGameHighScores(params GetGameHighScoresP) ([]GameHighScore, e
// 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) {
func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScores) ([]GameHighScore, error) {
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+10
View File
@@ -1,5 +1,15 @@
package tgapi
type Game struct {
Title string `json:"title"`
Description string `json:"description"`
Photo []PhotoSize `json:"photo"`
Text string `json:"text,omitempty"`
TextEntities []MessageEntity `json:"text_entities,omitempty"`
Animation *Animation `json:"animation,omitempty"`
}
type CallbackGame struct{}
// GameHighScore represents one row in a game high score table.
// See https://core.telegram.org/bots/api#gamehighscore
type GameHighScore struct {
+34 -12
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// AnswerInlineQueryP holds parameters for the answerInlineQuery method.
// AnswerInlineQuery holds parameters for the answerInlineQuery method.
// See https://core.telegram.org/bots/api#answerinlinequery
type AnswerInlineQueryP struct {
type AnswerInlineQuery struct {
InlineQueryID string `json:"inline_query_id"`
Results []InlineQueryResult `json:"results"`
CacheTime int `json:"cache_time,omitempty"`
@@ -16,7 +16,7 @@ type AnswerInlineQueryP struct {
// AnswerInlineQuery sends answers to an inline query.
// Returns true on success.
// See https://core.telegram.org/bots/api#answerinlinequery
func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) {
func (api *API) AnswerInlineQuery(params AnswerInlineQuery) (bool, error) {
req := NewRequest[bool]("answerInlineQuery", params)
return req.Do(api)
}
@@ -24,21 +24,21 @@ func (api *API) AnswerInlineQuery(params AnswerInlineQueryP) (bool, error) {
// 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) {
func (api *API) AnswerInlineQueryWithContext(ctx context.Context, params AnswerInlineQuery) (bool, error) {
req := NewRequest[bool]("answerInlineQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerWebAppQueryP holds parameters for the answerWebAppQuery method.
// AnswerWebAppQuery holds parameters for the answerWebAppQuery method.
// See https://core.telegram.org/bots/api#answerwebappquery
type AnswerWebAppQueryP struct {
type AnswerWebAppQuery struct {
WebAppQueryID string `json:"web_app_query_id"`
Result InlineQueryResult `json:"result"`
}
// AnswerWebAppQuery sets the result of a Web App interaction.
// See https://core.telegram.org/bots/api#answerwebappquery
func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage, error) {
func (api *API) AnswerWebAppQuery(params AnswerWebAppQuery) (SentWebAppMessage, error) {
req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params)
return req.Do(api)
}
@@ -46,14 +46,14 @@ func (api *API) AnswerWebAppQuery(params AnswerWebAppQueryP) (SentWebAppMessage,
// 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) {
func (api *API) AnswerWebAppQueryWithContext(ctx context.Context, params AnswerWebAppQuery) (SentWebAppMessage, error) {
req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params)
return req.DoWithContext(ctx, api)
}
// SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method.
// SavePreparedInlineMessage holds parameters for the savePreparedInlineMessage method.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage
type SavePreparedInlineMessageP struct {
type SavePreparedInlineMessage struct {
UserID int64 `json:"user_id"`
Result InlineQueryResult `json:"result"`
AllowUserChats bool `json:"allow_user_chats,omitempty"`
@@ -64,7 +64,7 @@ type SavePreparedInlineMessageP struct {
// SavePreparedInlineMessage stores a prepared message for Mini App users.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage
func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (PreparedInlineMessage, error) {
func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessage) (PreparedInlineMessage, error) {
req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
return req.Do(api)
}
@@ -72,7 +72,29 @@ func (api *API) SavePreparedInlineMessage(params SavePreparedInlineMessageP) (Pr
// 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) {
func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params SavePreparedInlineMessage) (PreparedInlineMessage, error) {
req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
return req.DoWithContext(ctx, api)
}
// SavePreparedKeyboardButton holds parameters for the savePreparedKeyboardButton method.
// See https://core.telegram.org/bots/api#savepreparedkeyboardbutton
type SavePreparedKeyboardButton struct {
UserID int64 `json:"user_id"`
Button KeyboardButton `json:"button"`
}
// SavePreparedKeyboardButton stores a prepared keyboard button for Mini App users.
// See https://core.telegram.org/bots/api#savepreparedkeyboardbutton
func (api *API) SavePreparedKeyboardButton(params SavePreparedKeyboardButton) (PreparedKeyboardButton, error) {
req := NewRequest[PreparedKeyboardButton]("savePreparedKeyboardButton", params)
return req.Do(api)
}
// SavePreparedKeyboardButtonWithContext is the context-aware variant of SavePreparedKeyboardButton.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#savepreparedkeyboardbutton
func (api *API) SavePreparedKeyboardButtonWithContext(ctx context.Context, params SavePreparedKeyboardButton) (PreparedKeyboardButton, error) {
req := NewRequest[PreparedKeyboardButton]("savePreparedKeyboardButton", params)
return req.DoWithContext(ctx, api)
}
+6
View File
@@ -24,3 +24,9 @@ type PreparedInlineMessage struct {
ID string `json:"id"`
ExpirationDate int `json:"expiration_date"`
}
// PreparedKeyboardButton describes a prepared keyboard button.
// See https://core.telegram.org/bots/api#preparedkeyboardbutton
type PreparedKeyboardButton struct {
ID string `json:"id"`
}
+132 -124
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// SendMessageP holds parameters for the sendMessage method.
// SendMessage holds parameters for the sendMessage method.
// See https://core.telegram.org/bots/api#sendmessage
type SendMessageP struct {
type SendMessage struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -26,22 +26,22 @@ type SendMessageP struct {
// SendMessage sends a text message.
// See https://core.telegram.org/bots/api#sendmessage
func (api *API) SendMessage(params SendMessageP) (Message, error) {
req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID)
func (api *API) SendMessage(params SendMessage) (Message, error) {
req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID)
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)
func (api *API) SendMessageWithContext(ctx context.Context, params SendMessage) (Message, error) {
req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessageP holds parameters for the forwardMessage method.
// ForwardMessage holds parameters for the forwardMessage method.
// See https://core.telegram.org/bots/api#forwardmessage
type ForwardMessageP struct {
type ForwardMessage struct {
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -58,7 +58,7 @@ type ForwardMessageP struct {
// ForwardMessage forwards a message.
// See https://core.telegram.org/bots/api#forwardmessage
func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) {
func (api *API) ForwardMessage(params ForwardMessage) (Message, error) {
req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID)
return req.Do(api)
}
@@ -66,14 +66,14 @@ func (api *API) ForwardMessage(params ForwardMessageP) (Message, error) {
// 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) {
func (api *API) ForwardMessageWithContext(ctx context.Context, params ForwardMessage) (Message, error) {
req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessagesP holds parameters for the forwardMessages method.
// ForwardMessages holds parameters for the forwardMessages method.
// See https://core.telegram.org/bots/api#forwardmessages
type ForwardMessagesP struct {
type ForwardMessages struct {
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -87,7 +87,7 @@ type ForwardMessagesP struct {
// ForwardMessages forwards multiple messages.
// Returns an array of message IDs of the sent messages.
// See https://core.telegram.org/bots/api#forwardmessages
func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) {
func (api *API) ForwardMessages(params ForwardMessages) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID)
return req.Do(api)
}
@@ -95,14 +95,14 @@ func (api *API) ForwardMessages(params ForwardMessagesP) ([]MessageID, error) {
// 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) {
func (api *API) ForwardMessagesWithContext(ctx context.Context, params ForwardMessages) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CopyMessageP holds parameters for the copyMessage method.
// CopyMessage holds parameters for the copyMessage method.
// See https://core.telegram.org/bots/api#copymessage
type CopyMessageP struct {
type CopyMessage struct {
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -128,7 +128,7 @@ type CopyMessageP struct {
// CopyMessage copies a message.
// Returns the MessageID of the sent copy.
// See https://core.telegram.org/bots/api#copymessage
func (api *API) CopyMessage(params CopyMessageP) (int, error) {
func (api *API) CopyMessage(params CopyMessage) (int, error) {
msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).Do(api)
if err != nil {
return 0, err
@@ -139,7 +139,7 @@ func (api *API) CopyMessage(params CopyMessageP) (int, error) {
// 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) {
func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessage) (int, error) {
msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).DoWithContext(ctx, api)
if err != nil {
return 0, err
@@ -147,9 +147,9 @@ func (api *API) CopyMessageWithContext(ctx context.Context, params CopyMessageP)
return msgID.MessageID, nil
}
// CopyMessagesP holds parameters for the copyMessages method.
// CopyMessages holds parameters for the copyMessages method.
// See https://core.telegram.org/bots/api#copymessages
type CopyMessagesP struct {
type CopyMessages struct {
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -164,7 +164,7 @@ type CopyMessagesP struct {
// CopyMessages copies multiple messages.
// Returns an array of message IDs of the sent copies.
// See https://core.telegram.org/bots/api#copymessages
func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) {
func (api *API) CopyMessages(params CopyMessages) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID)
return req.Do(api)
}
@@ -172,14 +172,14 @@ func (api *API) CopyMessages(params CopyMessagesP) ([]MessageID, error) {
// 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) {
func (api *API) CopyMessagesWithContext(ctx context.Context, params CopyMessages) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendLocationP holds parameters for the sendLocation method.
// SendLocation holds parameters for the sendLocation method.
// See https://core.telegram.org/bots/api#sendlocation
type SendLocationP struct {
type SendLocation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -204,7 +204,7 @@ type SendLocationP struct {
// SendLocation sends a point on the map.
// See https://core.telegram.org/bots/api#sendlocation
func (api *API) SendLocation(params SendLocationP) (Message, error) {
func (api *API) SendLocation(params SendLocation) (Message, error) {
req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID)
return req.Do(api)
}
@@ -212,14 +212,14 @@ func (api *API) SendLocation(params SendLocationP) (Message, error) {
// 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) {
func (api *API) SendLocationWithContext(ctx context.Context, params SendLocation) (Message, error) {
req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVenueP holds parameters for the sendVenue method.
// SendVenue holds parameters for the sendVenue method.
// See https://core.telegram.org/bots/api#sendvenue
type SendVenueP struct {
type SendVenue struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -246,7 +246,7 @@ type SendVenueP struct {
// SendVenue sends information about a venue.
// See https://core.telegram.org/bots/api#sendvenue
func (api *API) SendVenue(params SendVenueP) (Message, error) {
func (api *API) SendVenue(params SendVenue) (Message, error) {
req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID)
return req.Do(api)
}
@@ -254,14 +254,14 @@ func (api *API) SendVenue(params SendVenueP) (Message, error) {
// 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) {
func (api *API) SendVenueWithContext(ctx context.Context, params SendVenue) (Message, error) {
req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendContactP holds parameters for the sendContact method.
// SendContact holds parameters for the sendContact method.
// See https://core.telegram.org/bots/api#sendcontact
type SendContactP struct {
type SendContact struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -284,7 +284,7 @@ type SendContactP struct {
// SendContact sends a phone contact.
// See https://core.telegram.org/bots/api#sendcontact
func (api *API) SendContact(params SendContactP) (Message, error) {
func (api *API) SendContact(params SendContact) (Message, error) {
req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID)
return req.Do(api)
}
@@ -292,32 +292,40 @@ func (api *API) SendContact(params SendContactP) (Message, error) {
// 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) {
func (api *API) SendContactWithContext(ctx context.Context, params SendContact) (Message, error) {
req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPollP holds parameters for the sendPoll method.
// SendPoll holds parameters for the sendPoll method.
// See https://core.telegram.org/bots/api#sendpoll
type SendPollP struct {
type SendPoll struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
Question string `json:"question"`
QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"`
QuestionEntities []MessageEntity `json:"question_entities,omitempty"`
Options []InputPollOption `json:"options"`
IsAnonymous bool `json:"is_anonymous,omitempty"`
Type PollType `json:"type"`
AllowsMultipleAnswers bool `json:"allows_multiple_answers,omitempty"`
CorrectOptionID int `json:"correct_option_id,omitempty"`
Explanation string `json:"explanation,omitempty"`
ExplanationParseMode ParseMode `json:"explanation_parse_mode,omitempty"`
ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"`
OpenPeriod int `json:"open_period,omitempty"`
CloseDate int `json:"close_date"`
IsClosed bool `json:"is_closed,omitempty"`
Question string `json:"question"`
QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"`
QuestionEntities []MessageEntity `json:"question_entities,omitempty"`
Options []InputPollOption `json:"options"`
IsAnonymous bool `json:"is_anonymous,omitempty"`
Type PollType `json:"type"`
AllowsMultipleAnswers bool `json:"allows_multiple_answers,omitempty"`
AllowsRevoting bool `json:"allows_revoting,omitempty"`
ShuffleOptions bool `json:"shuffle_options,omitempty"`
AllowAddingOptions bool `json:"allow_adding_options,omitempty"`
HideResultsUntilCloses bool `json:"hide_results_until_closes,omitempty"`
CorrectOptionIDs []int `json:"correct_option_ids,omitempty"`
Explanation string `json:"explanation,omitempty"`
ExplanationParseMode ParseMode `json:"explanation_parse_mode,omitempty"`
ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"`
OpenPeriod int `json:"open_period,omitempty"`
CloseDate int `json:"close_date"`
IsClosed bool `json:"is_closed,omitempty"`
Description string `json:"description"`
DescriptionParseMode ParseMode `json:"description_parse_mode,omitempty"`
DescriptionEntities []MessageEntity `json:"description_entities,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"`
@@ -330,7 +338,7 @@ type SendPollP struct {
// SendPoll sends a native poll.
// See https://core.telegram.org/bots/api#sendpoll
func (api *API) SendPoll(params SendPollP) (Message, error) {
func (api *API) SendPoll(params SendPoll) (Message, error) {
req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID)
return req.Do(api)
}
@@ -338,14 +346,14 @@ func (api *API) SendPoll(params SendPollP) (Message, error) {
// 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) {
func (api *API) SendPollWithContext(ctx context.Context, params SendPoll) (Message, error) {
req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChecklistP holds parameters for the sendChecklist method.
// SendChecklist holds parameters for the sendChecklist method.
// See https://core.telegram.org/bots/api#sendchecklist
type SendChecklistP struct {
type SendChecklist struct {
BusinessConnectionID string `json:"business_connection_id"`
ChatID int64 `json:"chat_id"`
Checklist InputChecklist `json:"checklist"`
@@ -360,7 +368,7 @@ type SendChecklistP struct {
// SendChecklist sends a checklist.
// See https://core.telegram.org/bots/api#sendchecklist
func (api *API) SendChecklist(params SendChecklistP) (Message, error) {
func (api *API) SendChecklist(params SendChecklist) (Message, error) {
req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID)
return req.Do(api)
}
@@ -368,14 +376,14 @@ func (api *API) SendChecklist(params SendChecklistP) (Message, error) {
// 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) {
func (api *API) SendChecklistWithContext(ctx context.Context, params SendChecklist) (Message, error) {
req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDiceP holds parameters for the sendDice method.
// SendDice holds parameters for the sendDice method.
// See https://core.telegram.org/bots/api#senddice
type SendDiceP struct {
type SendDice struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -395,7 +403,7 @@ type SendDiceP struct {
// SendDice sends a dice, which will have a random value.
// See https://core.telegram.org/bots/api#senddice
func (api *API) SendDice(params SendDiceP) (Message, error) {
func (api *API) SendDice(params SendDice) (Message, error) {
req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID)
return req.Do(api)
}
@@ -403,14 +411,14 @@ func (api *API) SendDice(params SendDiceP) (Message, error) {
// 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) {
func (api *API) SendDiceWithContext(ctx context.Context, params SendDice) (Message, error) {
req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMessageDraftP holds parameters for the sendMessageDraft method.
// SendMessageDraft holds parameters for the sendMessageDraft method.
// See https://core.telegram.org/bots/api#sendmessagedraft
type SendMessageDraftP struct {
type SendMessageDraft struct {
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DraftID uint64 `json:"draft_id"`
@@ -422,7 +430,7 @@ type SendMessageDraftP struct {
// SendMessageDraft sends or updates a draft message in the target chat.
// Returns True on success.
// See https://core.telegram.org/bots/api#sendmessagedraft
func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) {
func (api *API) SendMessageDraft(params SendMessageDraft) (bool, error) {
req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID)
return req.Do(api)
}
@@ -430,14 +438,14 @@ func (api *API) SendMessageDraft(params SendMessageDraftP) (bool, error) {
// 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) {
func (api *API) SendMessageDraftWithContext(ctx context.Context, params SendMessageDraft) (bool, error) {
req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChatActionP holds parameters for the sendChatAction method.
// SendChatAction holds parameters for the sendChatAction method.
// See https://core.telegram.org/bots/api#sendchataction
type SendChatActionP struct {
type SendChatAction struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -447,7 +455,7 @@ type SendChatActionP struct {
// SendChatAction sends a chat action (typing, uploading photo, etc.).
// Returns True on success.
// See https://core.telegram.org/bots/api#sendchataction
func (api *API) SendChatAction(params SendChatActionP) (bool, error) {
func (api *API) SendChatAction(params SendChatAction) (bool, error) {
req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID)
return req.Do(api)
}
@@ -455,14 +463,14 @@ func (api *API) SendChatAction(params SendChatActionP) (bool, error) {
// 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) {
func (api *API) SendChatActionWithContext(ctx context.Context, params SendChatAction) (bool, error) {
req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetMessageReactionP holds parameters for the setMessageReaction method.
// SetMessageReaction holds parameters for the setMessageReaction method.
// See https://core.telegram.org/bots/api#setmessagereaction
type SetMessageReactionP struct {
type SetMessageReaction struct {
ChatID int64 `json:"chat_id"`
MessageId int `json:"message_id"`
Reaction []ReactionType `json:"reaction"`
@@ -472,7 +480,7 @@ type SetMessageReactionP struct {
// SetMessageReaction changes the chosen reaction on a message.
// Returns True on success.
// See https://core.telegram.org/bots/api#setmessagereaction
func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) {
func (api *API) SetMessageReaction(params SetMessageReaction) (bool, error) {
req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID)
return req.Do(api)
}
@@ -480,14 +488,14 @@ func (api *API) SetMessageReaction(params SetMessageReactionP) (bool, error) {
// 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) {
func (api *API) SetMessageReactionWithContext(ctx context.Context, params SetMessageReaction) (bool, error) {
req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageTextP holds parameters for the editMessageText method.
// EditMessageText holds parameters for the editMessageText method.
// See https://core.telegram.org/bots/api#editmessagetext
type EditMessageTextP struct {
type EditMessageText struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
@@ -503,7 +511,7 @@ type EditMessageTextP struct {
// If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagetext
func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error) {
func (api *API) EditMessageText(params EditMessageText) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID)
@@ -518,7 +526,7 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error)
// 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) {
func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessageText) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID)
@@ -530,9 +538,9 @@ func (api *API) EditMessageTextWithContext(ctx context.Context, params EditMessa
return res, false, err
}
// EditMessageCaptionP holds parameters for the editMessageCaption method.
// EditMessageCaption holds parameters for the editMessageCaption method.
// See https://core.telegram.org/bots/api#editmessagecaption
type EditMessageCaptionP struct {
type EditMessageCaption struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
@@ -548,7 +556,7 @@ type EditMessageCaptionP struct {
// If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagecaption
func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, error) {
func (api *API) EditMessageCaption(params EditMessageCaption) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID)
@@ -563,7 +571,7 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e
// 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) {
func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMessageCaption) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID)
@@ -575,9 +583,9 @@ func (api *API) EditMessageCaptionWithContext(ctx context.Context, params EditMe
return res, false, err
}
// EditMessageMediaP holds parameters for the editMessageMedia method.
// EditMessageMedia holds parameters for the editMessageMedia method.
// See https://core.telegram.org/bots/api#editmessagemedia
type EditMessageMediaP struct {
type EditMessageMedia struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
@@ -590,7 +598,7 @@ type EditMessageMediaP struct {
// If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagemedia
func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error) {
func (api *API) EditMessageMedia(params EditMessageMedia) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID)
@@ -605,7 +613,7 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error
// 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) {
func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMessageMedia) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID)
@@ -617,9 +625,9 @@ func (api *API) EditMessageMediaWithContext(ctx context.Context, params EditMess
return res, false, err
}
// EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method.
// EditMessageLiveLocation holds parameters for the editMessageLiveLocation method.
// See https://core.telegram.org/bots/api#editmessagelivelocation
type EditMessageLiveLocationP struct {
type EditMessageLiveLocation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
@@ -638,7 +646,7 @@ type EditMessageLiveLocationP struct {
// If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagelivelocation
func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Message, bool, error) {
func (api *API) EditMessageLiveLocation(params EditMessageLiveLocation) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID)
@@ -653,7 +661,7 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag
// 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) {
func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params EditMessageLiveLocation) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID)
@@ -665,9 +673,9 @@ func (api *API) EditMessageLiveLocationWithContext(ctx context.Context, params E
return res, false, err
}
// StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method.
// StopMessageLiveLocation holds parameters for the stopMessageLiveLocation method.
// See https://core.telegram.org/bots/api#stopmessagelivelocation
type StopMessageLiveLocationP struct {
type StopMessageLiveLocation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
@@ -679,7 +687,7 @@ type StopMessageLiveLocationP struct {
// If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#stopmessagelivelocation
func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Message, bool, error) {
func (api *API) StopMessageLiveLocation(params StopMessageLiveLocation) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID)
@@ -694,7 +702,7 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag
// 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) {
func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params StopMessageLiveLocation) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID)
@@ -706,8 +714,8 @@ func (api *API) StopMessageLiveLocationWithContext(ctx context.Context, params S
return res, false, err
}
// EditMessageChecklistP holds parameters for the editMessageChecklist method.
type EditMessageChecklistP struct {
// EditMessageChecklist holds parameters for the editMessageChecklist method.
type EditMessageChecklist struct {
BusinessConnectionID string `json:"business_connection_id"`
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
@@ -717,7 +725,7 @@ type EditMessageChecklistP struct {
// EditMessageChecklist edits a checklist message.
// See https://core.telegram.org/bots/api#editmessagechecklist
func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, error) {
func (api *API) EditMessageChecklist(params EditMessageChecklist) (Message, error) {
req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID)
return req.Do(api)
}
@@ -725,14 +733,14 @@ func (api *API) EditMessageChecklist(params EditMessageChecklistP) (Message, err
// 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) {
func (api *API) EditMessageChecklistWithContext(ctx context.Context, params EditMessageChecklist) (Message, error) {
req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method.
// EditMessageReplyMarkup holds parameters for the editMessageReplyMarkup method.
// See https://core.telegram.org/bots/api#editmessagereplymarkup
type EditMessageReplyMarkupP struct {
type EditMessageReplyMarkup struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"`
@@ -744,7 +752,7 @@ type EditMessageReplyMarkupP struct {
// If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagereplymarkup
func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message, bool, error) {
func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkup) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID)
@@ -759,7 +767,7 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message,
// 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) {
func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params EditMessageReplyMarkup) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID)
@@ -771,9 +779,9 @@ func (api *API) EditMessageReplyMarkupWithContext(ctx context.Context, params Ed
return res, false, err
}
// StopPollP holds parameters for the stopPoll method.
// StopPoll holds parameters for the stopPoll method.
// See https://core.telegram.org/bots/api#stoppoll
type StopPollP struct {
type StopPoll struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
@@ -783,7 +791,7 @@ type StopPollP struct {
// StopPoll stops a poll that was sent by the bot.
// Returns the stopped Poll.
// See https://core.telegram.org/bots/api#stoppoll
func (api *API) StopPoll(params StopPollP) (Poll, error) {
func (api *API) StopPoll(params StopPoll) (Poll, error) {
req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID)
return req.Do(api)
}
@@ -791,14 +799,14 @@ func (api *API) StopPoll(params StopPollP) (Poll, error) {
// 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) {
func (api *API) StopPollWithContext(ctx context.Context, params StopPoll) (Poll, error) {
req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveSuggestedPostP holds parameters for the approveSuggestedPost method.
// ApproveSuggestedPost holds parameters for the approveSuggestedPost method.
// See https://core.telegram.org/bots/api#approvesuggestedpost
type ApproveSuggestedPostP struct {
type ApproveSuggestedPost struct {
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
SendDate int `json:"send_date,omitempty"`
@@ -807,7 +815,7 @@ type ApproveSuggestedPostP struct {
// ApproveSuggestedPost approves a suggested channel post.
// Returns True on success.
// See https://core.telegram.org/bots/api#approvesuggestedpost
func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error) {
func (api *API) ApproveSuggestedPost(params ApproveSuggestedPost) (bool, error) {
req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID)
return req.Do(api)
}
@@ -815,14 +823,14 @@ func (api *API) ApproveSuggestedPost(params ApproveSuggestedPostP) (bool, error)
// 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) {
func (api *API) ApproveSuggestedPostWithContext(ctx context.Context, params ApproveSuggestedPost) (bool, error) {
req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineSuggestedPostP holds parameters for the declineSuggestedPost method.
// DeclineSuggestedPost holds parameters for the declineSuggestedPost method.
// See https://core.telegram.org/bots/api#declinesuggestedpost
type DeclineSuggestedPostP struct {
type DeclineSuggestedPost struct {
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
Comment string `json:"comment,omitempty"`
@@ -831,7 +839,7 @@ type DeclineSuggestedPostP struct {
// DeclineSuggestedPost declines a suggested channel post.
// Returns True on success.
// See https://core.telegram.org/bots/api#declinesuggestedpost
func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error) {
func (api *API) DeclineSuggestedPost(params DeclineSuggestedPost) (bool, error) {
req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID)
return req.Do(api)
}
@@ -839,14 +847,14 @@ func (api *API) DeclineSuggestedPost(params DeclineSuggestedPostP) (bool, error)
// 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) {
func (api *API) DeclineSuggestedPostWithContext(ctx context.Context, params DeclineSuggestedPost) (bool, error) {
req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessageP holds parameters for the deleteMessage method.
// DeleteMessage holds parameters for the deleteMessage method.
// See https://core.telegram.org/bots/api#deletemessage
type DeleteMessageP struct {
type DeleteMessage struct {
ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"`
}
@@ -854,7 +862,7 @@ type DeleteMessageP struct {
// DeleteMessage deletes a message.
// Returns True on success.
// See https://core.telegram.org/bots/api#deletemessage
func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) {
func (api *API) DeleteMessage(params DeleteMessage) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID)
return req.Do(api)
}
@@ -862,14 +870,14 @@ func (api *API) DeleteMessage(params DeleteMessageP) (bool, error) {
// 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) {
func (api *API) DeleteMessageWithContext(ctx context.Context, params DeleteMessage) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessagesP holds parameters for the deleteMessages method.
// DeleteMessages holds parameters for the deleteMessages method.
// See https://core.telegram.org/bots/api#deletemessages
type DeleteMessagesP struct {
type DeleteMessages struct {
ChatID int64 `json:"chat_id"`
MessageIDs []int `json:"message_ids"`
}
@@ -877,7 +885,7 @@ type DeleteMessagesP struct {
// DeleteMessages deletes multiple messages at once.
// Returns True on success.
// See https://core.telegram.org/bots/api#deletemessages
func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) {
func (api *API) DeleteMessages(params DeleteMessages) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID)
return req.Do(api)
}
@@ -885,14 +893,14 @@ func (api *API) DeleteMessages(params DeleteMessagesP) (bool, error) {
// 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) {
func (api *API) DeleteMessagesWithContext(ctx context.Context, params DeleteMessages) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// AnswerCallbackQueryP holds parameters for the answerCallbackQuery method.
// AnswerCallbackQuery holds parameters for the answerCallbackQuery method.
// See https://core.telegram.org/bots/api#answercallbackquery
type AnswerCallbackQueryP struct {
type AnswerCallbackQuery struct {
CallbackQueryID string `json:"callback_query_id"`
Text string `json:"text,omitempty"`
ShowAlert bool `json:"show_alert,omitempty"`
@@ -903,7 +911,7 @@ type AnswerCallbackQueryP struct {
// AnswerCallbackQuery sends answers to callback queries sent from inline keyboards.
// Returns True on success.
// See https://core.telegram.org/bots/api#answercallbackquery
func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) {
func (api *API) AnswerCallbackQuery(params AnswerCallbackQuery) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
return req.Do(api)
}
@@ -911,7 +919,7 @@ func (api *API) AnswerCallbackQuery(params AnswerCallbackQueryP) (bool, error) {
// 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) {
func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQuery) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
return req.DoWithContext(ctx, api)
}
+419 -113
View File
@@ -1,64 +1,222 @@
package tgapi
import "git.nix13.pw/scuroneko/extypes"
import (
"encoding/json"
"git.scuroneko.dev/scuroneko/extypes"
)
// MessageID represents a message identifier wrapper returned by some API methods.
type MessageID struct {
MessageID int `json:"message_id"`
}
// MessageReplyMarkup represents an inline keyboard markup for a message.
// It is used in the Message type.
type MessageReplyMarkup struct {
InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"`
}
// DirectMessageTopic represents a forum topic in a direct message.
type DirectMessageTopic struct {
TopicID int64 `json:"topic_id"`
User *User `json:"user,omitempty"`
}
type MessageOriginType string
const (
MessageOriginUserType = "user"
MessageOriginHiddenUserType = "hidden_user"
MessageOriginChatType = "chat"
MessageOriginChannel = "channel"
)
type MessageOrigin struct {
Type MessageOriginType `json:"type"`
Date int64 `json:"date"`
SenderUser *User `json:"sender_user,omitempty"`
SenderUserName string `json:"sender_user_name,omitempty"`
SenderChat *Chat `json:"sender_chat,omitempty"`
Chat *Chat `json:"chat,omitempty"`
MessageID int `json:"message_id"`
AuthorSignature string `json:"author_signature,omitempty"`
}
type ExternalReplyInfo struct {
Origin MessageOrigin `json:"origin"`
Chat *Chat `json:"chat,omitempty"`
MessageID int `json:"message_id,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
Animation *Animation `json:"animation,omitempty"`
Audio *Audio `json:"audio,omitempty"`
Document *Document `json:"document,omitempty"`
PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"`
Photo []PhotoSize `json:"photo,omitempty"`
Sticker *Sticker `json:"sticker,omitempty"`
Story *Story `json:"story,omitempty"`
Video *Video `json:"video,omitempty"`
VideoNote *VideoNote `json:"video_note,omitempty"`
Voice *Voice `json:"voice,omitempty"`
HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"`
Checklist *Checklist `json:"checklist,omitempty"`
Contact *Contact `json:"contact,omitempty"`
Dice *Dice `json:"dice,omitempty"`
Game *Game `json:"game,omitempty"`
Giveaway *Giveaway `json:"giveaway,omitempty"`
GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"`
Invoice *Invoice `json:"invoice,omitempty"`
Location *Location `json:"location,omitempty"`
Poll *Poll `json:"poll,omitempty"`
Venue *Venue `json:"venue,omitempty"`
}
type TextQuote struct {
Text string `json:"text"`
Entities []MessageEntity `json:"entities"`
Position int `json:"position"`
IsManual bool `json:"is_manual,omitempty"`
}
type MessageAutoDeleteTimerChanged struct {
MessageAutoDeleteTime int `json:"message_auto_delete_time"`
}
type DirectMessagePriceChanged struct {
AreDirectMessagesEnabled bool `json:"are_direct_messages_enabled"`
DirectMessageStarCount int `json:"direct_message_star_count,omitempty"`
}
type PaidMessagePriceChanged struct {
PaidMessageStarCount int `json:"paid_message_star_count"`
}
// Message represents a Telegram message.
// See https://core.telegram.org/bots/api#message
type Message struct {
MessageID int `json:"message_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"`
BusinessConnectionId string `json:"business_connection_id,omitempty"`
From *User `json:"from,omitempty"`
MessageID int `json:"message_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"`
From *User `json:"from,omitempty"`
SenderChat *Chat `json:"sender_chat,omitempty"`
SenderBoostCount int `json:"sender_boost_count,omitempty"`
SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
SenderTag string `json:"sender_tag,omitempty"`
Chat *Chat `json:"chat,omitempty"`
SenderChat *Chat `json:"sender_chat,omitempty"`
SenderBoostCount int `json:"sender_boost_count,omitempty"`
SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
SenderTag string `json:"sender_tag,omitempty"`
Date int `json:"date"`
BusinessConnectionId string `json:"business_connection_id,omitempty"`
Chat *Chat `json:"chat,omitempty"`
ForwardOrigin *MessageOrigin `json:"forward_origin,omitempty"`
IsTopicMessage bool `json:"is_topic_message,omitempty"`
IsAutomaticForward bool `json:"is_automatic_forward,omitempty"`
IsFromOffline bool `json:"is_from_offline,omitempty"`
IsPaidPost bool `json:"is_paid_post,omitempty"`
MediaGroupId string `json:"media_group_id,omitempty"`
AuthorSignature string `json:"author_signature,omitempty"`
PaidStarCount int `json:"paid_star_count,omitempty"`
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
IsTopicMessage bool `json:"is_topic_message,omitempty"`
IsAutomaticForward bool `json:"is_automatic_forward,omitempty"`
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
ExternalReply *ExternalReplyInfo `json:"external_reply,omitempty"`
Quote *TextQuote `json:"quote,omitempty"`
Text string `json:"text"`
Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"`
Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
Date int `json:"date"`
EditDate int `json:"edit_date"`
ReplyMarkup *MessageReplyMarkup `json:"reply_markup,omitempty"`
ReplyToStory *Story `json:"reply_to_story,omitempty"`
ReplyToChecklistTaskID int `json:"reply_to_checklist_task_id,omitempty"`
ReplyToPollOptionID string `json:"reply_to_poll_option_id,omitempty"`
ViaBot *User `json:"via_bot,omitempty"`
EditDate int `json:"edit_date,omitempty"`
HasProtectedContent bool `json:"has_protected_content,omitempty"`
IsFromOffline bool `json:"is_from_offline,omitempty"`
IsPaidPost bool `json:"is_paid_post,omitempty"`
MediaGroupId string `json:"media_group_id,omitempty"`
AuthorSignature string `json:"author_signature,omitempty"`
PaidStarCount int `json:"paid_star_count,omitempty"`
Text string `json:"text"`
Entities []MessageEntity `json:"entities,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,omitempty"`
EffectID string `json:"effect_id,omitempty"`
EffectID string `json:"effect_id,omitempty"`
Animation *Animation `json:"animation,omitempty"`
Audio *Audio `json:"audio,omitempty"`
Document *Document `json:"document,omitempty"`
PaidMedia *PaidMediaInfo `json:"paid_media,omitempty"`
Photo extypes.Slice[PhotoSize] `json:"photo,omitempty"`
Sticker *Sticker `json:"sticker,omitempty"`
Story *Story `json:"story,omitempty"`
Video *Video `json:"video,omitempty"`
VideoNote *VideoNote `json:"video_note,omitempty"`
Voice *Voice `json:"voice,omitempty"`
Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
HasMediaSpoiler bool `json:"has_media_spoiler,omitempty"`
Checklist *Checklist `json:"checklist,omitempty"`
Contact *Contact `json:"contact,omitempty"`
Dice *Dice `json:"dice,omitempty"`
Game *Game `json:"game,omitempty"`
Poll *Poll `json:"poll,omitempty"`
Venue *Venue `json:"venue,omitempty"`
Location *Location `json:"location,omitempty"`
NewChatMembers []User `json:"new_chat_members,omitempty"`
LeftChatMember *User `json:"left_chat_member,omitempty"`
ChatOwnerLeft *ChatOwnerLeft `json:"chat_owner_left,omitempty"`
ChatOwnerChanged *ChatOwnerChanged `json:"chat_owner_changed,omitempty"`
NewChatTitle string `json:"new_chat_title,omitempty"`
NewChatPhoto []PhotoSize `json:"new_chat_photo,omitempty"`
DeleteChatPhoto bool `json:"delete_chat_photo,omitempty"`
GroupChatCreated bool `json:"group_chat_created,omitempty"`
SupergroupChatCreated bool `json:"supergroup_chat_created,omitempty"`
ChannelChatCreated bool `json:"channel_chat_created,omitempty"`
MessageAutoDeleteTimerChanged *MessageAutoDeleteTimerChanged `json:"message_auto_delete_timer_changed,omitempty"`
MigrateToChatID int64 `json:"migrate_to_chat_id,omitempty"`
MigrateFromChatID int64 `json:"migrate_from_chat_id,omitempty"`
PinnedMessage *MaybeInaccessibleMessage `json:"pinned_message,omitempty"`
Invoice *Invoice `json:"invoice,omitempty"`
SuccessfulPayment *SuccessfulPayment `json:"successful_payment,omitempty"`
RefundedPayment *RefundedPayment `json:"refunded_payment,omitempty"`
UsersShared *UsersShared `json:"users_shared,omitempty"`
ChatShared *ChatShared `json:"chat_shared,omitempty"`
Gift *GiftInfo `json:"gift,omitempty"`
UniqueGift *UniqueGiftInfo `json:"unique_gift,omitempty"`
GiftUpgradeSent *GiftInfo `json:"gift_upgrade_sent,omitempty"`
ConnectedWebsite string `json:"connected_website,omitempty"`
WriteAccessAllowed *WriteAccessAllowed `json:"write_access_allowed,omitempty"`
PassportData *PassportData `json:"passport_data,omitempty"`
ProximityAlertTriggered *ProximityAlertTriggered `json:"proximity_alert_triggered,omitempty"`
BoostAdded *ChatBoostAdded `json:"boost_added,omitempty"`
ChatBackgroundSet *ChatBackground `json:"chat_background_set,omitempty"`
ChecklistTaskDone *ChecklistTaskDone `json:"checklist_task_done,omitempty"`
ChecklistTasksAdded *ChecklistTasksAdded `json:"checklist_tasks_added,omitempty"`
DirectMessagePriceChanged *DirectMessagePriceChanged `json:"direct_message_price_changed,omitempty"`
ForumTopicCreated *ForumTopicCreated `json:"forum_topic_created,omitempty"`
ForumTopicEdited *ForumTopicEdited `json:"forum_topic_edited,omitempty"`
ForumTopicClosed *ForumTopicClosed `json:"forum_topic_closed,omitempty"`
ForumTopicReopened *ForumTopicReopened `json:"forum_topic_reopened,omitempty"`
GeneralForumTopicHidden *GeneralForumTopicHidden `json:"general_forum_topic_hidden,omitempty"`
GeneralForumTopicUnhidden *GeneralForumTopicUnhidden `json:"general_forum_topic_unhidden,omitempty"`
GiveawayCreated *GiveawayCreated `json:"giveaway_created,omitempty"`
Giveaway *Giveaway `json:"giveaway,omitempty"`
GiveawayWinners *GiveawayWinners `json:"giveaway_winners,omitempty"`
GiveawayCompleted *GiveawayCompleted `json:"giveaway_completed,omitempty"`
ManagedBotCreated *ManagedBotCreated `json:"managed_bot_created,omitempty"`
PaidMessagePriceChanged *PaidMessagePriceChanged `json:"paid_message_price_changed,omitempty"`
PollOptionAdded *PollOptionAdded `json:"poll_option_added,omitempty"`
PollOptionDeleted *PollOptionDeleted `json:"poll_option_deleted,omitempty"`
SuggestedPostApproved *SuggestedPostApproved `json:"suggested_post_approved,omitempty"`
SuggestedPostApprovalFailed *SuggestedPostApprovalFailed `json:"suggested_post_approval_failed,omitempty"`
SuggestedPostDeclined *SuggestedPostDeclined `json:"suggested_post_declined,omitempty"`
SuggestedPostPaid *SuggestedPostPaid `json:"suggested_post_paid,omitempty"`
SuggestedPostRefunded *SuggestedPostRefunded `json:"suggested_post_refunded,omitempty"`
VideoChatScheduled *VideoChatScheduled `json:"video_chat_scheduled,omitempty"`
VideoChatStarted *VideoChatStarted `json:"video_chat_started,omitempty"`
VideoChatEnded *VideoChatEnded `json:"video_chat_ended,omitempty"`
VideoChatParticipantsInvited *VideoChatParticipantsInvited `json:"video_chat_participants_invited,omitempty"`
WebAppData *WebAppData `json:"web_app_data,omitempty"`
ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
}
// InaccessibleMessage describes a message that was deleted or is otherwise inaccessible.
@@ -71,32 +229,125 @@ type InaccessibleMessage struct {
// MaybeInaccessibleMessage is a union type that can be either Message or InaccessibleMessage.
// See https://core.telegram.org/bots/api#maybeinaccessiblemessage
type MaybeInaccessibleMessage interface{ Message | InaccessibleMessage }
type MaybeInaccessibleMessage struct {
msg *Message
ina *InaccessibleMessage
}
// UnmarshalJSON decodes either an accessible Message or an InaccessibleMessage.
func (m *MaybeInaccessibleMessage) UnmarshalJSON(data []byte) error {
tmp := struct {
Date int `json:"date"`
}{}
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
var err error
if tmp.Date > 0 {
err = json.Unmarshal(data, &m.msg)
} else {
err = json.Unmarshal(data, &m.ina)
}
if err != nil {
return err
}
return nil
}
// MarshalJSON encodes the populated accessible or inaccessible message payload.
func (m *MaybeInaccessibleMessage) MarshalJSON() ([]byte, error) {
if m.msg != nil {
return json.Marshal(m.msg)
} else if m.ina != nil {
return json.Marshal(m.ina)
}
return json.Marshal(nil)
}
// Message returns the accessible message payload when present.
func (m *MaybeInaccessibleMessage) Message() *Message {
return m.msg
}
// InaccessibleMessage returns the inaccessible message payload when present.
func (m *MaybeInaccessibleMessage) InaccessibleMessage() *InaccessibleMessage {
return m.ina
}
// IsAccessible reports whether the payload is an accessible message.
func (m *MaybeInaccessibleMessage) IsAccessible() bool {
return m.msg != nil
}
// IsInaccessible reports whether the payload is an inaccessible message.
func (m *MaybeInaccessibleMessage) IsInaccessible() bool {
return m.ina != nil
}
// MessageID returns the message identifier from either payload form.
func (m *MaybeInaccessibleMessage) MessageID() int {
if m.IsAccessible() {
return m.msg.MessageID
} else if m.IsInaccessible() {
return m.ina.MessageID
}
return 0
}
// Chat returns the chat from either payload form.
func (m *MaybeInaccessibleMessage) Chat() *Chat {
if m.IsAccessible() {
return m.msg.Chat
} else if m.IsInaccessible() {
return &m.ina.Chat
}
return nil
}
// MessageEntityType represents the type of a message entity.
type MessageEntityType string
const (
MessageEntityMention MessageEntityType = "mention"
MessageEntityHashtag MessageEntityType = "hashtag"
MessageEntityCashtag MessageEntityType = "cashtag"
MessageEntityBotCommand MessageEntityType = "bot_command"
MessageEntityUrl MessageEntityType = "url"
MessageEntityEmail MessageEntityType = "email"
MessageEntityPhoneNumber MessageEntityType = "phone_number"
MessageEntityBold MessageEntityType = "bold"
MessageEntityItalic MessageEntityType = "italic"
MessageEntityUnderline MessageEntityType = "underline"
MessageEntityStrike MessageEntityType = "strikethrough"
MessageEntitySpoiler MessageEntityType = "spoiler"
MessageEntityBlockquote MessageEntityType = "blockquote"
// 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 MessageEntityType = "code"
MessageEntityPre MessageEntityType = "pre"
MessageEntityTextLink MessageEntityType = "text_link"
MessageEntityTextMention MessageEntityType = "text_mention"
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
MessageEntityDateTime MessageEntityType = "date_time"
// 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"
)
// MessageEntity represents one special entity in a text message.
@@ -121,12 +372,13 @@ type ReplyParameters struct {
MessageID int `json:"message_id"`
ChatID int64 `json:"chat_id,omitempty"`
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"`
QuotePosition int `json:"quote_position,omitempty"`
ChecklistTaskID int `json:"checklist_task_id,omitempty"`
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"`
QuotePosition int `json:"quote_position,omitempty"`
ChecklistTaskID int `json:"checklist_task_id,omitempty"`
PollOptionID string `json:"poll_option_id,omitempty"`
}
// LinkPreviewOptions describes the options used for link preview generation.
@@ -166,23 +418,27 @@ type InlineKeyboardMarkup struct {
type KeyboardButtonStyle string
const (
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
// 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"
)
// KeyboardButton represents one button of the reply keyboard.
// See https://core.telegram.org/bots/api#keyboardbutton
type KeyboardButton struct {
Text string `json:"text"`
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
Style KeyboardButtonStyle `json:"style,omitempty"`
RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"`
RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"`
RequestContact bool `json:"request_contact,omitempty"`
RequestLocation bool `json:"request_location,omitempty"`
RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"`
WebApp *WebAppInfo `json:"web_app,omitempty"`
Text string `json:"text"`
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
Style KeyboardButtonStyle `json:"style,omitempty"`
RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"`
RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"`
RequestManagedBot *KeyboardButtonRequestManagedBot `json:"request_managed_bot,omitempty"`
RequestContact bool `json:"request_contact,omitempty"`
RequestLocation bool `json:"request_location,omitempty"`
RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"`
WebApp *WebAppInfo `json:"web_app,omitempty"`
}
// KeyboardButtonRequestUsers defines criteria used to request suitable users.
@@ -213,6 +469,14 @@ type KeyboardButtonRequestChat struct {
RequestPhoto bool `json:"request_photo,omitempty"`
}
// KeyboardButtonRequestManagedBot defines criteria used to request a managed bot.
// See https://core.telegram.org/bots/api#keyboardbuttonrequestmanagedbot
type KeyboardButtonRequestManagedBot struct {
RequestID int32 `json:"request_id"`
SuggestedName string `json:"suggested_name,omitempty"`
SuggestedUsername string `json:"suggested_username,omitempty"`
}
// KeyboardButtonPollType represents the type of a poll that may be created from a keyboard button.
// See https://core.telegram.org/bots/api#keyboardbuttonpolltype
type KeyboardButtonPollType struct {
@@ -252,52 +516,27 @@ type CallbackQuery struct {
GameShortName string `json:"game_short_name,omitempty"`
}
// InputPollOption contains information about one answer option in a poll to be sent.
// See https://core.telegram.org/bots/api#inputpolloption
type InputPollOption struct {
Text string `json:"text"`
TextParseMode ParseMode `json:"text_parse_mode,omitempty"`
TextEntities []*MessageEntity `json:"text_entities,omitempty"`
}
// PollType represents the type of a poll.
type PollType string
const (
PollTypeRegular PollType = "regular"
PollTypeQuiz PollType = "quiz"
)
// InputChecklistTask describes a task in a checklist.
type InputChecklistTask struct {
ID int `json:"id"`
Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,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"`
Tasks []InputChecklistTask `json:"tasks"`
OtherCanAddTasks bool `json:"other_can_add_tasks,omitempty"`
OtherCanMarkTasksAsDone bool `json:"other_can_mark_tasks_as_done,omitempty"`
}
// ChatActionType represents the type of chat action.
type ChatActionType string
const (
ChatActionTyping ChatActionType = "typing"
ChatActionUploadPhoto ChatActionType = "upload_photo"
ChatActionUploadVideo ChatActionType = "upload_video"
ChatActionUploadVoice ChatActionType = "upload_voice"
ChatActionUploadDocument ChatActionType = "upload_document"
ChatActionChooseSticker ChatActionType = "choose_sticker"
ChatActionFindLocation ChatActionType = "find_location"
// 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
)
@@ -358,3 +597,70 @@ type SuggestedPostParameters struct {
Price SuggestedPostPrice `json:"price"`
SendDate int `json:"send_date"`
}
// ManagedBotCreated describes a service message about a newly created managed bot.
// See https://core.telegram.org/bots/api#managedbotcreated
type ManagedBotCreated struct {
Bot User `json:"bot"`
}
// ManagedBotUpdated describes an update about a managed bot and its manager.
// See https://core.telegram.org/bots/api#managedbotupdated
type ManagedBotUpdated struct {
User User `json:"user"`
Bot User `json:"bot"`
}
type SharedUser struct {
UserID int64 `json:"user_id"`
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
Photo []PhotoSize `json:"photo,omitempty"`
}
type UsersShared struct {
RequestID int `json:"request_id"`
Users []SharedUser `json:"users"`
}
type ChatShared struct {
RequestID int `json:"request_id"`
ChatID int64 `json:"chat_id"`
Title string `json:"title,omitempty"`
Username string `json:"username,omitempty"`
Photo []PhotoSize `json:"photo,omitempty"`
}
type SuggestedPostApproved struct {
SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"`
Price SuggestedPostPrice `json:"price"`
SendDate int `json:"send_date"`
}
type SuggestedPostApprovalFailed struct {
SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"`
Price SuggestedPostPrice `json:"price"`
}
type SuggestedPostDeclined struct {
SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"`
Comment string `json:"comment,omitempty"`
}
type SuggestedPostPaid struct {
SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"`
Currency string `json:"currency"`
Amount int `json:"amount"`
StarAmount *StarAmount `json:"star_amount,omitempty"`
}
type SuggestedPostRefunded struct {
SuggestedPostMessage *Message `json:"suggested_post_message,omitempty"`
Reason string `json:"reason,omitempty"`
}
type VideoChatScheduled struct {
StartDate int64 `json:"start_date"`
}
type VideoChatStarted struct{}
type VideoChatEnded struct {
Duration int64 `json:"duration"`
}
type VideoChatParticipantsInvited struct {
Users []User `json:"users"`
}
+88 -19
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.
@@ -33,6 +33,48 @@ func (api *API) GetMeWithContext(ctx context.Context) (User, error) {
return req.DoWithContext(ctx, api)
}
// GetManagedBotToken holds parameters for the getManagedBotToken method.
// See https://core.telegram.org/bots/api#getmanagedbottoken
type GetManagedBotToken struct {
UserID int64 `json:"user_id"`
}
// GetManagedBotToken returns the current token of a managed bot.
// See https://core.telegram.org/bots/api#getmanagedbottoken
func (api *API) GetManagedBotToken(params GetManagedBotToken) (string, error) {
req := NewRequest[string]("getManagedBotToken", params)
return req.Do(api)
}
// GetManagedBotTokenWithContext is the context-aware variant of GetManagedBotToken.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmanagedbottoken
func (api *API) GetManagedBotTokenWithContext(ctx context.Context, params GetManagedBotToken) (string, error) {
req := NewRequest[string]("getManagedBotToken", params)
return req.DoWithContext(ctx, api)
}
// ReplaceManagedBotToken holds parameters for the replaceManagedBotToken method.
// See https://core.telegram.org/bots/api#replacemanagedbottoken
type ReplaceManagedBotToken struct {
UserID int64 `json:"user_id"`
}
// ReplaceManagedBotToken replaces and returns the token of a managed bot.
// See https://core.telegram.org/bots/api#replacemanagedbottoken
func (api *API) ReplaceManagedBotToken(params ReplaceManagedBotToken) (string, error) {
req := NewRequest[string]("replaceManagedBotToken", params)
return req.Do(api)
}
// ReplaceManagedBotTokenWithContext is the context-aware variant of ReplaceManagedBotToken.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#replacemanagedbottoken
func (api *API) ReplaceManagedBotTokenWithContext(ctx context.Context, params ReplaceManagedBotToken) (string, error) {
req := NewRequest[string]("replaceManagedBotToken", params)
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
@@ -80,13 +122,13 @@ func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams)
return req.DoWithContext(ctx, api)
}
// SetWebhookP holds parameters for the setWebhook method.
// SetWebhook 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 {
type SetWebhook struct {
URL string `json:"url"`
IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
MaxConnections int8 `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
SecretToken string `json:"secret_token,omitempty"`
@@ -96,7 +138,7 @@ type SetWebhookP struct {
// 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) {
func (api *API) SetWebhook(params SetWebhook) (bool, error) {
req := NewRequest[bool]("setWebhook", params)
return req.Do(api)
}
@@ -105,21 +147,21 @@ func (api *API) SetWebhook(params SetWebhookP) (bool, error) {
// 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) {
func (api *API) SetWebhookWithContext(ctx context.Context, params SetWebhook) (bool, error) {
req := NewRequest[bool]("setWebhook", params)
return req.DoWithContext(ctx, api)
}
// DeleteWebhookP holds parameters for the deleteWebhook method.
// DeleteWebhook holds parameters for the deleteWebhook method.
// See https://core.telegram.org/bots/api#deletewebhook
type DeleteWebhookP struct {
type DeleteWebhook struct {
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
}
// DeleteWebhook removes the current webhook integration.
// Returns true on success.
// See https://core.telegram.org/bots/api#deletewebhook
func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) {
func (api *API) DeleteWebhook(params DeleteWebhook) (bool, error) {
req := NewRequest[bool]("deleteWebhook", params)
return req.Do(api)
}
@@ -127,7 +169,7 @@ func (api *API) DeleteWebhook(params DeleteWebhookP) (bool, error) {
// 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) {
func (api *API) DeleteWebhookWithContext(ctx context.Context, params DeleteWebhook) (bool, error) {
req := NewRequest[bool]("deleteWebhook", params)
return req.DoWithContext(ctx, api)
}
@@ -147,15 +189,15 @@ func (api *API) GetWebhookInfoWithContext(ctx context.Context) (WebhookInfo, err
return req.DoWithContext(ctx, api)
}
// GetFileP holds parameters for the getFile method.
// GetFile holds parameters for the getFile method.
// See https://core.telegram.org/bots/api#getfile
type GetFileP struct {
FileId string `json:"file_id"`
type GetFile struct {
FileID string `json:"file_id"`
}
// GetFile returns basic information about a file and prepares it for downloading.
// See https://core.telegram.org/bots/api#getfile
func (api *API) GetFile(params GetFileP) (File, error) {
func (api *API) GetFile(params GetFile) (File, error) {
req := NewRequest[File]("getFile", params)
return req.Do(api)
}
@@ -163,13 +205,14 @@ func (api *API) GetFile(params GetFileP) (File, error) {
// 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) {
func (api *API) GetFileWithContext(ctx context.Context, params GetFile) (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)
@@ -177,12 +220,38 @@ func (api *API) GetFileByLink(link string) ([]byte, error) {
// 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"
@@ -199,15 +268,15 @@ func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error)
if err != nil {
return nil, err
}
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
defer func() {
_ = res.Body.Close()
}()
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
}
+95
View File
@@ -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) {
@@ -113,3 +151,60 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
t.Fatalf("expected allowed_updates to be omitted, got %v", gotBody["allowed_updates"])
}
}
func TestSetChatMenuButtonSendsStructuredMenuButton(t *testing.T) {
var gotBody 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)
}
if err := json.Unmarshal(body, &gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":true}`)),
}, nil
}),
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(client),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
text := "Open"
if _, err := api.SetChatMenuButton(SetChatMenuButton{
ChatID: 42,
MenuButton: &MenuButton{
Type: MenuButtonWebAppType,
Text: &text,
WebApp: &WebAppInfo{
URL: "https://example.test/app",
},
},
}); err != nil {
t.Fatalf("SetChatMenuButton returned error: %v", err)
}
menuButton, ok := gotBody["menu_button"].(map[string]any)
if !ok {
t.Fatalf("expected structured menu_button, got %#v", gotBody["menu_button"])
}
if menuButton["type"] != string(MenuButtonWebAppType) {
t.Fatalf("unexpected menu button type: %#v", menuButton["type"])
}
if menuButton["text"] != text {
t.Fatalf("unexpected menu button text: %#v", menuButton["text"])
}
}
+2 -16
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.
@@ -19,17 +19,3 @@ type EmptyParams struct{}
// NoParams is a convenient instance of EmptyParams.
var NoParams = EmptyParams{}
// WebhookInfo describes the current webhook status.
// See https://core.telegram.org/bots/api#webhookinfo
type WebhookInfo struct {
URL string `json:"url"`
HasCustomCertificate bool `json:"has_custom_certificate"`
PendingUpdateCount int `json:"pending_update_count"`
IPAddress string `json:"ip_address,omitempty"`
LastErrorDate int `json:"last_error_date,omitempty"`
LastErrorMessage string `json:"last_error_message,omitempty"`
LastSynchronizationErrorDate int `json:"last_synchronization_error_date,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowedUpdates []string `json:"allowed_updates,omitempty"`
}
+37
View File
@@ -0,0 +1,37 @@
package tgapi
import (
"encoding/json"
"strings"
"testing"
)
func TestParseNoneOmitsParseModeInJSON(t *testing.T) {
data, err := json.Marshal(SendMessage{
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(SendMessage{
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))
}
}
+4 -4
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method.
// SetPassportDataErrors holds parameters for the setPassportDataErrors method.
// See https://core.telegram.org/bots/api#setpassportdataerrors
type SetPassportDataErrorsP struct {
type SetPassportDataErrors struct {
UserID int64 `json:"user_id"`
Errors []PassportElementError `json:"errors"`
}
@@ -12,7 +12,7 @@ type SetPassportDataErrorsP struct {
// SetPassportDataErrors informs a user about Telegram Passport data errors.
// Returns true on success.
// See https://core.telegram.org/bots/api#setpassportdataerrors
func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, error) {
func (api *API) SetPassportDataErrors(params SetPassportDataErrors) (bool, error) {
req := NewRequest[bool]("setPassportDataErrors", params)
return req.Do(api)
}
@@ -20,7 +20,7 @@ func (api *API) SetPassportDataErrors(params SetPassportDataErrorsP) (bool, erro
// 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) {
func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrors) (bool, error) {
req := NewRequest[bool]("setPassportDataErrors", params)
return req.DoWithContext(ctx, api)
}
+60 -1
View File
@@ -1,5 +1,64 @@
package tgapi
type PassportData struct {
Data []EncryptedPassportElement `json:"data"`
Credentials EncryptedCredentials `json:"credentials"`
}
type PassportFile struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
FileSize int64 `json:"file_size"`
FileDate int64 `json:"file_date"`
}
type PassportElementType string
const (
PassportPersonalDetailsType PassportElementType = "personal_details"
PassportPassportType PassportElementType = "passport"
PassportDriverLicenseType PassportElementType = "driver_license"
PassportIdentityCardType PassportElementType = "identity_card"
PassportInternalPassportType PassportElementType = "internal_passport"
PassportAddressType PassportElementType = "address"
PassportUtilityBillType PassportElementType = "utility_bill"
PassportBankStatementType PassportElementType = "bank_statement"
PassportRentalAgreementType PassportElementType = "rental_agreement"
PassportPassportRegistrationType PassportElementType = "passport_registration"
PassportTemporaryRegistrationType PassportElementType = "temporary_registration"
PassportPhoneNumberType PassportElementType = "phone_number"
PassportEmailType PassportElementType = "email"
)
type EncryptedPassportElement struct {
Type PassportElementType `json:"type"`
Data string `json:"data,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
Email string `json:"email,omitempty"`
Files []PassportFile `json:"files,omitempty"`
FrontSide *PassportFile `json:"front_side,omitempty"`
ReverseSide *PassportFile `json:"reverse_side,omitempty"`
Selfie *PassportFile `json:"selfie,omitempty"`
Translation *PassportFile `json:"translation,omitempty"`
Hash string `json:"hash,omitempty"`
}
type EncryptedCredentials struct {
Data string `json:"data"`
Hash string `json:"hash"`
Secret string `json:"secret"`
}
// PassportElementError is a JSON-serializable passport element error object.
// See https://core.telegram.org/bots/api#passportelementerror
type PassportElementError map[string]any
type PassportElementError struct {
Source string `json:"source"`
Type PassportElementType `json:"type"`
FieldName string `json:"field_name,omitempty"`
DataHash string `json:"data_hash,omitempty"`
FileHash string `json:"file_hash,omitempty"`
FileHashes []string `json:"file_hashes,omitempty"`
ElementHash string `json:"element_hash,omitempty"`
Message string `json:"message"`
}
+16 -16
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// SendInvoiceP holds parameters for the sendInvoice method.
// SendInvoice holds parameters for the sendInvoice method.
// See https://core.telegram.org/bots/api#sendinvoice
type SendInvoiceP struct {
type SendInvoice struct {
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -43,7 +43,7 @@ type SendInvoiceP struct {
// SendInvoice sends an invoice.
// See https://core.telegram.org/bots/api#sendinvoice
func (api *API) SendInvoice(params SendInvoiceP) (Message, error) {
func (api *API) SendInvoice(params SendInvoice) (Message, error) {
req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID)
return req.Do(api)
}
@@ -51,14 +51,14 @@ func (api *API) SendInvoice(params SendInvoiceP) (Message, error) {
// 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) {
func (api *API) SendInvoiceWithContext(ctx context.Context, params SendInvoice) (Message, error) {
req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateInvoiceLinkP holds parameters for the createInvoiceLink method.
// CreateInvoiceLink holds parameters for the createInvoiceLink method.
// See https://core.telegram.org/bots/api#createinvoicelink
type CreateInvoiceLinkP struct {
type CreateInvoiceLink struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
Title string `json:"title"`
@@ -87,7 +87,7 @@ type CreateInvoiceLinkP struct {
// CreateInvoiceLink creates an invoice link.
// See https://core.telegram.org/bots/api#createinvoicelink
func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) {
func (api *API) CreateInvoiceLink(params CreateInvoiceLink) (string, error) {
req := NewRequest[string]("createInvoiceLink", params)
return req.Do(api)
}
@@ -95,14 +95,14 @@ func (api *API) CreateInvoiceLink(params CreateInvoiceLinkP) (string, error) {
// 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) {
func (api *API) CreateInvoiceLinkWithContext(ctx context.Context, params CreateInvoiceLink) (string, error) {
req := NewRequest[string]("createInvoiceLink", params)
return req.DoWithContext(ctx, api)
}
// AnswerShippingQueryP holds parameters for the answerShippingQuery method.
// AnswerShippingQuery holds parameters for the answerShippingQuery method.
// See https://core.telegram.org/bots/api#answershippingquery
type AnswerShippingQueryP struct {
type AnswerShippingQuery struct {
ShippingQueryID string `json:"shipping_query_id"`
OK bool `json:"ok"`
ShippingOptions []ShippingOption `json:"shipping_options,omitempty"`
@@ -112,7 +112,7 @@ type AnswerShippingQueryP struct {
// AnswerShippingQuery answers a shipping query.
// Returns true on success.
// See https://core.telegram.org/bots/api#answershippingquery
func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) {
func (api *API) AnswerShippingQuery(params AnswerShippingQuery) (bool, error) {
req := NewRequest[bool]("answerShippingQuery", params)
return req.Do(api)
}
@@ -120,14 +120,14 @@ func (api *API) AnswerShippingQuery(params AnswerShippingQueryP) (bool, error) {
// 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) {
func (api *API) AnswerShippingQueryWithContext(ctx context.Context, params AnswerShippingQuery) (bool, error) {
req := NewRequest[bool]("answerShippingQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method.
// AnswerPreCheckoutQuery holds parameters for the answerPreCheckoutQuery method.
// See https://core.telegram.org/bots/api#answerprecheckoutquery
type AnswerPreCheckoutQueryP struct {
type AnswerPreCheckoutQuery struct {
PreCheckoutQueryID string `json:"pre_checkout_query_id"`
OK bool `json:"ok"`
ErrorMessage string `json:"error_message,omitempty"`
@@ -136,7 +136,7 @@ type AnswerPreCheckoutQueryP struct {
// AnswerPreCheckoutQuery answers a pre-checkout query.
// Returns true on success.
// See https://core.telegram.org/bots/api#answerprecheckoutquery
func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, error) {
func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQuery) (bool, error) {
req := NewRequest[bool]("answerPreCheckoutQuery", params)
return req.Do(api)
}
@@ -144,7 +144,7 @@ func (api *API) AnswerPreCheckoutQuery(params AnswerPreCheckoutQueryP) (bool, er
// 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) {
func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params AnswerPreCheckoutQuery) (bool, error) {
req := NewRequest[bool]("answerPreCheckoutQuery", params)
return req.DoWithContext(ctx, api)
}
+80
View File
@@ -7,6 +7,62 @@ type LabeledPrice struct {
Amount int `json:"amount"`
}
type Invoice struct {
Title string `json:"title"`
Description string `json:"description"`
StartParameter string `json:"start_parameter"`
Currency string `json:"currency"`
TotalAmount int `json:"total_amount"`
}
// ShippingQuery represents an incoming shipping query.
// See https://core.telegram.org/bots/api#shippingquery
type ShippingQuery struct {
ID string `json:"id"`
From User `json:"from"`
InvoicePayload string `json:"invoice_payload"`
ShippingAddress ShippingAddress `json:"shipping_address"`
}
// ShippingAddress represents a shipping address.
// See https://core.telegram.org/bots/api#shippingaddress
type ShippingAddress struct {
CountryCode string `json:"country_code"`
State string `json:"state"`
City string `json:"city"`
StreetLine1 string `json:"street_line1"`
StreetLine2 string `json:"street_line2"`
PostCode string `json:"post_code"`
}
// OrderInfo represents information about an order.
// See https://core.telegram.org/bots/api#orderinfo
type OrderInfo struct {
Name string `json:"name"`
PhoneNumber string `json:"phone_number"`
Email string `json:"email"`
ShippingAddress ShippingAddress `json:"shipping_address"`
}
// PreCheckoutQuery represents an incoming pre-checkout query.
// See https://core.telegram.org/bots/api#precheckoutquery
type PreCheckoutQuery struct {
ID string `json:"id"`
From User `json:"from"`
Currency string `json:"currency"`
TotalAmount int `json:"total_amount"`
InvoicePayload string `json:"invoice_payload"`
ShippingOptionID string `json:"shipping_option_id"`
OrderInfo *OrderInfo `json:"order_info,omitempty"`
}
// PaidMediaPurchased represents a purchased paid media.
// See https://core.telegram.org/bots/api#paidmediapurchased
type PaidMediaPurchased struct {
From User `json:"from"`
PaidMediaPayload string `json:"paid_media_payload"`
}
// ShippingOption represents one shipping option.
// See https://core.telegram.org/bots/api#shippingoption
type ShippingOption struct {
@@ -14,3 +70,27 @@ type ShippingOption struct {
Title string `json:"title"`
Prices []LabeledPrice `json:"prices"`
}
type SuccessfulPayment struct {
Currency string `json:"currency"`
TotalAmount int `json:"total_amount"`
InvoicePayload string `json:"invoice_payload"`
SubscriptionExpirationDate int `json:"subscription_expiration_date,omitempty"`
IsRecurring bool `json:"is_recurring,omitempty"`
IsFirstRecurring bool `json:"is_first_recurring,omitempty"`
ShippingOptionID string `json:"shipping_option_id,omitempty"`
OrderInfo *OrderInfo `json:"order_info,omitempty"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
ProviderPaymentChargeID string `json:"proviced_payment_charge_id"`
}
type RefundedPayment struct {
Currency string `json:"currency"`
TotalAmount int `json:"total_amount"`
InvoicePayload string `json:"invoice_payload"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
ProviderPaymentChargeID string `json:"proviced_payment_charge_id,omitempty"`
}
+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)
}
}
+12 -12
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// GetStarTransactionsP holds parameters for the getStarTransactions method.
// GetStarTransactions holds parameters for the getStarTransactions method.
// See https://core.telegram.org/bots/api#getstartransactions
type GetStarTransactionsP struct {
type GetStarTransactions struct {
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
}
@@ -26,7 +26,7 @@ func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, er
// GetStarTransactions returns Telegram Star transactions for the bot.
// See https://core.telegram.org/bots/api#getstartransactions
func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) {
func (api *API) GetStarTransactions(params GetStarTransactions) (StarTransactions, error) {
req := NewRequest[StarTransactions]("getStarTransactions", params)
return req.Do(api)
}
@@ -34,14 +34,14 @@ func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactio
// 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) {
func (api *API) GetStarTransactionsWithContext(ctx context.Context, params GetStarTransactions) (StarTransactions, error) {
req := NewRequest[StarTransactions]("getStarTransactions", params)
return req.DoWithContext(ctx, api)
}
// RefundStarPaymentP holds parameters for the refundStarPayment method.
// RefundStarPayment holds parameters for the refundStarPayment method.
// See https://core.telegram.org/bots/api#refundstarpayment
type RefundStarPaymentP struct {
type RefundStarPayment struct {
UserID int64 `json:"user_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
}
@@ -49,7 +49,7 @@ type RefundStarPaymentP struct {
// RefundStarPayment refunds a successful Telegram Stars payment.
// Returns true on success.
// See https://core.telegram.org/bots/api#refundstarpayment
func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) {
func (api *API) RefundStarPayment(params RefundStarPayment) (bool, error) {
req := NewRequest[bool]("refundStarPayment", params)
return req.Do(api)
}
@@ -57,14 +57,14 @@ func (api *API) RefundStarPayment(params RefundStarPaymentP) (bool, error) {
// 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) {
func (api *API) RefundStarPaymentWithContext(ctx context.Context, params RefundStarPayment) (bool, error) {
req := NewRequest[bool]("refundStarPayment", params)
return req.DoWithContext(ctx, api)
}
// EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method.
// EditUserStarSubscription holds parameters for the editUserStarSubscription method.
// See https://core.telegram.org/bots/api#edituserstarsubscription
type EditUserStarSubscriptionP struct {
type EditUserStarSubscription struct {
UserID int64 `json:"user_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
IsCanceled bool `json:"is_canceled"`
@@ -73,7 +73,7 @@ type EditUserStarSubscriptionP struct {
// EditUserStarSubscription cancels or re-enables a user star subscription extension.
// Returns true on success.
// See https://core.telegram.org/bots/api#edituserstarsubscription
func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool, error) {
func (api *API) EditUserStarSubscription(params EditUserStarSubscription) (bool, error) {
req := NewRequest[bool]("editUserStarSubscription", params)
return req.Do(api)
}
@@ -81,7 +81,7 @@ func (api *API) EditUserStarSubscription(params EditUserStarSubscriptionP) (bool
// 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) {
func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscription) (bool, error) {
req := NewRequest[bool]("editUserStarSubscription", params)
return req.DoWithContext(ctx, api)
}
+64 -64
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// SendStickerP holds parameters for the sendSticker method.
// SendSticker holds parameters for the sendSticker method.
// See https://core.telegram.org/bots/api#sendsticker
type SendStickerP struct {
type SendSticker struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -24,7 +24,7 @@ type SendStickerP struct {
// SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker.
// See https://core.telegram.org/bots/api#sendsticker
func (api *API) SendSticker(params SendStickerP) (Message, error) {
func (api *API) SendSticker(params SendSticker) (Message, error) {
req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID)
return req.Do(api)
}
@@ -32,20 +32,20 @@ func (api *API) SendSticker(params SendStickerP) (Message, error) {
// 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) {
func (api *API) SendStickerWithContext(ctx context.Context, params SendSticker) (Message, error) {
req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetStickerSetP holds parameters for the getStickerSet method.
// GetStickerSet holds parameters for the getStickerSet method.
// See https://core.telegram.org/bots/api#getstickerset
type GetStickerSetP struct {
type GetStickerSet struct {
Name string `json:"name"`
}
// GetStickerSet returns a sticker set by its name.
// See https://core.telegram.org/bots/api#getstickerset
func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) {
func (api *API) GetStickerSet(params GetStickerSet) (StickerSet, error) {
req := NewRequest[StickerSet]("getStickerSet", params)
return req.Do(api)
}
@@ -53,20 +53,20 @@ func (api *API) GetStickerSet(params GetStickerSetP) (StickerSet, error) {
// 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) {
func (api *API) GetStickerSetWithContext(ctx context.Context, params GetStickerSet) (StickerSet, error) {
req := NewRequest[StickerSet]("getStickerSet", params)
return req.DoWithContext(ctx, api)
}
// GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method.
// GetCustomEmojiStickers holds parameters for the getCustomEmojiStickers method.
// See https://core.telegram.org/bots/api#getcustomemojistickers
type GetCustomEmojiStickersP struct {
type GetCustomEmojiStickers struct {
CustomEmojiIDs []string `json:"custom_emoji_ids"`
}
// GetCustomEmojiStickers returns information about custom emoji stickers by their IDs.
// See https://core.telegram.org/bots/api#getcustomemojistickers
func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticker, error) {
func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickers) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getCustomEmojiStickers", params)
return req.Do(api)
}
@@ -74,14 +74,14 @@ func (api *API) GetCustomEmojiStickers(params GetCustomEmojiStickersP) ([]Sticke
// 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) {
func (api *API) GetCustomEmojiStickersWithContext(ctx context.Context, params GetCustomEmojiStickers) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getCustomEmojiStickers", params)
return req.DoWithContext(ctx, api)
}
// UploadStickerFileP holds parameters for the uploadStickerFile method.
// UploadStickerFile holds parameters for the uploadStickerFile method.
// See https://core.telegram.org/bots/api#uploadstickerfile
type UploadStickerFileP struct {
type UploadStickerFile struct {
UserID int64 `json:"user_id"`
StickerFormat InputStickerFormat `json:"sticker_format"`
}
@@ -89,7 +89,7 @@ type UploadStickerFileP struct {
// UploadStickerFile uploads a sticker file for later use in sticker set methods.
// sticker is the file to upload.
// See https://core.telegram.org/bots/api#uploadstickerfile
func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFile) (File, error) {
func (api *API) UploadStickerFile(params UploadStickerFile, sticker UploaderFile) (File, error) {
uploader := NewUploader(api)
defer func() {
_ = uploader.Close()
@@ -101,7 +101,7 @@ func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFil
// 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) {
func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadStickerFile, sticker UploaderFile) (File, error) {
uploader := NewUploader(api)
defer func() {
_ = uploader.Close()
@@ -110,9 +110,9 @@ func (api *API) UploadStickerFileWithContext(ctx context.Context, params UploadS
return req.DoWithContext(ctx, uploader)
}
// CreateNewStickerSetP holds parameters for the createNewStickerSet method.
// CreateNewStickerSet holds parameters for the createNewStickerSet method.
// See https://core.telegram.org/bots/api#createnewstickerset
type CreateNewStickerSetP struct {
type CreateNewStickerSet struct {
UserID int64 `json:"user_id"`
Name string `json:"name"`
Title string `json:"title"`
@@ -125,7 +125,7 @@ type CreateNewStickerSetP struct {
// CreateNewStickerSet creates a new sticker set owned by a user.
// Returns True on success.
// See https://core.telegram.org/bots/api#createnewstickerset
func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) {
func (api *API) CreateNewStickerSet(params CreateNewStickerSet) (bool, error) {
req := NewRequest[bool]("createNewStickerSet", params)
return req.Do(api)
}
@@ -133,14 +133,14 @@ func (api *API) CreateNewStickerSet(params CreateNewStickerSetP) (bool, error) {
// 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) {
func (api *API) CreateNewStickerSetWithContext(ctx context.Context, params CreateNewStickerSet) (bool, error) {
req := NewRequest[bool]("createNewStickerSet", params)
return req.DoWithContext(ctx, api)
}
// AddStickerToSetP holds parameters for the addStickerToSet method.
// AddStickerToSet holds parameters for the addStickerToSet method.
// See https://core.telegram.org/bots/api#addstickertoset
type AddStickerToSetP struct {
type AddStickerToSet struct {
UserID int64 `json:"user_id"`
Name string `json:"name"`
Sticker InputSticker `json:"sticker"`
@@ -149,7 +149,7 @@ type AddStickerToSetP struct {
// AddStickerToSet adds a new sticker to a set created by the bot.
// Returns True on success.
// See https://core.telegram.org/bots/api#addstickertoset
func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) {
func (api *API) AddStickerToSet(params AddStickerToSet) (bool, error) {
req := NewRequest[bool]("addStickerToSet", params)
return req.Do(api)
}
@@ -157,14 +157,14 @@ func (api *API) AddStickerToSet(params AddStickerToSetP) (bool, error) {
// 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) {
func (api *API) AddStickerToSetWithContext(ctx context.Context, params AddStickerToSet) (bool, error) {
req := NewRequest[bool]("addStickerToSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method.
// SetStickerPositionInSet holds parameters for the setStickerPositionInSet method.
// See https://core.telegram.org/bots/api#setstickerpositioninset
type SetStickerPositionInSetP struct {
type SetStickerPositionInSet struct {
Sticker string `json:"sticker"`
Position int `json:"position"`
}
@@ -172,7 +172,7 @@ type SetStickerPositionInSetP struct {
// SetStickerPositionInSet moves a sticker in a set to a specific position.
// Returns True on success.
// See https://core.telegram.org/bots/api#setstickerpositioninset
func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool, error) {
func (api *API) SetStickerPositionInSet(params SetStickerPositionInSet) (bool, error) {
req := NewRequest[bool]("setStickerPositionInSet", params)
return req.Do(api)
}
@@ -180,21 +180,21 @@ func (api *API) SetStickerPositionInSet(params SetStickerPositionInSetP) (bool,
// 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) {
func (api *API) SetStickerPositionInSetWithContext(ctx context.Context, params SetStickerPositionInSet) (bool, error) {
req := NewRequest[bool]("setStickerPositionInSet", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method.
// DeleteStickerFromSet holds parameters for the deleteStickerFromSet method.
// See https://core.telegram.org/bots/api#deletestickerfromset
type DeleteStickerFromSetP struct {
type DeleteStickerFromSet struct {
Sticker string `json:"sticker"`
}
// DeleteStickerFromSet deletes a sticker from a set created by the bot.
// Returns True on success.
// See https://core.telegram.org/bots/api#deletestickerfromset
func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error) {
func (api *API) DeleteStickerFromSet(params DeleteStickerFromSet) (bool, error) {
req := NewRequest[bool]("deleteStickerFromSet", params)
return req.Do(api)
}
@@ -202,14 +202,14 @@ func (api *API) DeleteStickerFromSet(params DeleteStickerFromSetP) (bool, error)
// 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) {
func (api *API) DeleteStickerFromSetWithContext(ctx context.Context, params DeleteStickerFromSet) (bool, error) {
req := NewRequest[bool]("deleteStickerFromSet", params)
return req.DoWithContext(ctx, api)
}
// ReplaceStickerInSetP holds parameters for the replaceStickerInSet method.
// ReplaceStickerInSet holds parameters for the replaceStickerInSet method.
// See https://core.telegram.org/bots/api#replacestickerinset
type ReplaceStickerInSetP struct {
type ReplaceStickerInSet struct {
UserID int64 `json:"user_id"`
Name string `json:"name"`
OldSticker string `json:"old_sticker"`
@@ -219,7 +219,7 @@ type ReplaceStickerInSetP struct {
// ReplaceStickerInSet replaces an existing sticker in a set with a new one.
// Returns True on success.
// See https://core.telegram.org/bots/api#replacestickerinset
func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) {
func (api *API) ReplaceStickerInSet(params ReplaceStickerInSet) (bool, error) {
req := NewRequest[bool]("replaceStickerInSet", params)
return req.Do(api)
}
@@ -227,14 +227,14 @@ func (api *API) ReplaceStickerInSet(params ReplaceStickerInSetP) (bool, error) {
// 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) {
func (api *API) ReplaceStickerInSetWithContext(ctx context.Context, params ReplaceStickerInSet) (bool, error) {
req := NewRequest[bool]("replaceStickerInSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerEmojiListP holds parameters for the setStickerEmojiList method.
// SetStickerEmojiList holds parameters for the setStickerEmojiList method.
// See https://core.telegram.org/bots/api#setstickeremojilist
type SetStickerEmojiListP struct {
type SetStickerEmojiList struct {
Sticker string `json:"sticker"`
EmojiList []string `json:"emoji_list"`
}
@@ -242,7 +242,7 @@ type SetStickerEmojiListP struct {
// SetStickerEmojiList changes the list of emoji associated with a sticker.
// Returns True on success.
// See https://core.telegram.org/bots/api#setstickeremojilist
func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) {
func (api *API) SetStickerEmojiList(params SetStickerEmojiList) (bool, error) {
req := NewRequest[bool]("setStickerEmojiList", params)
return req.Do(api)
}
@@ -250,14 +250,14 @@ func (api *API) SetStickerEmojiList(params SetStickerEmojiListP) (bool, error) {
// 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) {
func (api *API) SetStickerEmojiListWithContext(ctx context.Context, params SetStickerEmojiList) (bool, error) {
req := NewRequest[bool]("setStickerEmojiList", params)
return req.DoWithContext(ctx, api)
}
// SetStickerKeywordsP holds parameters for the setStickerKeywords method.
// SetStickerKeywords holds parameters for the setStickerKeywords method.
// See https://core.telegram.org/bots/api#setstickerkeywords
type SetStickerKeywordsP struct {
type SetStickerKeywords struct {
Sticker string `json:"sticker"`
Keywords []string `json:"keywords"`
}
@@ -265,7 +265,7 @@ type SetStickerKeywordsP struct {
// SetStickerKeywords changes the keywords of a sticker.
// Returns True on success.
// See https://core.telegram.org/bots/api#setstickerkeywords
func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) {
func (api *API) SetStickerKeywords(params SetStickerKeywords) (bool, error) {
req := NewRequest[bool]("setStickerKeywords", params)
return req.Do(api)
}
@@ -273,14 +273,14 @@ func (api *API) SetStickerKeywords(params SetStickerKeywordsP) (bool, error) {
// 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) {
func (api *API) SetStickerKeywordsWithContext(ctx context.Context, params SetStickerKeywords) (bool, error) {
req := NewRequest[bool]("setStickerKeywords", params)
return req.DoWithContext(ctx, api)
}
// SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method.
// SetStickerMaskPosition holds parameters for the setStickerMaskPosition method.
// See https://core.telegram.org/bots/api#setstickermaskposition
type SetStickerMaskPositionP struct {
type SetStickerMaskPosition struct {
Sticker string `json:"sticker"`
MaskPosition *MaskPosition `json:"mask_position,omitempty"`
}
@@ -288,7 +288,7 @@ type SetStickerMaskPositionP struct {
// SetStickerMaskPosition changes the mask position of a mask sticker.
// Returns True on success.
// See https://core.telegram.org/bots/api#setstickermaskposition
func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, error) {
func (api *API) SetStickerMaskPosition(params SetStickerMaskPosition) (bool, error) {
req := NewRequest[bool]("setStickerMaskPosition", params)
return req.Do(api)
}
@@ -296,14 +296,14 @@ func (api *API) SetStickerMaskPosition(params SetStickerMaskPositionP) (bool, er
// 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) {
func (api *API) SetStickerMaskPositionWithContext(ctx context.Context, params SetStickerMaskPosition) (bool, error) {
req := NewRequest[bool]("setStickerMaskPosition", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetTitleP holds parameters for the setStickerSetTitle method.
// SetStickerSetTitle holds parameters for the setStickerSetTitle method.
// See https://core.telegram.org/bots/api#setstickersettitle
type SetStickerSetTitleP struct {
type SetStickerSetTitle struct {
Name string `json:"name"`
Title string `json:"title"`
}
@@ -311,7 +311,7 @@ type SetStickerSetTitleP struct {
// SetStickerSetTitle sets the title of a sticker set created by the bot.
// Returns True on success.
// See https://core.telegram.org/bots/api#setstickersettitle
func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) {
func (api *API) SetStickerSetTitle(params SetStickerSetTitle) (bool, error) {
req := NewRequest[bool]("setStickerSetTitle", params)
return req.Do(api)
}
@@ -319,14 +319,14 @@ func (api *API) SetStickerSetTitle(params SetStickerSetTitleP) (bool, error) {
// 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) {
func (api *API) SetStickerSetTitleWithContext(ctx context.Context, params SetStickerSetTitle) (bool, error) {
req := NewRequest[bool]("setStickerSetTitle", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method.
// SetStickerSetThumbnail holds parameters for the setStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setstickersetthumbnail
type SetStickerSetThumbnailP struct {
type SetStickerSetThumbnail struct {
Name string `json:"name"`
UserID int64 `json:"user_id"`
Thumbnail string `json:"thumbnail"`
@@ -336,7 +336,7 @@ type SetStickerSetThumbnailP struct {
// SetStickerSetThumbnail sets the thumbnail of a sticker set.
// Returns True on success.
// See https://core.telegram.org/bots/api#setstickersetthumbnail
func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, error) {
func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnail) (bool, error) {
req := NewRequest[bool]("setStickerSetThumbnail", params)
return req.Do(api)
}
@@ -344,14 +344,14 @@ func (api *API) SetStickerSetThumbnail(params SetStickerSetThumbnailP) (bool, er
// 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) {
func (api *API) SetStickerSetThumbnailWithContext(ctx context.Context, params SetStickerSetThumbnail) (bool, error) {
req := NewRequest[bool]("setStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method.
// SetCustomEmojiStickerSetThumbnail holds parameters for the setCustomEmojiStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
type SetCustomEmojiStickerSetThumbnailP struct {
type SetCustomEmojiStickerSetThumbnail struct {
Name string `json:"name"`
CustomEmojiID string `json:"custom_emoji_id,omitempty"`
}
@@ -359,7 +359,7 @@ type SetCustomEmojiStickerSetThumbnailP struct {
// SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set.
// Returns True on success.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSetThumbnailP) (bool, error) {
func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSetThumbnail) (bool, error) {
req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params)
return req.Do(api)
}
@@ -367,21 +367,21 @@ func (api *API) SetCustomEmojiStickerSetThumbnail(params SetCustomEmojiStickerSe
// 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) {
func (api *API) SetCustomEmojiStickerSetThumbnailWithContext(ctx context.Context, params SetCustomEmojiStickerSetThumbnail) (bool, error) {
req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerSetP holds parameters for the deleteStickerSet method.
// DeleteStickerSet holds parameters for the deleteStickerSet method.
// See https://core.telegram.org/bots/api#deletestickerset
type DeleteStickerSetP struct {
type DeleteStickerSet struct {
Name string `json:"name"`
}
// DeleteStickerSet deletes a sticker set created by the bot.
// Returns True on success.
// See https://core.telegram.org/bots/api#deletestickerset
func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) {
func (api *API) DeleteStickerSet(params DeleteStickerSet) (bool, error) {
req := NewRequest[bool]("deleteStickerSet", params)
return req.Do(api)
}
@@ -389,7 +389,7 @@ func (api *API) DeleteStickerSet(params DeleteStickerSetP) (bool, error) {
// 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) {
func (api *API) DeleteStickerSetWithContext(ctx context.Context, params DeleteStickerSet) (bool, error) {
req := NewRequest[bool]("deleteStickerSet", params)
return req.DoWithContext(ctx, api)
}
+2 -2
View File
@@ -38,8 +38,8 @@ const (
// Sticker represents a sticker.
// See https://core.telegram.org/bots/api#sticker
type Sticker struct {
FileId string `json:"file_id"`
FileUniqueId string `json:"file_unique_id"`
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Type StickerType `json:"type"`
Width int `json:"width"`
Height int `json:"height"`
+289 -168
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"
@@ -56,11 +57,15 @@ const (
UpdateTypeChatBoost UpdateType = "chat_boost"
// UpdateTypeRemovedChatBoost is a removed chat boost update.
UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost"
UpdateTypeManagedBot UpdateType = "managed_bot"
)
// 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 +76,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"`
@@ -89,35 +93,98 @@ type Update struct {
ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"`
ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"`
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"`
ManagedBot *ManagedBotUpdated `json:"managed_bot,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()
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
case u.ManagedBot != nil:
u.Type = UpdateTypeManagedBot
default:
u.Type = UpdateTypeUnknown
}
return nil
}
// MarshalJSON emits the canonical deleted_business_messages field.
func (u Update) MarshalJSON() ([]byte, error) {
u.syncDeletedBusinessMessages()
type alias Update
return json.Marshal(alias(u))
// WebhookInfo describes the current webhook status.
// See https://core.telegram.org/bots/api#webhookinfo
type WebhookInfo struct {
URL string `json:"url"`
HasCustomCertificate bool `json:"has_custom_certificate"`
PendingUpdateCount int `json:"pending_update_count"`
IPAddress string `json:"ip_address,omitempty"`
LastErrorDate int `json:"last_error_date,omitempty"`
LastErrorMessage string `json:"last_error_message,omitempty"`
LastSynchronizationErrorDate int `json:"last_synchronization_error_date,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
AllowedUpdates []string `json:"allowed_updates,omitempty"`
}
type ProximityAlertTriggered struct {
Traveler User `json:"traveler"`
Watcher User `json:"watcher"`
Distance int `json:"distance"`
}
// InlineQuery represents an incoming inline query.
@@ -141,115 +208,15 @@ type ChosenInlineResult struct {
Query string `json:"query"`
}
// ShippingQuery represents an incoming shipping query.
// See https://core.telegram.org/bots/api#shippingquery
type ShippingQuery struct {
ID string `json:"id"`
From User `json:"from"`
InvoicePayload string `json:"invoice_payload"`
ShippingAddress ShippingAddress `json:"shipping_address"`
}
// ShippingAddress represents a shipping address.
// See https://core.telegram.org/bots/api#shippingaddress
type ShippingAddress struct {
CountryCode string `json:"country_code"`
State string `json:"state"`
City string `json:"city"`
StreetLine1 string `json:"street_line1"`
StreetLine2 string `json:"street_line2"`
PostCode string `json:"post_code"`
}
// OrderInfo represents information about an order.
// See https://core.telegram.org/bots/api#orderinfo
type OrderInfo struct {
Name string `json:"name"`
PhoneNumber string `json:"phone_number"`
Email string `json:"email"`
ShippingAddress ShippingAddress `json:"shipping_address"`
}
// PreCheckoutQuery represents an incoming pre-checkout query.
// See https://core.telegram.org/bots/api#precheckoutquery
type PreCheckoutQuery struct {
ID string `json:"id"`
From User `json:"from"`
Currency string `json:"currency"`
TotalAmount int `json:"total_amount"`
InvoicePayload string `json:"invoice_payload"`
ShippingOptionID string `json:"shipping_option_id"`
OrderInfo *OrderInfo `json:"order_info,omitempty"`
}
// PaidMediaPurchased represents a purchased paid media.
// See https://core.telegram.org/bots/api#paidmediapurchased
type PaidMediaPurchased struct {
From User `json:"from"`
PaidMediaPayload string `json:"paid_media_payload"`
}
// File represents a file ready to be downloaded.
// See https://core.telegram.org/bots/api#file
type File struct {
FileId string `json:"file_id"`
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
FileSize int64 `json:"file_size,omitempty"`
FilePath string `json:"file_path,omitempty"`
}
// Audio represents an audio file to be treated as music by the Telegram clients.
// See https://core.telegram.org/bots/api#audio
type Audio struct {
FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"`
Duration int `json:"duration"`
Performer string `json:"performer,omitempty"`
Title string `json:"title,omitempty"`
FileName string `json:"file_name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
FileSize int64 `json:"file_size,omitempty"`
Thumbnail *PhotoSize `json:"thumbnail,omitempty"`
}
// PollOption contains information about one answer option in a poll.
// See https://core.telegram.org/bots/api#polloption
type PollOption struct {
Text string `json:"text"`
TextEntities []MessageEntity `json:"text_entities"`
VoterCount int `json:"voter_count"`
}
// Poll contains information about a poll.
// See https://core.telegram.org/bots/api#poll
type Poll struct {
ID string `json:"id"`
Question string `json:"question"`
QuestionEntities []MessageEntity `json:"question_entities"`
Options []PollOption `json:"options"`
TotalVoterCount int `json:"total_voter_count"`
IsClosed bool `json:"is_closed"`
IsAnonymous bool `json:"is_anonymous"`
Type PollType `json:"type"`
AllowsMultipleAnswers bool `json:"allows_multiple_answers"`
CorrectOptionID *int `json:"correct_option_id,omitempty"`
Explanation *string `json:"explanation,omitempty"`
ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"`
OpenPeriod int `json:"open_period,omitempty"`
CloseDate int `json:"close_date,omitempty"`
}
// PollAnswer represents an answer of a user in a poll.
// See https://core.telegram.org/bots/api#pollanswer
type PollAnswer struct {
PollID string `json:"poll_id"`
VoterChat Chat `json:"voter_chat"`
User User `json:"user"`
OptionIDS []int `json:"option_ids"`
}
// ChatMemberUpdated represents changes in the status of a chat member.
// See https://core.telegram.org/bots/api#chatmemberupdated
type ChatMemberUpdated struct {
@@ -311,18 +278,17 @@ type WebAppInfo struct {
URL string `json:"url"`
}
type WebAppData struct {
Data string `json:"data"`
ButtonText string `json:"button_text"`
}
// StarAmount represents an amount of Telegram Stars.
type StarAmount struct {
Amount int `json:"amount"`
NanostarAmount int `json:"nanostar_amount"`
}
// Story represents a story.
type Story struct {
Chat Chat `json:"chat"`
ID int `json:"id"`
}
// AcceptedGiftTypes represents the types of gifts accepted by a user or chat.
type AcceptedGiftTypes struct {
UnlimitedGifts bool `json:"unlimited_gifts"`
@@ -332,6 +298,58 @@ type AcceptedGiftTypes struct {
GiftsFromChannels bool `json:"gifts_from_channels"`
}
// GiftBackground represents the background of a gift.
type GiftBackground struct {
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
TextColor int `json:"text_color"`
}
// Gift represents a gift that can be sent.
type Gift struct {
ID string `json:"id"`
Sticker Sticker `json:"sticker"`
StarCount int `json:"star_count"`
UpdateStarCount *int `json:"update_star_count,omitempty"`
IsPremium *bool `json:"is_premium,omitempty"`
HasColors *bool `json:"has_colors,omitempty"`
TotalCount *int `json:"total_count,omitempty"`
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"`
UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"`
PublisherChat *Chat `json:"publisher_chat,omitempty"`
}
// Gifts represents a list of gifts.
type Gifts struct {
Gifts []Gift `json:"gifts"`
}
type UniqueGiftModel struct {
Name string `json:"name"`
Sticker Sticker `json:"sticker"`
RarityPerMille int `json:"rarity_per_mille"`
Rarity string `json:"rarity,omitempty"`
}
type UniqueGiftSymbol struct {
Name string `json:"name"`
Sticker Sticker `json:"sticker"`
RarityPerMille int `json:"rarity_per_mille"`
}
type UniqueGiftBackdropColors struct {
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
SymbolColor int `json:"symbol_color"`
TextColor int `json:"text_color"`
}
type UniqueGiftBackdrop struct {
Name string `json:"name"`
Colors UniqueGiftBackdropColors `json:"colors"`
RarityPerMille int `json:"rarity_per_mille"`
}
// UniqueGiftColors represents color information for a unique gift.
type UniqueGiftColors struct {
ModelCustomEmojiID string `json:"model_custom_emoji_id"`
@@ -342,67 +360,79 @@ type UniqueGiftColors struct {
DarkThemeOtherColors []int `json:"dark_theme_other_colors"`
}
// GiftBackground represents the background of a gift.
type GiftBackground struct {
CenterColor int `json:"center_color"`
EdgeColor int `json:"edge_color"`
TextColor int `json:"text_color"`
type UniqueGift struct {
GiftID string `json:"gift_id"`
BaseName string `json:"base_name"`
Name string `json:"name"`
Number int `json:"number"`
Model UniqueGiftModel `json:"model"`
Symbol UniqueGiftSymbol `json:"symbol"`
Backdrop UniqueGiftBackdrop `json:"backdrop"`
IsPremium bool `json:"is_premium,omitempty"`
IsBurned bool `json:"is_burned,omitempty"`
IsFromBlockchain bool `json:"is_from_blockchain,omitempty"`
Colors *UniqueGiftColors `json:"colors,omitempty"`
PublisherChat *Chat `json:"publisher_chat,omitempty"`
}
// Gift represents a gift that can be sent.
type Gift struct {
ID string `json:"id"`
Sticker Sticker `json:"sticker"`
StarCount int `json:"star_count"`
UpdateStarCount *int `json:"update_star_count,omitempty"`
IsPremium *bool `json:"is_premium,omitempty"`
HasColors *bool `json:"has_colors,omitempty"`
TotalCount *int `json:"total_count,omitempty"`
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"`
UniqueGiftVariantColor *int `json:"unique_gift_variant_color,omitempty"`
PublisherChat *Chat `json:"publisher_chat,omitempty"`
}
type GiftInfo struct {
Gift Gift `json:"gift"`
// Gifts represents a list of gifts.
type Gifts struct {
Gifts []Gift `json:"gifts"`
OwnedGiftID string `json:"owned_gift_id,omitempty"`
ConvertStarCount int `json:"convert_star_count,omitempty"`
PrepaidUpgradeStarCount int `json:"prepaid_upgrade_star_count,omitempty"`
IsUpgradeSeparate bool `json:"is_upgrade_separate,omitempty"`
CanBeUpgraded bool `json:"can_be_upgraded,omitempty"`
Text string `json:"text,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
UniqueGiftNumber int `json:"unique_gift_number,omitempty"`
}
type UniqueGiftInfo struct {
Gift UniqueGift `json:"gift"`
Origin string `json:"origin"`
LastResaleCurrency string `json:"last_resale_currency,omitempty"`
LastResaleAmount int `json:"last_resale_amount,omitempty"`
OwnedGiftID string `json:"owned_gift_id,omitempty"`
TransferStarCount int `json:"transfer_star_count,omitempty"`
NextTransferDate int `json:"next_transfer_date,omitempty"`
}
// OwnedGiftType represents the type of an owned gift.
type OwnedGiftType string
const (
// OwnedGiftRegularType identifies a regular owned gift.
OwnedGiftRegularType OwnedGiftType = "regular"
OwnedGiftUniqueType OwnedGiftType = "unique"
// OwnedGiftUniqueType identifies a unique owned gift.
OwnedGiftUniqueType OwnedGiftType = "unique"
)
// OwnedGift represents a gift owned by a user or chat.
type OwnedGift struct {
Type OwnedGiftType `json:"type"`
OwnerGiftID *string `json:"owner_gift_id,omitempty"`
SendDate *int `json:"send_date,omitempty"`
IsSaved *bool `json:"is_saved,omitempty"`
OwnedGiftID string `json:"ownen_gift_id,omitempty"`
SendDate int `json:"send_date,omitempty"`
IsSaved bool `json:"is_saved,omitempty"`
// 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"`
CanBeUpgraded *bool `json:"can_be_upgraded,omitempty"`
WasRefunded *bool `json:"was_refunded,omitempty"`
ConvertStarCount *int `json:"convert_star_count,omitempty"`
PrepaidUpgradeStarCount *int `json:"prepaid_upgrade_star_count,omitempty"`
IsUpgradeSeparate *bool `json:"is_upgrade_separate,omitempty"`
UniqueGiftNumber *int `json:"unique_gift_number,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
CanBeUpgraded bool `json:"can_be_upgraded,omitempty"`
WasRefunded bool `json:"was_refunded,omitempty"`
ConvertStarCount int `json:"convert_star_count,omitempty"`
PrepaidUpgradeStarCount int `json:"prepaid_upgrade_star_count,omitempty"`
IsUpgradeSeparate bool `json:"is_upgrade_separate,omitempty"`
UniqueGiftNumber int `json:"unique_gift_number,omitempty"`
// Fields specific to "unique" type
CanBeTransferred *bool `json:"can_be_transferred,omitempty"`
TransferStarCount *int `json:"transfer_star_count,omitempty"`
NextTransferDate *int `json:"next_transfer_date,omitempty"`
CanBeTransferred bool `json:"can_be_transferred,omitempty"`
TransferStarCount int `json:"transfer_star_count,omitempty"`
NextTransferDate int `json:"next_transfer_date,omitempty"`
}
// OwnedGifts represents a list of owned gifts with pagination.
@@ -411,3 +441,94 @@ type OwnedGifts struct {
Gifts []OwnedGift `json:"gifts"`
NextOffset string `json:"next_offset"`
}
type GiveawayCreated struct {
PrizeStarCount int `json:"prize_star_count,omitempty"`
}
type Giveaway struct {
Chats []Chat `json:"chats"`
WinnersSelectionDate int `json:"winners_selection_date"`
WinnerCount int `json:"winner_count"`
OnlyNewMembers bool `json:"only_new_members,omitempty"`
HasPublicWinners bool `json:"has_public_winners,omitempty"`
PrizeDescription string `json:"prize_description,omitempty"`
CountryCodes []string `json:"country_codes,omitempty"`
PrizeStarCount int `json:"prize_star_count,omitempty"`
PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"`
}
type GiveawayWinners struct {
Chat Chat `json:"chat"`
GiveawayMessageID int `json:"giveaway_message_id"`
WinnersSelectionDate int `json:"winners_selection_date"`
WinnerCount int `json:"winner_count"`
Winners []User `json:"winners"`
AdditionalChatCount int `json:"additional_chat_count,omitempty"`
PrizeStarCount int `json:"prize_star_count,omitempty"`
PremiumSubscriptionMonthCount int `json:"premium_subscription_month_count,omitempty"`
UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"`
OnlyNewMembers bool `json:"only_new_members,omitempty"`
WasRefunded bool `json:"was_refunded,omitempty"`
PrizeDescription string `json:"prize_description,omitempty"`
}
type GiveawayCompleted struct {
WinnerCount int `json:"winner_count"`
UnclaimedPrizeCount int `json:"unclaimed_prize_count,omitempty"`
GiveawayMessage *Message `json:"giveaway_message,omitempty"`
IsStarGiveaway bool `json:"is_star_giveaway,omitempty"`
}
type WriteAccessAllowed struct {
FromRequest bool `json:"from_request,omitempty"`
WebAppName string `json:"web_app_name,omitempty"`
FromAttachmentMenu bool `json:"from_attachment_menu,omitempty"`
}
type BackgroundFillType string
const (
BackgroundFillSolidType BackgroundFillType = "solid"
BackgroundFillGradientType BackgroundFillType = "gradient"
BackgroundFillFreeformGradientType BackgroundFillType = "freeform_gradient"
)
type BackgroundFill struct {
Type BackgroundFillType `json:"type"`
Color int `json:"color,omitempty"`
TopColor int `json:"top_color,omitempty"`
BottomColor int `json:"bottom_color,omitempty"`
RotationAngle int `json:"rotation_angle,omitempty"`
Colors []int `json:"colors,omitempty"`
}
type BackgroundTypeType string
const (
BackgroundTypeFillType BackgroundTypeType = "fill"
BackgroundTypeWallpaperType BackgroundTypeType = "wallpaper"
BackgroundTypePatternType BackgroundTypeType = "pattern"
BackgroundTypeChatThemeType BackgroundTypeType = "chat_theme"
)
type BackgroundType struct {
Type BackgroundTypeType `json:"type"`
Fill *BackgroundFill `json:"fill,omitempty"`
DarkThemeDimming int `json:"dark_theme_dimming,omitempty"`
Document *Document `json:"document,omitempty"`
IsBlurred bool `json:"is_blurred,omitempty"`
IsMoving bool `json:"is_moving,omitempty"`
Intensity int `json:"intensity,omitempty"`
IsInverted bool `json:"is_inverted,omitempty"`
ThemeName string `json:"theme_name,omitempty"`
}
+181 -33
View File
@@ -6,41 +6,130 @@ import (
"testing"
)
func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) {
var update Update
err := json.Unmarshal([]byte(`{
"update_id": 1,
"deleted_business_messages": {
"business_connection_id": "conn",
"chat": {"id": 42, "type": "private"},
"message_ids": [3, 5]
}
}`), &update)
if err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
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]
}
}`,
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,
},
{
name: "managed bot",
body: `{
"update_id": 5,
"managed_bot": {
"user": {"id": 11, "is_bot": false, "first_name": "Manager"},
"bot": {"id": 12, "is_bot": true, "first_name": "Worker"}
}
}`,
want: UpdateTypeManagedBot,
},
}
if update.DeletedBusinessMessages == nil {
t.Fatal("expected DeletedBusinessMessages to be populated")
}
if update.DeletedBusinessMessage == nil {
t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated")
}
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)
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.Type != tt.want {
t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
}
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 tt.want == UpdateTypeManagedBot && update.ManagedBot.Bot.ID != 12 {
t.Fatalf("unexpected managed bot id: got %d want %d", update.ManagedBot.Bot.ID, 12)
}
})
}
}
func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
func TestPollUnmarshalSupportsBotAPI96Fields(t *testing.T) {
var poll Poll
body := `{
"id": "poll-1",
"question": "Pick winners",
"question_entities": [],
"options": [],
"total_voter_count": 2,
"is_closed": false,
"is_anonymous": false,
"type": "quiz",
"allows_multiple_answers": true,
"allows_revoting": true,
"correct_option_ids": [1, 3]
}`
if err := json.Unmarshal([]byte(body), &poll); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if !poll.AllowsRevoting {
t.Fatal("expected allows_revoting to be decoded")
}
if len(poll.CorrectOptionIDs) != 2 || poll.CorrectOptionIDs[0] != 1 || poll.CorrectOptionIDs[1] != 3 {
t.Fatalf("unexpected correct option ids: %#v", poll.CorrectOptionIDs)
}
}
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 +139,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 +152,66 @@ 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)
}
}
func TestMaybeInaccessibleMessageUnmarshalAccessibleMessage(t *testing.T) {
var wrapper MaybeInaccessibleMessage
body := `{
"message_id": 10,
"date": 1700000000,
"chat": {"id": 42, "type": "private"},
"text": "hello"
}`
if err := json.Unmarshal([]byte(body), &wrapper); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if !wrapper.IsAccessible() {
t.Fatal("expected accessible message payload")
}
if wrapper.IsInaccessible() {
t.Fatal("expected inaccessible payload to be empty")
}
if wrapper.Message() == nil || wrapper.Message().Text != "hello" {
t.Fatalf("unexpected accessible payload: %#v", wrapper.Message())
}
if wrapper.MessageID() != 10 {
t.Fatalf("unexpected message id: got %d want %d", wrapper.MessageID(), 10)
}
if wrapper.Chat() == nil || wrapper.Chat().ID != 42 {
t.Fatalf("unexpected chat payload: %#v", wrapper.Chat())
}
}
func TestMaybeInaccessibleMessageUnmarshalInaccessibleMessage(t *testing.T) {
var wrapper MaybeInaccessibleMessage
body := `{
"message_id": 7,
"date": 0,
"chat": {"id": -1001, "type": "supergroup"}
}`
if err := json.Unmarshal([]byte(body), &wrapper); err != nil {
t.Fatalf("Unmarshal returned error: %v", err)
}
if wrapper.IsAccessible() {
t.Fatal("expected accessible payload to be empty")
}
if !wrapper.IsInaccessible() {
t.Fatal("expected inaccessible message payload")
}
if wrapper.InaccessibleMessage() == nil || wrapper.InaccessibleMessage().MessageID != 7 {
t.Fatalf("unexpected inaccessible payload: %#v", wrapper.InaccessibleMessage())
}
if wrapper.MessageID() != 7 {
t.Fatalf("unexpected message id: got %d want %d", wrapper.MessageID(), 7)
}
if wrapper.Chat() == nil || wrapper.Chat().ID != -1001 {
t.Fatalf("unexpected chat payload: %#v", wrapper.Chat())
}
}
+20 -11
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 (
@@ -70,6 +71,11 @@ type Uploader struct {
// NewUploader creates a multipart uploader bound to an API client.
func NewUploader(api *API) *Uploader {
logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
if api == nil {
logger.Errorln("api is nil")
_ = logger.Close()
return nil
}
return &Uploader{api, logger}
}
@@ -81,8 +87,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
@@ -90,16 +100,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
@@ -204,8 +215,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)
@@ -238,10 +248,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
+22 -1
View File
@@ -57,7 +57,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
}()
msg, err := uploader.SendPhoto(
UploadPhotoP{
UploadPhoto{
ChatID: 42,
CaptionEntities: []MessageEntity{{
Type: MessageEntityBold,
@@ -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 {
+37 -37
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// UploadPhotoP holds parameters for uploading a photo using the Uploader.
// UploadPhoto holds parameters for uploading a photo using the Uploader.
// See https://core.telegram.org/bots/api#sendphoto
type UploadPhotoP struct {
type UploadPhoto struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -29,7 +29,7 @@ type UploadPhotoP struct {
// SendPhoto uploads a photo via multipart and sends it as a message.
// file is the photo file to upload.
// See https://core.telegram.org/bots/api#sendphoto
func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, error) {
func (u *Uploader) SendPhoto(params UploadPhoto, file UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file)
return req.Do(u)
}
@@ -39,14 +39,14 @@ func (u *Uploader) SendPhoto(params UploadPhotoP, file UploaderFile) (Message, e
// 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) {
func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhoto, 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.
// UploadAudio holds parameters for uploading an audio file using the Uploader.
// See https://core.telegram.org/bots/api#sendaudio
type UploadAudioP struct {
type UploadAudio struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -73,7 +73,7 @@ type UploadAudioP struct {
// SendAudio uploads an audio file via multipart and sends it as a message.
// files are the audio file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendaudio
func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Message, error) {
func (u *Uploader) SendAudio(params UploadAudio, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...)
return req.Do(u)
}
@@ -83,14 +83,14 @@ func (u *Uploader) SendAudio(params UploadAudioP, files ...UploaderFile) (Messag
// 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) {
func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudio, 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.
// UploadDocument holds parameters for uploading a document using the Uploader.
// See https://core.telegram.org/bots/api#senddocument
type UploadDocumentP struct {
type UploadDocument struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -114,7 +114,7 @@ type UploadDocumentP struct {
// SendDocument uploads a document via multipart and sends it as a message.
// files are the document file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#senddocument
func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (Message, error) {
func (u *Uploader) SendDocument(params UploadDocument, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...)
return req.Do(u)
}
@@ -124,14 +124,14 @@ func (u *Uploader) SendDocument(params UploadDocumentP, files ...UploaderFile) (
// 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) {
func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocument, 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.
// UploadVideo holds parameters for uploading a video using the Uploader.
// See https://core.telegram.org/bots/api#sendvideo
type UploadVideoP struct {
type UploadVideo struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -162,7 +162,7 @@ type UploadVideoP struct {
// SendVideo uploads a video via multipart and sends it as a message.
// files are the video file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendvideo
func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Message, error) {
func (u *Uploader) SendVideo(params UploadVideo, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...)
return req.Do(u)
}
@@ -172,14 +172,14 @@ func (u *Uploader) SendVideo(params UploadVideoP, files ...UploaderFile) (Messag
// 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) {
func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideo, 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.
// UploadAnimation holds parameters for uploading an animation using the Uploader.
// See https://core.telegram.org/bots/api#sendanimation
type UploadAnimationP struct {
type UploadAnimation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -208,7 +208,7 @@ type UploadAnimationP struct {
// SendAnimation uploads an animation via multipart and sends it as a message.
// files are the animation file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendanimation
func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile) (Message, error) {
func (u *Uploader) SendAnimation(params UploadAnimation, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...)
return req.Do(u)
}
@@ -218,14 +218,14 @@ func (u *Uploader) SendAnimation(params UploadAnimationP, files ...UploaderFile)
// 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) {
func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimation, 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.
// UploadVoice holds parameters for uploading a voice note using the Uploader.
// See https://core.telegram.org/bots/api#sendvoice
type UploadVoiceP struct {
type UploadVoice struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -249,7 +249,7 @@ type UploadVoiceP struct {
// SendVoice uploads a voice note via multipart and sends it as a message.
// files are the voice file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendvoice
func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Message, error) {
func (u *Uploader) SendVoice(params UploadVoice, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...)
return req.Do(u)
}
@@ -259,14 +259,14 @@ func (u *Uploader) SendVoice(params UploadVoiceP, files ...UploaderFile) (Messag
// 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) {
func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoice, 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.
// UploadVideoNote holds parameters for uploading a video note (rounded video) using the Uploader.
// See https://core.telegram.org/bots/api#sendvideonote
type UploadVideoNoteP struct {
type UploadVideoNote struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -288,7 +288,7 @@ type UploadVideoNoteP struct {
// SendVideoNote uploads a video note via multipart and sends it as a message.
// files are the video note file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendvideonote
func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile) (Message, error) {
func (u *Uploader) SendVideoNote(params UploadVideoNote, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...)
return req.Do(u)
}
@@ -298,21 +298,21 @@ func (u *Uploader) SendVideoNote(params UploadVideoNoteP, files ...UploaderFile)
// 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) {
func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNote, 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.
// UploadChatPhoto holds parameters for uploading a chat photo using the Uploader.
// See https://core.telegram.org/bots/api#setchatphoto
type UploadChatPhotoP struct {
type UploadChatPhoto struct {
ChatID int64 `json:"chat_id"`
}
// SetChatPhoto uploads a new chat photo.
// photo is the photo file to upload.
// See https://core.telegram.org/bots/api#setchatphoto
func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bool, error) {
func (u *Uploader) SetChatPhoto(params UploadChatPhoto, photo UploaderFile) (bool, error) {
req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
return req.Do(u)
}
@@ -322,18 +322,18 @@ func (u *Uploader) SetChatPhoto(params UploadChatPhotoP, photo UploaderFile) (bo
// 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) {
func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhoto, 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.
// UploadSetWebhook 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 {
type UploadSetWebhook struct {
URL string `json:"url"`
IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"`
MaxConnections int8 `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
SecretToken string `json:"secret_token,omitempty"`
@@ -342,7 +342,7 @@ type UploadSetWebhookP struct {
// 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) {
func (u *Uploader) SetWebhook(params UploadSetWebhook, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.Do(u)
}
@@ -350,7 +350,7 @@ func (u *Uploader) SetWebhook(params UploadSetWebhookP, certificate UploaderFile
// 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) {
func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhook, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.DoWithContext(ctx, u)
}
+16 -16
View File
@@ -2,9 +2,9 @@ package tgapi
import "context"
// GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method.
// GetUserProfilePhotos holds parameters for the GetUserProfilePhotos method.
// See https://core.telegram.org/bots/api#getuserprofilephotos
type GetUserProfilePhotosP struct {
type GetUserProfilePhotos struct {
UserID int64 `json:"user_id"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
@@ -12,7 +12,7 @@ type GetUserProfilePhotosP struct {
// GetUserProfilePhotos returns a list of profile pictures for a user.
// See https://core.telegram.org/bots/api#getuserprofilephotos
func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfilePhotos, error) {
func (api *API) GetUserProfilePhotos(params GetUserProfilePhotos) (UserProfilePhotos, error) {
req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params)
return req.Do(api)
}
@@ -20,14 +20,14 @@ func (api *API) GetUserProfilePhotos(params GetUserProfilePhotosP) (UserProfileP
// 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) {
func (api *API) GetUserProfilePhotosWithContext(ctx context.Context, params GetUserProfilePhotos) (UserProfilePhotos, error) {
req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params)
return req.DoWithContext(ctx, api)
}
// GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method.
// GetUserProfileAudios holds parameters for the GetUserProfileAudios method.
// See https://core.telegram.org/bots/api#getuserprofileaudios
type GetUserProfileAudiosP struct {
type GetUserProfileAudios struct {
UserID int64 `json:"user_id"`
Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"`
@@ -35,7 +35,7 @@ type GetUserProfileAudiosP struct {
// GetUserProfileAudios returns a list of profile audios for a user.
// See https://core.telegram.org/bots/api#getuserprofileaudios
func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileAudios, error) {
func (api *API) GetUserProfileAudios(params GetUserProfileAudios) (UserProfileAudios, error) {
req := NewRequest[UserProfileAudios]("getUserProfileAudios", params)
return req.Do(api)
}
@@ -43,14 +43,14 @@ func (api *API) GetUserProfileAudios(params GetUserProfileAudiosP) (UserProfileA
// 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) {
func (api *API) GetUserProfileAudiosWithContext(ctx context.Context, params GetUserProfileAudios) (UserProfileAudios, error) {
req := NewRequest[UserProfileAudios]("getUserProfileAudios", params)
return req.DoWithContext(ctx, api)
}
// SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method.
// SetUserEmojiStatus holds parameters for the SetUserEmojiStatus method.
// See https://core.telegram.org/bots/api#setuseremojistatus
type SetUserEmojiStatusP struct {
type SetUserEmojiStatus struct {
UserID int64 `json:"user_id"`
EmojiID string `json:"emoji_status_custom_emoji_id,omitempty"`
ExpirationDate int `json:"emoji_status_expiration_date,omitempty"`
@@ -59,7 +59,7 @@ type SetUserEmojiStatusP struct {
// SetUserEmojiStatus sets a custom emoji status for a user.
// Returns true on success.
// See https://core.telegram.org/bots/api#setuseremojistatus
func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) {
func (api *API) SetUserEmojiStatus(params SetUserEmojiStatus) (bool, error) {
req := NewRequest[bool]("setUserEmojiStatus", params)
return req.Do(api)
}
@@ -67,14 +67,14 @@ func (api *API) SetUserEmojiStatus(params SetUserEmojiStatusP) (bool, error) {
// 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) {
func (api *API) SetUserEmojiStatusWithContext(ctx context.Context, params SetUserEmojiStatus) (bool, error) {
req := NewRequest[bool]("setUserEmojiStatus", params)
return req.DoWithContext(ctx, api)
}
// GetUserGiftsP holds parameters for the GetUserGifts method.
// GetUserGifts holds parameters for the GetUserGifts method.
// See https://core.telegram.org/bots/api#getusergifts
type GetUserGiftsP struct {
type GetUserGifts struct {
UserID int64 `json:"user_id"`
ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"`
ExcludeLimitedUpgradable bool `json:"exclude_limited_upgradable,omitempty"`
@@ -88,7 +88,7 @@ type GetUserGiftsP struct {
// GetUserGifts returns gifts owned by a user.
// See https://core.telegram.org/bots/api#getusergifts
func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) {
func (api *API) GetUserGifts(params GetUserGifts) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getUserGifts", params)
return req.Do(api)
}
@@ -96,7 +96,7 @@ func (api *API) GetUserGifts(params GetUserGiftsP) (OwnedGifts, error) {
// 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) {
func (api *API) GetUserGiftsWithContext(ctx context.Context, params GetUserGifts) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getUserGifts", params)
return req.DoWithContext(ctx, api)
}
+1
View File
@@ -13,6 +13,7 @@ type User struct {
AddedToAttachmentMenu *bool `json:"added_to_attachment_menu,omitempty"`
CanJoinGroups *bool `json:"can_join_groups,omitempty"`
CanReadAllGroupMessages *bool `json:"can_read_all_group_messages,omitempty"`
CanManageBots *bool `json:"can_manage_bots,omitempty"`
SupportsInlineQueries *bool `json:"supports_inline_queries,omitempty"`
CanConnectToBusiness *bool `json:"can_connect_to_business,omitempty"`
HasMainWebApp *bool `json:"has_main_web_app,omitempty"`
+223
View File
@@ -0,0 +1,223 @@
package laniakea
import (
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func (bot *Bot[T]) handleUpdate(u *tgapi.Update, ctx *MsgContext) bool {
handled := false
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.appData) {
continue
}
startTime := time.Now()
bot.safeEmitEvent(pluginCtx.Context(), HandlerStartedEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: plugin.name,
HandlerKind: HandlerUpdateKind,
HandlerName: string(u.Type),
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
})
err := handler(pluginCtx, bot.appData)
endEvent := HandlerFinishedEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: plugin.name,
HandlerKind: HandlerUpdateKind,
HandlerName: string(u.Type),
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
Duration: time.Since(startTime),
}
if err != nil {
endEvent.Err = err
endEvent.UserFacing = IsUserError(err)
}
bot.safeEmitEvent(pluginCtx.Context(), endEvent)
if err != nil {
bot.safeEmitEvent(pluginCtx.Context(), ErrorEvent{
UpdateID: u.UpdateID,
UpdateType: u.Type,
Plugin: plugin.name,
HandlerKind: HandlerUpdateKind,
HandlerName: string(u.Type),
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
Err: err,
UserFacing: IsUserError(err),
})
pluginCtx.error(err)
}
handled = true
}
return handled
}
func (bot *Bot[T]) prepareUpdateCtx(u *tgapi.Update, ctx *MsgContext) {
var from *tgapi.User
var chat *tgapi.Chat
switch u.Type {
case tgapi.UpdateTypeMessage:
if u.Message != nil {
ctx.Msg = u.Message
if u.Message.Chat != nil {
chat = u.Message.Chat
}
if u.Message.From != nil {
from = u.Message.From
}
}
case tgapi.UpdateTypeEditedMessage:
if u.EditedMessage != nil {
ctx.Msg = u.EditedMessage
if u.EditedMessage.Chat != nil {
chat = u.EditedMessage.Chat
}
if u.EditedMessage.From != nil {
from = u.EditedMessage.From
}
}
case tgapi.UpdateTypeChannelPost:
if u.ChannelPost != nil {
ctx.Msg = u.ChannelPost
if u.ChannelPost.Chat != nil {
chat = u.ChannelPost.Chat
}
if u.ChannelPost.From != nil {
from = u.ChannelPost.From
}
}
case tgapi.UpdateTypeEditedChannelPost:
if u.EditedChannelPost != nil {
ctx.Msg = u.EditedChannelPost
if u.EditedChannelPost.Chat != nil {
chat = u.EditedChannelPost.Chat
}
if u.EditedChannelPost.From != nil {
from = u.EditedChannelPost.From
}
}
case tgapi.UpdateTypeBusinessMessage:
if u.BusinessMessage != nil {
ctx.Msg = u.BusinessMessage
if u.BusinessMessage.Chat != nil {
chat = u.BusinessMessage.Chat
}
if u.BusinessMessage.From != nil {
from = u.BusinessMessage.From
}
}
case tgapi.UpdateTypeEditedBusinessMessage:
if u.EditedBusinessMessage != nil {
ctx.Msg = u.EditedBusinessMessage
if u.EditedBusinessMessage.Chat != nil {
chat = u.EditedBusinessMessage.Chat
}
if u.EditedBusinessMessage.From != nil {
from = u.EditedBusinessMessage.From
}
}
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.Message.Chat != nil {
chat = u.CallbackQuery.Message.Chat
}
}
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
chat = &u.MyChatMember.Chat
}
case tgapi.UpdateTypeChatMember:
if u.ChatMember != nil {
from = &u.ChatMember.From
chat = &u.ChatMember.Chat
}
case tgapi.UpdateTypeChatJoinRequest:
if u.ChatJoinRequest != nil {
from = &u.ChatJoinRequest.From
chat = &u.ChatJoinRequest.Chat
}
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
chat = u.MessageReaction.Chat
}
case tgapi.UpdateTypeChatBoost:
if u.ChatBoost != nil {
from = &u.ChatBoost.Boost.Source.User
chat = &u.ChatBoost.Chat
}
case tgapi.UpdateTypeRemovedChatBoost:
if u.RemovedChatBoost != nil {
from = &u.RemovedChatBoost.Source.User
chat = &u.RemovedChatBoost.Chat
}
}
if ctx.Msg != nil && from == nil {
from = ctx.Msg.From
}
if from != nil {
ctx.From = from
ctx.FromID = from.ID
} else {
ctx.FromID = 0
}
if chat != nil {
ctx.Chat = chat
ctx.ChatID = chat.ID
} else {
ctx.ChatID = 0
}
}
+10 -6
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 = utils.VersionMajor
VersionMinor = utils.VersionMinor
VersionPatch = utils.VersionPatch
VersionBeta = utils.VersionBeta
// 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
}
+3 -4
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 {
@@ -19,10 +19,9 @@ type multipartEncodeParams struct {
}
func TestEncodeMultipartJSONFields(t *testing.T) {
threadID := 7
params := multipartEncodeParams{
ChatID: 42,
MessageThreadID: &threadID,
MessageThreadID: new(7),
ReplyMarkup: &tgapi.ReplyMarkup{
InlineKeyboard: [][]tgapi.InlineKeyboardButton{{
{Text: "A", CallbackData: "b"},
+1 -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.
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strings"
"testing"
"git.nix13.pw/scuroneko/slog"
"git.scuroneko.dev/scuroneko/slog"
)
func TestCreateFileLoggerWritesToConfiguredFile(t *testing.T) {
+10 -5
View File
@@ -1,9 +1,14 @@
package utils
const (
VersionString = "1.0.0-rc.7"
VersionMajor = 1
VersionMinor = 0
VersionPatch = 0
VersionBeta = 7
// VersionString is the module version string.
VersionString = "1.0.0-rc.14"
// 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 is the prerelease counter for the current version.
VersionBeta = 14
)