REPOSITORY / ScuroNeko/Laniakea

Compare commits

DIFF REPOSITORY

Compare commits

...
Author SHA1 Message Date
ScuroNeko e92a0d37f3 (ci/cd): some style changes in workflow
Golang lint / lint (push) Successful in 2m34s
2026-04-23 22:24:11 +03:00
ScuroNeko 768dc859d7 (new): logger replacer for token
Golang lint / lint (push) Successful in 1m19s
(fix): scene command routing
2026-04-23 21:50:00 +03:00
ScuroNeko d6da95394c (dev): actions
Gitea Actions Demo / Explore-Gitea-Actions (push) Failing after 7s
2026-04-23 12:23:58 +03:00
ScuroNeko c9ec18ccea (new): bot opts file loader 2026-04-23 12:12:57 +03:00
ScuroNeko aa18da73d5 Add plugin message fallback
Route unmatched messages through plugin fallback handlers

Add observer coverage and bump version to rc.15
2026-04-13 16:27:53 +03:00
ScuroNeko 2b64e8543f tests cleanup 2026-04-13 10:11:13 +03:00
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
ScuroNeko 7776acaf12 refactor logging setup and split local/remote close APIs 2026-03-24 13:45:19 +03:00
ScuroNeko db31246eeb retract 1.0.0 rc 5 2026-03-23 13:19:39 +03:00
ScuroNeko d04c91342b small close fix; logo in readme 2026-03-23 13:13:47 +03:00
ScuroNeko 2e14d8b5df small close fix; logo in readme 2026-03-23 12:59:18 +03:00
ScuroNeko 6b9075c722 feat(tgapi): add context-aware API/uploader methods and align params/docs with Telegram Bot API 2026-03-19 15:10:49 +03:00
ScuroNeko 0b1a58a514 readme change 2026-03-19 14:23:23 +03:00
ScuroNeko c59dd1fe8e Handle canceled update polling without shutdown delay 2026-03-19 14:16:31 +03:00
103 changed files with 12943 additions and 1860 deletions
+12
View File
@@ -0,0 +1,12 @@
name: Golang lint
run-name: Linting code
on: [push]
jobs:
lint:
runs-on: go-latest
steps:
- name: Checkout repository code
uses: actions/checkout@v6
- name: Run golangci-lint
run: golangci-lint run
+5 -1
View File
@@ -1,2 +1,6 @@
.idea/ .idea/
test/ .wiki/
.vscode/
test/
.codex/
.codex
+14
View File
@@ -0,0 +1,14 @@
version: "2"
run:
timeout: 5m
linters:
disable-all: true
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
issues:
max-issues-per-linter: 0
max-same-issues: 0
+41
View File
@@ -0,0 +1,41 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-yaml
- id: check-json
- id: mixed-line-ending
args: ["--fix=lf"]
- repo: local
hooks:
- id: gofmt
name: gofmt
entry: gofmt -w
language: system
types: [go]
- id: go-vet
name: go vet
entry: go vet ./...
language: system
pass_filenames: false
types: [go]
- id: golangci-lint
name: golangci-lint
entry: golangci-lint run
language: system
pass_filenames: false
types: [go]
- id: go-test
name: go test
entry: go test ./...
language: system
pass_filenames: false
stages: [pre-push]
types: [go]
+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.
+305
View File
@@ -0,0 +1,305 @@
# Changelog
## v1.0.0-rc.15
### Changed
- Added file-based `BotOpts` loading and saving through `LoadBotOptsFile(...)`, `SaveBotOptsFile(...)`, and the `BotOptsFileCodec` API, with built-in JSON support.
- Added plugin-level message fallback handlers for text messages and channel posts that do not match commands.
- Added godoc for the exported `BotOpts` file codec and load/save helpers.
- README, README_RU, and bot-configuration wiki pages now document file-based `BotOpts` loading, built-in JSON support, env placeholder expansion, and custom codec usage including the TOML example.
- Active scenes now let unmatched slash-commands continue into normal bot command routing instead of also executing the current scene step or scene message fallback.
### Tests
- Added regression coverage for JSON `BotOpts` file codecs, file load/save helpers, decode failures, and env placeholder expansion.
- Added regression coverage for plugin message fallback routing, observer lifecycle events, command precedence, and middleware blocking.
- Added regression coverage proving unmatched slash-commands do not trigger active scene step handlers before normal bot command routing.
## 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.
-15
View File
@@ -1,15 +0,0 @@
# Проверка наличия golangci-lint
GO_LINT := $(shell command -v golangci-lint 2>/dev/null)
# Цель: запуск всех проверок кода
check:
@echo "🔍 Running code checks..."
@go mod tidy -v
@go vet ./...
@if [ -n "$(GO_LINT)" ]; then \
echo "✅ golangci-lint found, running..." && \
golangci-lint run --timeout=5m --verbose; \
else \
echo "⚠️ golangci-lint not installed. Install with: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.57.2"; \
fi
@go test -race -v ./... 2>/dev/null || echo "⚠️ Tests skipped or failed (run manually with 'go test -race ./...')"
+155 -31
View File
@@ -1,13 +1,17 @@
# Laniakea # Laniakea
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) ![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting. A lightweight, easy-to-use, and performant Telegram Bot API wrapper for Go. It simplifies bot development with a clean plugin system, middleware support, automatic command generation, and built-in rate limiting.
[На русском](README_RU.md) [На русском](README_RU.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
--- ---
## ✨ Features ## ✨ Features
@@ -17,15 +21,22 @@ 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). * **Middleware Support:** Run code before or after commands (e.g., logging, access control).
* **Automatic Command Generation:** Generate help and command lists automatically. * **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). * **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. * **Context-Aware:** Pass custom application data or state contexts to your handlers.
* **Fluent Interface:** Chain methods for clean configuration (e.g., `bot.ErrorTemplate(...).AddPlugins(...)`). * **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 ## 📦 Installation
```bash ```bash
go get git.nix13.pw/scuroneko/laniakea go get git.scuroneko.dev/scuroneko/laniakea
```
or
```bash
go get github.com/scuroneko/laniakea
``` ```
## 🚀 Quick Start (with step-by-step explanation) ## 🚀 Quick Start (with step-by-step explanation)
@@ -37,17 +48,18 @@ package main
import ( import (
"log" "log"
"git.nix13.pw/scuroneko/laniakea" // Import the Laniakea library "git.scuroneko.dev/scuroneko/laniakea" // Import the Laniakea library
) )
// echo is a command handler function. // echo is a command handler function.
// It receives two parameters: // It receives two parameters:
// - ctx: the message context (contains info about the message, sender, chat, etc.) // - ctx: the message context (contains info about the message, sender, chat, etc.)
// - db: your custom database context (here we use NoDB, a placeholder for no database) // - data: your shared application data (here we use NoData, a placeholder for no shared data)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Answer the user with the text they sent, without any command prefix. // Answer the user with the text they sent, without any command prefix.
// ctx.Text contains the user's message with the command part stripped off. // ctx.Text contains the user's message with the command part stripped off.
ctx.Answer(ctx.Text) // User input WITHOUT command ctx.Answer(ctx.Text) // User input WITHOUT command
return nil
} }
func main() { func main() {
@@ -55,14 +67,17 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"} opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Initialize a new bot instance. // 2. Initialize a new bot instance.
// We use laniakea.NoDB as the database context type (no database needed for this example). // We use laniakea.NoData as the application data type (no shared data needed for this example).
bot := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
// Ensure bot resources are cleaned up on exit. // Ensure bot resources are cleaned up on exit.
defer bot.Close() defer bot.Close()
// 3. Create a new plugin named "ping". // 3. Create a new plugin named "ping".
// Plugins help group related commands and middlewares. // 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. // 4. Add a command to the plugin.
// p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command. // p.NewCommand(echo, "echo") creates a command that triggers the 'echo' function on the "/echo" command.
@@ -70,14 +85,15 @@ func main() {
// 5. Add another command using an anonymous function (closure). // 5. Add another command using an anonymous function (closure).
// This command simply replies "Pong" when the user sends "/ping". // This command simply replies "Pong" when the user sends "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil
}, "ping")) }, "ping"))
// 6. Configure the bot with a custom error template and add the plugin. // 6. Configure the bot with a custom error template and add the plugin.
// 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. // 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. // 7. Automatically generate commands like /start, /help, and a list of all registered commands.
// This is optional but very useful for most bots. // This is optional but very useful for most bots.
@@ -86,26 +102,77 @@ func main() {
} }
// 8. Start the bot, listening for updates (long polling). // 8. Start the bot, listening for updates (long polling).
bot.Run() if err := bot.Run(); err != nil {
log.Fatal(err)
}
} }
``` ```
### How It Works ### How It Works
1. `BotOpts`: Holds configuration like the API token. 1. `BotOpts`: Holds configuration like the API token.
2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass a custom database context (e.g., *sql.DB) that will be available in all handlers. Use laniakea.NoDB if you don't need it. 2. `NewBot[T]`: Creates a bot instance. The type parameter T allows you to pass 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. 3. `NewPlugin`: Creates a logical group for commands and middlewares.
4. `AddCommand`: Registers a command. The first argument is the handler function (func(*MsgContext, T)), the second is the command name (without the slash). 4. `AddCommand`: Registers a command. The first argument is the handler function (`func(*MsgContext, T) error`), the second is the command name (without the slash).
5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom database context T. 5. **Handler Functions**: Receive *MsgContext (message details, methods like Answer) and your custom application data T, and return an error for centralized error handling.
6. `ErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error. 6. `SetErrorTemplate`: Sets a template for error messages. The %s placeholder is replaced by the actual error.
7. `AutoGenerateCommands`: Adds built-in commands (/start, /help) and a command that lists all available commands. 7. `AutoGenerateCommands`: Registers plugin-defined commands with Telegram across the supported scopes.
8. `Run()`: Starts the bot's update polling loop. 8. `Run()`: Starts the bot's update polling loop and returns an error if startup or polling fails.
9. `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.
## File-Based Config
`BotOpts` can also be loaded from or saved to config files through the file codec API.
Built in:
- `BotOptsFileJsonCodec` for JSON files.
Example:
```go
codec := laniakea.BotOptsFileJsonCodec{}
opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
if err != nil {
log.Fatal(err)
}
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
```
Placeholders like `{{ TG_TOKEN }}` inside the file are expanded from environment variables before decoding.
You can also implement your own codec for other formats by satisfying `BotOptsFileCodec`.
Only JSON is supported out of the box right now. If you want another format such as TOML, use `BotOptsFileJsonCodec` as the reference implementation for your own codec.
See the full guide in the wiki: [Bot Options and Configuration](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration)
## 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 ## 📖 Core Concepts
### Plugins ### Plugins
Plugins are the main way to organize code. A plugin can have multiple commands and middlewares. Plugins are the main way to organize code. A plugin can have multiple commands and middlewares.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
``` ```
@@ -114,9 +181,10 @@ bot.AddPlugins(plugin)
A command is a function that handles a specific bot command (e.g., /start). A command is a function that handles a specific bot command (e.g., /start).
```go ```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) { func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Access command arguments via ctx.Args ([]string) // Access command arguments via ctx.Args ([]string)
// Reply to the user: ctx.Answer("some text") // Reply to the user: ctx.Answer("some text")
return nil
} }
``` ```
@@ -125,8 +193,10 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Provides access to the incoming message and useful reply methods: Provides access to the incoming message and useful reply methods:
- `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none. - `Answer(text string) *AnswerMessage`: Sends a message with parse_mode none.
- `AnswerLong(text string) []*AnswerMessage`: Splits long plain text into multiple messages.
- `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping). - `AnswerMarkdown(text string) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping).
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard. - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message with parse_mode none and inline keyboard.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Splits long plain text into multiple messages and attaches the keyboard to the final chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Sends a message formatted with MarkdownV2 (you handle escaping) and inline keyboard.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Sends a message with photo with parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping). - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Sends a photo with MarkdownV2 caption (you handle escaping).
@@ -145,16 +215,59 @@ Provides access to the incoming message and useful reply methods:
This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`. This split keeps method intent explicit: JSON-only calls go through `API`, file uploads go through `Uploader`.
### 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 ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
db := &MyDB{...} db := &MyDB{...}
bot := laniakea.NewBot[*MyDB](opts, db) // Pass db instance bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
log.Fatal(err)
}
bot.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
Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context. Middleware are functions that run before a command handler. They are perfect for cross-cutting concerns like logging, access control, rate limiting, or modifying the context.
@@ -169,11 +282,12 @@ func(ctx *MsgContext, db T) bool
- If it returns false, the execution chain stops immediately (the command will not run). - If it returns false, the execution chain stops immediately (the command will not run).
### Adding Middleware ### Adding Middleware
Use the Use method of a plugin to add one or more middleware functions. They are executed in the order they are added. Use `AddMiddleware` on a plugin to add one or more shared middleware functions. They are executed in the order they are added.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware) plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
``` ```
@@ -202,16 +316,26 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware can modify the MsgContext (e.g., add custom fields) before the command runs. - Middleware can modify the MsgContext (e.g., add custom fields) before the command runs.
## ⚙️ Advanced Configuration ## ⚙️ Advanced Configuration
- **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. - **Inline Keyboards**: Build keyboards using `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64`, or `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` defines the default payload format, and `InlineKeyboard.SetPayloadType(...)` overrides it for one keyboard.
- **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully. - **Rate Limiting**: Pass a configured utils.RateLimiter via BotOpts to handle Telegram's rate limits gracefully.
- **Custom HTTP Client**: Provide your own http.Client in BotOpts for fine-tuned control. - **Localization**: `L10n` is safe for concurrent use once attached to the bot.
- **Custom Update Handlers**: Use `plugin.AddUpdateHandler(...)` for Telegram update types that are not part of the command/payload flow.
- **Lifecycle**: `RunWithContext(...)` 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 ## 📝 License
This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details. This project is licensed under the GNU General Public License v3.0 — see the [LICENSE](LICENSE) file for details.
## 📚 Learn More ## 📚 Learn More
[GoDoc](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) [GoDoc](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
[Telegram Bot API](https://core.telegram.org/bots/api) [Telegram Bot API](https://core.telegram.org/bots/api)
+168 -37
View File
@@ -1,13 +1,17 @@
# Laniakea # Laniakea
![Laniakea](assets/logo.jpg)
[![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/) [![Go Version](https://img.shields.io/badge/Go-1.24+-00ADD8?logo=go&style=flat-square)](https://go.dev/)
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg?style=flat-square)](LICENSE)
![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.nix13.pw&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.nix13.pw%2FScuroNeko%2FLaniakea%2Freleases) ![Gitea Release](https://img.shields.io/gitea/v/release/ScuroNeko/Laniakea?gitea_url=https%3A%2F%2Fgit.scuroneko.dev&sort=semver&display_name=release&style=flat-square&color=purple&link=https%3A%2F%2Fgit.scuroneko.dev%2FScuroNeko%2FLaniakea%2Freleases)
Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке中间件, автоматической генерации команд и встроенному ограничителю скорости запросов. Легковесная, простая в использовании и производительная обёртка для Telegram Bot API на Go. Она упрощает разработку ботов благодаря чистой системе плагинов, поддержке Middleware, автоматической генерации команд и встроенному рейтлимитеру.
[English](README.md) [English](README.md)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
--- ---
## ✨ Возможности ## ✨ Возможности
@@ -18,15 +22,22 @@
* **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа). * **Поддержка промежуточных слоёв (Middleware):** Выполняйте код до или после команд (например, логирование, проверка доступа).
* **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически. * **Автоматическая генерация команд:** Генерируйте справку и списки команд автоматически.
* **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`). * **Встроенный ограничитель запросов (Rate Limiter):** Защитите бота от превышения лимитов Telegram API (с обработкой `retry_after`).
* **Контекст данных:** Передавайте свой контекст базы данных или состояния в обработчики. * **Контекст данных:** Передавайте общие данные приложения или state в обработчики.
* **Текучий интерфейс (Fluent Interface):** Стройте цепочки методов для чистой конфигурации (например, `bot.ErrorTemplate(...).AddPlugins(...)`). * **Настраиваемый API:** Комбинируйте `Set...` и `Add...` helper-методы для понятной конфигурации, например `bot.SetErrorTemplate(...).AddPlugins(...)`.
* **Polling и Webhook Runtime:** Запускайте бота через long polling с `Run()` / `RunWithContext(...)` или через webhook server, которым владеет сам бот, с `RunWebHookWithContext(...)`.
--- ---
## 📦 Установка ## 📦 Установка
```bash ```bash
go get git.nix13.pw/scuroneko/laniakea go get git.scuroneko.dev/scuroneko/laniakea
```
или
```bash
go get github.com/scuroneko/laniakea
``` ```
## 🚀 Быстрый старт (с пошаговыми комментариями) ## 🚀 Быстрый старт (с пошаговыми комментариями)
@@ -38,17 +49,18 @@ package main
import ( import (
"log" "log"
"git.nix13.pw/scuroneko/laniakea" // Импортируем библиотеку Laniakea "git.scuroneko.dev/scuroneko/laniakea" // Импортируем библиотеку Laniakea
) )
// echo — это функция-обработчик команды. // echo — это функция-обработчик команды.
// Она получает два параметра: // Она получает два параметра:
// - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.) // - ctx: контекст сообщения (содержит информацию о сообщении, отправителе, чате и т.д.)
// - db: ваш пользовательский контекст базы данных (здесь мы используем NoDB — заглушку) // - data: ваши общие данные приложения (здесь мы используем NoData — заглушку без общих зависимостей)
func echo(ctx *laniakea.MsgContext, db *laniakea.NoDB) { func echo(ctx *laniakea.MsgContext, data laniakea.NoData) error {
// Отвечаем пользователю текстом, который он прислал, без префикса команды. // Отвечаем пользователю текстом, который он прислал, без префикса команды.
// ctx.Text содержит сообщение пользователя, из которого удалена часть с командой. // ctx.Text содержит сообщение пользователя, из которого удалена часть с командой.
ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды ctx.Answer(ctx.Text) // Ввод пользователя БЕЗ команды
return nil
} }
func main() { func main() {
@@ -56,14 +68,17 @@ func main() {
opts := &laniakea.BotOpts{Token: "TOKEN"} opts := &laniakea.BotOpts{Token: "TOKEN"}
// 2. Инициализируем новый экземпляр бота. // 2. Инициализируем новый экземпляр бота.
// Используем laniakea.NoDB как тип контекста базы данных (база не нужна для примера). // Используем laniakea.NoData как тип данных приложения (общие зависимости не нужны для примера).
bot := laniakea.NewBot[laniakea.NoDB](opts) bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
// Гарантируем освобождение ресурсов бота при выходе. // Гарантируем освобождение ресурсов бота при выходе.
defer bot.Close() defer bot.Close()
// 3. Создаём новый плагин с именем "ping". // 3. Создаём новый плагин с именем "ping".
// Плагины помогают группировать связанные команды и промежуточные обработчики. // Плагины помогают группировать связанные команды и промежуточные обработчики.
p := laniakea.NewPlugin[laniakea.NoDB]("ping") p := laniakea.NewPlugin[laniakea.NoData]("ping")
// 4. Добавляем команду в плагин. // 4. Добавляем команду в плагин.
// p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo". // p.NewCommand(echo, "echo") создаёт команду, которая вызывает функцию 'echo' по команде "/echo".
@@ -71,14 +86,15 @@ func main() {
// 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание). // 5. Добавляем ещё одну команду, используя анонимную функцию (замыкание).
// Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping". // Эта команда просто отвечает "Pong", когда пользователь отправляет "/ping".
p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, db *laniakea.NoDB) { p.AddCommand(p.NewCommand(func(ctx *laniakea.MsgContext, data laniakea.NoData) error {
ctx.Answer("Pong") ctx.Answer("Pong")
return nil
}, "ping")) }, "ping"))
// 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин. // 6. Настраиваем бота: задаём шаблон ошибки и добавляем плагин.
// ErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки). // SetErrorTemplate устанавливает формат для сообщений об ошибках (где %s будет заменён на текст ошибки).
// AddPlugins(p) регистрирует наш плагин "ping" в боте. // AddPlugins(p) регистрирует наш плагин "ping" в боте.
bot = bot.ErrorTemplate("Ошибка\n\n%s").AddPlugins(p) bot = bot.SetErrorTemplate("Ошибка\n\n%s").AddPlugins(p)
// 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд. // 7. Автоматически генерируем команды, такие как /start, /help и список всех зарегистрированных команд.
// Это необязательно, но очень полезно для большинства ботов. // Это необязательно, но очень полезно для большинства ботов.
@@ -87,26 +103,77 @@ func main() {
} }
// 8. Запускаем бота, начиная прослушивание обновлений (long polling). // 8. Запускаем бота, начиная прослушивание обновлений (long polling).
bot.Run() if err := bot.Run(); err != nil {
log.Fatal(err)
}
} }
``` ```
### Как это работает ### Как это работает
1. `BotOpts`: Содержит конфигурацию, например, токен API. 1. `BotOpts`: Содержит конфигурацию, например, токен API.
2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать пользовательский контекст базы данных (например, *sql.DB), который будет доступен во всех обработчиках. Используйте laniakea.NoDB, если он не нужен. 2. `NewBot[T]`: Создаёт экземпляр бота. Параметр типа T позволяет передать общие данные приложения (например, *sql.DB или контейнер сервисов), которые будут доступны во всех обработчиках. Используйте laniakea.NoData, если они не нужны.
3. `NewPlugin`: Создаёт логическую группу для команд и Middleware. 3. `NewPlugin`: Создаёт логическую группу для команд и Middleware.
4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (func(*MsgContext, T)), второй — имя команды (без слеша). 4. `AddCommand`: Регистрирует команду. Первый аргумент — функция-обработчик (`func(*MsgContext, T) error`), второй — имя команды (без слеша).
5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваш контекст базы данных T. 5. **Функции-обработчики**: Получают *MsgContext (детали сообщения, методы типа Answer) и ваши данные приложения типа T, а ошибку возвращают для централизованной обработки.
6. `ErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки. 6. `SetErrorTemplate`: Устанавливает шаблон для сообщений об ошибках. Плейсхолдер %s заменяется на текст ошибки.
7. `AutoGenerateCommands`: Добавляет встроенные команды (/start, /help) и команду, показывающую список всех доступных команд. 7. `AutoGenerateCommands`: Регистрирует команды из плагинов в Telegram для поддерживаемых scope.
8. `Run()`: Запускает цикл опроса обновлений бота. 8. `Run()`: Запускает цикл опроса обновлений бота и возвращает ошибку, если старт или polling завершился неуспешно.
9. `RunWebHookWithContext(...)`: Запускает bot-owned webhook runtime, когда Telegram должен доставлять update по HTTP вместо long polling.
10. Экземпляр `Bot` одноразовый. После завершения `Run()`, `RunWithContext()` или `RunWebHookWithContext()` для следующего запуска создавайте новый бот.
## Конфиг из файла
`BotOpts` можно не только собирать вручную или из environment, но и загружать и сохранять через file codec API.
Из коробки доступно:
- `BotOptsFileJsonCodec` для JSON-файлов.
Пример:
```go
codec := laniakea.BotOptsFileJsonCodec{}
opts, err := laniakea.LoadBotOptsFile(codec, "config.json")
if err != nil {
log.Fatal(err)
}
bot, err := laniakea.NewBot[laniakea.NoData](opts)
if err != nil {
log.Fatal(err)
}
```
Плейсхолдеры вида `{{ TG_TOKEN }}` внутри файла перед декодированием разворачиваются из переменных окружения.
Для других форматов можно реализовать собственный codec через интерфейс `BotOptsFileCodec`.
Из коробки сейчас поддерживается только JSON. Если нужен другой формат, например TOML, используй `BotOptsFileJsonCodec` как эталонную реализацию собственного codec.
Подробности есть в wiki: [Bot Options and Configuration RU](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki/Bot-Options-and-Configuration-RU)
## 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) ### Плагины (Plugins)
Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware. Плагины — основной способ организации кода. Плагин может содержать несколько команд и Middleware.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
bot.AddPlugins(plugin) bot.AddPlugins(plugin)
``` ```
@@ -115,9 +182,10 @@ bot.AddPlugins(plugin)
Команда — это функция, которая обрабатывает конкретную команду бота (например, /start). Команда — это функция, которая обрабатывает конкретную команду бота (например, /start).
```go ```go
func myHandler(ctx *laniakea.MsgContext, db *MyDB) { func myHandler(ctx *laniakea.MsgContext, db *MyDB) error {
// Доступ к аргументам команды через ctx.Args ([]string) // Доступ к аргументам команды через ctx.Args ([]string)
// Ответ пользователю: ctx.Answer("какой-то текст") // Ответ пользователю: ctx.Answer("какой-то текст")
return nil
} }
``` ```
@@ -125,26 +193,78 @@ func myHandler(ctx *laniakea.MsgContext, db *MyDB) {
Предоставляет доступ к входящему сообщению и полезные методы для ответа: Предоставляет доступ к входящему сообщению и полезные методы для ответа:
- `Answer(text string)`: Отправляет сообщение с parse_mode none. - `Answer(text string)`: Отправляет сообщение с parse_mode none.
- `AnswerLong(text string) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений.
- `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне). - `AnswerMarkdown(text string)`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне).
- `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой. - `Keyboard(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение с parse_mode none и Inline клавиатурой.
- `KeyboardLong(text string, keyboard *InlineKeyboard) []*AnswerMessage`: Разбивает длинный plain text на несколько сообщений и вешает клавиатуру на последний chunk.
- `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой. - `KeyboardMarkdown(text string, keyboard *InlineKeyboard) *AnswerMessage`: Отправляет сообщение, отформатированное MarkdownV2 (экранирование на вашей стороне), и Inline клавиатурой.
- `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none. - `AnswerPhoto(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью и parse_mode none.
- `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне). - `AnswerPhotoMarkdown(photoId, text string) *AnswerMessage`: Отправляет фотографию с подписью, отформатированной MarkdownV2 (экранирование на вашей стороне).
- `EditCallback(text string)`: Редактирует сообщение, форматируя его в MarkdownV2 (экранирование на вашей стороне), после нажатия Inline кнопки. - `EditCallback(text string)`: Редактирует сообщение с `parse_mode` none после нажатия inline-кнопки.
- `EditCallbackMarkdown(text string)`: Редактирует сообщение с parse_mode none после нажатия Inline кнопки. - `EditCallbackMarkdown(text string)`: Редактирует сообщение в формате MarkdownV2 (экранирование на вашей стороне) после нажатия inline-кнопки.
- `SendChatAction(action string)`: Отправляет действие "печатает", "загружает фото" и т.д. - `SendAction(action tgapi.ChatActionType)`: Отправляет действие "печатает", "загружает фото" и т.д.
- Поля: `Text`, `Args`, `From`, `Chat`, `Msg` и другие. - Поля: `Text`, `Args`, `From`, `FromID`, `Msg`, `InlineMsgId`, `CallbackQueryId` и другие.
- И много других методов и полей! - И много других методов и полей!
### Контекст базы данных (Database Context) ### App Data
Параметр типа `T` в `NewBot[T]` — мощная функция. Вы можете передать любой тип (например, пул соединений с БД), и он будет доступен в каждом обработчике команды и中间件. Параметр типа `T` в `NewBot[T]` — мощная возможность. Вы можете передать любой тип, но для разделяемых зависимостей вроде пула соединений с БД, контейнера сервисов или API-клиента обычно стоит использовать pointer type.
```go ```go
type MyDB struct { /* ... */ } type MyDB struct { /* ... */ }
db := &MyDB{...} db := &MyDB{...}
bot := laniakea.NewBot[*MyDB](opts, db) // Передаём экземпляр db bot, err := laniakea.NewBot[*MyDB](opts)
if err != nil {
log.Fatal(err)
}
bot.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)
Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста. Middleware — это функции, которые выполняются перед обработчиком команды. Они идеально подходят для сквозных задач, таких как логирование, контроль доступа, ограничение скорости запросов или модификация контекста.
@@ -159,11 +279,12 @@ func(ctx *MsgContext, db T) bool
- Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается). - Если возвращается false, цепочка выполнения немедленно прерывается (команда не запускается).
### Добавление middleware ### Добавление middleware
Используйте метод Use плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления. Используйте метод `AddMiddleware` плагина для добавления одной или нескольких функций middleware. Они выполняются в порядке добавления.
```go ```go
plugin := laniakea.NewPlugin[MyDB]("admin") plugin := laniakea.NewPlugin[*MyDB]("admin")
plugin.Use(loggingMiddleware, adminOnlyMiddleware) plugin.AddMiddleware(laniakea.NewMiddleware("logging", loggingMiddleware))
plugin.AddMiddleware(laniakea.NewMiddleware("admin-only", adminOnlyMiddleware))
plugin.AddCommand(plugin.NewCommand(banUser, "ban")) plugin.AddCommand(plugin.NewCommand(banUser, "ban"))
``` ```
@@ -192,15 +313,25 @@ func adminOnlyMiddleware(ctx *laniakea.MsgContext, db *MyDB) bool {
- Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды. - Middleware может изменять MsgContext (например, добавлять пользовательские поля) перед запуском команды.
## ⚙️ Расширенная настройка ## ⚙️ Расширенная настройка
**Инлайн-клавиатуры**: Создавайте клавиатуры с помощью laniakea.NewKeyboard(). - **Инлайн-клавиатуры**: Создавайте клавиатуры с помощью `laniakea.NewInlineKeyboardJson`, `laniakea.NewInlineKeyboardBase64` или `laniakea.NewInlineKeyboard`. `Bot.SetPayloadType(...)` задаёт payload format по умолчанию, а `InlineKeyboard.SetPayloadType(...)` переопределяет его для конкретной клавиатуры.
**Ограничение запросов**: Передайте настроенный utils.RateLimiter через BotOpts для корректной обработки лимитов Telegram. - **Ограничение запросов**: Передайте настроенный `utils.RateLimiter` через `BotOpts` для корректной обработки лимитов Telegram.
**Пользовательский HTTP-клиент**: Предоставьте свой http.Client в BotOpts для точного контроля. - **Локализация**: `L10n` безопасен для конкурентного использования после подключения к боту.
- **Пользовательские update handlers**: Используйте `plugin.AddUpdateHandler(...)` для Telegram update types вне command/payload flow.
- **Жизненный цикл**: `RunWithContext(...)` и `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). Этот проект лицензирован под GNU General Public License v3.0 - подробности см. в файле [LICENSE](LICENSE).
## 📚 Дополнительная информация ## 📚 Дополнительная информация
[GoDoc Laniakea](https://pkg.go.dev/git.nix13.pw/scuroneko/laniakea) [GoDoc Laniakea](https://pkg.go.dev/git.scuroneko.dev/scuroneko/laniakea)
[Wiki](https://git.scuroneko.dev/ScuroNeko/Laniakea/wiki)
[Telegram Bot API](https://core.telegram.org/bots/api) [Telegram Bot API](https://core.telegram.org/bots/api)
+45
View File
@@ -0,0 +1,45 @@
# Semantic Versioning Policy
This project follows Semantic Versioning with the rules below.
## Public API Surface
The public API consists of:
- exported identifiers in package `laniakea`
- exported identifiers in package `tgapi`
- documented behavior in `README.md`, `README_RU.md`, and package godoc
Anything unexported is internal and may change without notice.
## Breaking Changes
A release requires a major version bump when it changes any of the following:
- exported function, method, type, field, constant, or variable names
- function or method signatures
- JSON field names or request/response wire compatibility in `tgapi`
- documented behavioral guarantees relied on by callers
Examples:
- removing an exported alias
- changing callback payload encoding defaults
- changing handler dispatch semantics in a way that breaks existing bots
## Minor Changes
A release uses a minor version bump for backward-compatible additions:
- new exported types, methods, helpers, or update handlers
- support for new Telegram Bot API fields or methods
- optional configuration knobs that do not change existing defaults
## Patch Changes
A release uses a patch version bump for backward-compatible fixes:
- bug fixes
- test-only changes
- godoc and README clarifications
- internal refactors with no public behavior change
## Pre-Releases
`-rc.N` builds may still adjust API details before `v1.0.0`.
Once `v1.0.0` is released, breaking changes require a new major version.
+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`.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

+213 -315
View File
@@ -4,36 +4,45 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings"
"sync" "sync"
"time" "time"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
"github.com/alitto/pond/v2"
) )
// DbContext is an interface representing the application's database context. // AppData is the generic shared application data type injected into bots,
// It is injected into plugins and middleware via Bot.DatabaseContext(). // plugins, and handlers.
//
// Use it for long-lived shared dependencies such as database handles, service
// containers, API clients, or immutable configuration snapshots.
// //
// Example: // Example:
// //
// type MyDB struct { ... } // type MyDB struct { ... }
// bot := NewBot[MyDB](opts).DatabaseContext(&myDB) // myDB := &MyDB{}
// bot, err := NewBot[*MyDB](opts)
// if err != nil {
// return err
// }
// bot.SetAppData(myDB)
// //
// Use NoDB if no database is needed. // Use NoData if no shared application data is needed.
type DbContext any type AppData any
// NoDB is a placeholder type for bots that do not use a database. // NoData is a placeholder type for bots that do not use shared application
// Use Bot[NoDB] to indicate no dependency injection is required. // data.
type NoDB struct{ DbContext } //
// 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. // AppDataLogger builds a slog.LoggerWriter from injected application data.
// Used to inject database-specific log output (e.g., SQL queries, ORM events). //
type DbLogger[T DbContext] func(db *T) slog.LoggerWriter // 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. // BotPayloadType defines the serialization format for callback data payloads.
type BotPayloadType string type BotPayloadType string
@@ -45,6 +54,20 @@ var (
BotPayloadJson BotPayloadType = "json" BotPayloadJson BotPayloadType = "json"
) )
var (
// ErrNoPrefixes reports that the bot was started without any command prefixes.
ErrNoPrefixes = errors.New("no prefixes defined")
// ErrNoPlugins reports that the bot was started without any registered plugins.
ErrNoPlugins = errors.New("no plugins defined")
// ErrBotAlreadyRun reports that Run, 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. // Bot is the core Telegram bot instance.
// //
// Manages: // Manages:
@@ -54,17 +77,22 @@ var (
// - Logging and rate limiting // - Logging and rate limiting
// - Localization and draft message support // - Localization and draft message support
// //
// All methods are safe for concurrent use. Direct field access is not recommended. // Runtime accessors are safe for concurrent use. Configure the bot before Run,
type Bot[T DbContext] struct { // RunWithContext, or RunWebHookWithContext.
token string // A Bot is single-use: after Run, RunWithContext, or RunWebHookWithContext returns,
debug bool // create a new Bot for the next session.
errorTemplate string type Bot[T AppData] struct {
username string token string
payloadType BotPayloadType debug bool
maxWorkers int errorTemplate string
username string
payloadType BotPayloadType
strictPayloadType bool
maxWorkers int
logger *slog.Logger // Main bot logger (JSON stdout + optional file) logger *slog.Logger // Main bot logger (JSON stdout + optional file)
RequestLogger *slog.Logger // Optional request-level API logging RequestLogger *slog.Logger // Optional request-level API logging
webHookLogger *slog.Logger // Webhook logger. Available only after Bot.RunWebHookWithContext.
extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers extraLoggers extypes.Slice[*slog.Logger] // API, Uploader, and custom loggers
plugins []Plugin[T] // Command/event handlers plugins []Plugin[T] // Command/event handlers
@@ -74,9 +102,16 @@ type Bot[T DbContext] struct {
api *tgapi.API // Telegram API client api *tgapi.API // Telegram API client
uploader *tgapi.Uploader // File uploader uploader *tgapi.Uploader // File uploader
dbContext *T // Injected database context
l10n *L10n // Localization manager l10n *L10n // Localization manager
draftProvider *DraftProvider // Draft message builder 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 updateOffsetMu sync.Mutex
updateOffset int // Last processed update ID updateOffset int // Last processed update ID
@@ -84,6 +119,21 @@ type Bot[T DbContext] struct {
updateQueue chan *tgapi.Update // Internal queue for processing updates updateQueue chan *tgapi.Update // Internal queue for processing updates
runnerOnceWG sync.WaitGroup // Tracks one-time async runners runnerOnceWG sync.WaitGroup // Tracks one-time async runners
runnerBgWG sync.WaitGroup // Tracks background async runners runnerBgWG sync.WaitGroup // Tracks background async runners
runStateMu sync.Mutex
running bool
ran bool
}
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. // NewBot creates and initializes a new Bot instance using the provided BotOpts.
@@ -94,21 +144,16 @@ type Bot[T DbContext] struct {
// - Fetches bot username via GetMe() // - Fetches bot username via GetMe()
// - Sets up DraftProvider with random IDs // - Sets up DraftProvider with random IDs
// - Adds API and Uploader loggers to extraLoggers // - Adds API and Uploader loggers to extraLoggers
// func NewBot[T any](opts *BotOpts) (*Bot[T], error) {
// Panics if: if opts == nil {
// - Token is empty return nil, ErrOptsIsNil
// - GetMe() fails (invalid token or network error) }
func NewBot[T any](opts *BotOpts) *Bot[T] {
if opts.Token == "" { if opts.Token == "" {
panic("laniakea: BotOpts.Token is required") return nil, ErrTokenRequired
} }
updateQueue := make(chan *tgapi.Update, 512) updateQueue := make(chan *tgapi.Update, 512)
//var limiter *utils.RateLimiter
//if opts.RateLimit > 0 {
// limiter = utils.NewRateLimiter()
//}
limiter := utils.NewRateLimiter() limiter := utils.NewRateLimiter()
limiter.SetGlobalRate(opts.RateLimit) limiter.SetGlobalRate(opts.RateLimit)
@@ -131,22 +176,26 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
} }
bot := &Bot[T]{ bot := &Bot[T]{
updateOffset: 0, updateOffset: 0,
errorTemplate: "%s", errorTemplate: "%s",
payloadType: BotPayloadBase64, payloadType: BotPayloadBase64,
maxWorkers: workers, strictPayloadType: opts.StrictPayloadType,
updateQueue: updateQueue, maxWorkers: workers,
api: api, updateQueue: updateQueue,
uploader: uploader, api: api,
debug: opts.Debug, uploader: uploader,
prefixes: prefixes, debug: opts.Debug,
token: opts.Token, prefixes: prefixes,
plugins: make([]Plugin[T], 0), token: opts.Token,
updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...), plugins: make([]Plugin[T], 0),
runners: make([]Runner[T], 0), updateTypes: append([]tgapi.UpdateType{}, opts.UpdateTypes...),
extraLoggers: make([]*slog.Logger, 0), runners: make([]Runner[T], 0),
l10n: &L10n{}, extraLoggers: make([]*slog.Logger, 0),
draftProvider: NewRandomDraftProvider(api), l10n: &L10n{},
draftProvider: NewRandomDraftProvider(api),
sessionStore: NewMemorySessionStore(),
sceneScopePriority: []SceneScope{SceneScopeUserChat, SceneScopeChat, SceneScopeUser},
} }
// Add API and Uploader loggers to extraLoggers for unified output // Add API and Uploader loggers to extraLoggers for unified output
@@ -164,7 +213,7 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
u, err := api.GetMe() u, err := api.GetMe()
if err != nil { if err != nil {
_ = bot.Close() _ = bot.Close()
bot.logger.Fatal(err) return nil, err
} }
bot.username = Val(u.Username, "") bot.username = Val(u.Username, "")
if bot.username == "" { if bot.username == "" {
@@ -172,78 +221,79 @@ func NewBot[T any](opts *BotOpts) *Bot[T] {
} }
bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown"))) bot.logger.Infoln(fmt.Sprintf("Authorized as %s (@%s)", u.FirstName, Val(u.Username, "unknown")))
return bot return bot, nil
} }
// Close gracefully shuts down bot-owned resources. // Close gracefully shuts down bot-owned resources.
// //
// Closes: // Close shuts down, in order:
// - Registered plugins via Plugin.Close
// - Webhook logger (if initialized)
// - Uploader (waits for pending uploads) // - Uploader (waits for pending uploads)
// - API client // - API client internals
// - RequestLogger (if enabled) // - RequestLogger (if enabled)
// - Main logger // - Main logger
// //
// RunWithContext does not call Close automatically. The caller is responsible // RunWithContext and RunWebHookWithContext do not call Close automatically.
// for invoking Close after RunWithContext returns to release these resources. // The caller is responsible for invoking Close after runtime returns to release
// these resources.
// //
// Returns a joined error containing all shutdown failures, if any. // Close returns a joined error containing all shutdown failures, if any.
func (bot *Bot[T]) Close() error { func (bot *Bot[T]) Close() error {
var e []error var e []error
logCloseErr := func(err error) {
if err := bot.uploader.Close(); err != nil { if err == nil {
bot.logger.Errorln(err) return
e = append(e, err) }
} if bot.logger != nil {
if err := bot.api.CloseApi(); err != nil {
bot.logger.Errorln(err)
e = append(e, err)
}
if bot.RequestLogger != nil {
if err := bot.RequestLogger.Close(); err != nil {
bot.logger.Errorln(err) bot.logger.Errorln(err)
}
e = append(e, err)
}
for _, p := range bot.plugins {
if err := p.Close(); err != nil {
e = append(e, err) e = append(e, err)
} }
} }
if err := bot.logger.Close(); err != nil { if bot.webHookLogger != nil {
e = append(e, err) if err := bot.webHookLogger.Close(); err != nil {
logCloseErr(err)
}
bot.webHookLogger = nil
}
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 {
logCloseErr(err)
}
}
if bot.logger != nil {
if err := bot.logger.Close(); err != nil {
e = append(e, err)
}
} }
return errors.Join(e...) return errors.Join(e...)
} }
// initLoggers configures the main and optional request loggers. // CloseRemote sends Telegram Bot API "close" request for the current bot
// instance using ctx for cancellation and deadlines.
// //
// Uses DEBUG flag to set log level (DEBUG if true, FATAL otherwise). // This is separate from Bot.Close(), which only releases local resources.
// Writes to stdout in JSON format by default. func (bot *Bot[T]) CloseRemote(ctx context.Context) error {
// If WriteToFile is true, writes to main.log and requests.log in LoggerBasePath. if _, err := bot.api.CloseRemoteWithContext(ctx); err != nil {
func (bot *Bot[T]) initLoggers(opts *BotOpts) { return err
level := slog.FATAL
if opts.Debug {
level = slog.DEBUG
}
bot.logger = slog.CreateLogger().Level(level).Prefix("BOT")
bot.logger.AddWriter(bot.logger.CreateJsonStdoutWriter())
if opts.WriteToFile {
path := fmt.Sprintf("%s/main.log", strings.TrimRight(opts.LoggerBasePath, "/"))
fileWriter, err := bot.logger.CreateTextFileWriter(path)
if err != nil {
bot.logger.Fatal(err)
}
bot.logger.AddWriter(fileWriter)
}
if opts.UseRequestLogger {
bot.RequestLogger = slog.CreateLogger().Level(level).Prefix("REQUESTS")
bot.RequestLogger.AddWriter(bot.RequestLogger.CreateJsonStdoutWriter())
if opts.WriteToFile {
path := fmt.Sprintf("%s/requests.log", strings.TrimRight(opts.LoggerBasePath, "/"))
fileWriter, err := bot.RequestLogger.CreateTextFileWriter(path)
if err != nil {
bot.logger.Fatal(err)
}
bot.RequestLogger.AddWriter(fileWriter)
}
} }
return nil
} }
// GetUpdateOffset returns the current update offset (thread-safe). // GetUpdateOffset returns the current update offset (thread-safe).
@@ -260,15 +310,18 @@ func (bot *Bot[T]) SetUpdateOffset(offset int) {
bot.updateOffset = offset 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. // GetLogger returns the main bot logger.
func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger } func (bot *Bot[T]) GetLogger() *slog.Logger { return bot.logger }
// GetDBContext returns the injected database context. // GetLoggerLevel returns the effective log level derived from the bot's debug
// Returns nil if not set via DatabaseContext(). // flag.
func (bot *Bot[T]) GetDBContext() *T { return bot.dbContext } func (bot *Bot[T]) GetLoggerLevel() slog.LogLevel {
level := slog.FATAL
if bot.debug {
level = slog.DEBUG
}
return level
}
// L10n translates a key in the given language. // L10n translates a key in the given language.
// Returns empty string if translation not found. // Returns empty string if translation not found.
@@ -276,181 +329,6 @@ func (bot *Bot[T]) L10n(lang, key string) string {
return bot.l10n.Translate(lang, key) 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
return bot
}
// AddPlugins registers one or more plugins.
// Plugins are executed in registration order unless filtered by middleware.
func (bot *Bot[T]) AddPlugins(plugin ...*Plugin[T]) *Bot[T] {
for _, p := range plugin {
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
//
// 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)
}
return bot
}
// RunWithContext starts the bot with a given context for graceful shutdown. // RunWithContext starts the bot with a given context for graceful shutdown.
// //
// This is the main entry point for bot execution. It: // This is the main entry point for bot execution. It:
@@ -464,31 +342,29 @@ func (bot *Bot[T]) AddDatabaseLoggerWriter(writer DbLogger[T]) *Bot[T] {
// - Finishes processing currently queued updates // - Finishes processing currently queued updates
// - Waits for registered runners to exit // - 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. // RunWithContext does not close API, uploader, or logger resources on return.
// The caller must invoke Close after RunWithContext finishes. // The caller must invoke Close after RunWithContext finishes.
// //
// Example: // A Bot is single-use. After RunWithContext returns, later calls return ErrBotAlreadyRun.
// func (bot *Bot[T]) RunWithContext(ctx context.Context) error {
// ctx, cancel := context.WithCancel(context.Background())
// go bot.RunWithContext(ctx)
// // ... later ...
// cancel() // triggers graceful shutdown
// _ = bot.Close()
func (bot *Bot[T]) RunWithContext(ctx context.Context) {
if len(bot.prefixes) == 0 { if len(bot.prefixes) == 0 {
bot.logger.Fatalln("no prefixes defined") return ErrNoPrefixes
return
} }
if len(bot.plugins) == 0 { if len(bot.plugins) == 0 {
bot.logger.Fatalln("no plugins defined") return ErrNoPlugins
return
} }
if err := bot.beginRun(); err != nil {
return err
}
defer bot.finishRun()
bot.ExecRunners(ctx) bot.ExecRunners(ctx)
bot.logger.Infoln("Bot running. Press CTRL+C to exit.")
// Start update polling in a goroutine // Start update polling in a goroutine
go func() { go func() {
defer func() { defer func() {
@@ -497,6 +373,8 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
} }
close(bot.updateQueue) close(bot.updateQueue)
}() }()
retryDelay := time.Duration(0)
retryCount := 0
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -504,16 +382,40 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
default: default:
updates, err := bot.Updates(ctx) updates, err := bot.Updates(ctx)
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
return
}
bot.logger.Errorln("failed to fetch updates:", err) bot.logger.Errorln("failed to fetch updates:", err)
time.Sleep(time.Second) // exponential backoff retryDelay = nextPollRetryDelay(retryDelay)
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 continue
} }
retryDelay = 0
retryCount = 0
for _, update := range updates { for _, update := range updates {
u := update // copy loop variable to avoid race condition if err := bot.enqueueUpdate(ctx, update); err != nil {
select {
case bot.updateQueue <- &u:
case <-ctx.Done():
return return
} }
} }
@@ -521,17 +423,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 // Start worker pool for concurrent update handling
pool := pond.NewPool(bot.maxWorkers) bot.startUpdateWorkers(ctx)
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.runnerOnceWG.Wait() bot.runnerOnceWG.Wait()
bot.runnerBgWG.Wait() bot.runnerBgWG.Wait()
return nil
} }
// Run starts the bot using a background context. // Run starts the bot using a background context.
@@ -540,6 +438,6 @@ func (bot *Bot[T]) RunWithContext(ctx context.Context) {
// Use this for simple bots where graceful shutdown is not required. // Use this for simple bots where graceful shutdown is not required.
// //
// For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully. // For production use, prefer RunWithContext to handle SIGINT/SIGTERM gracefully.
func (bot *Bot[T]) Run() { func (bot *Bot[T]) Run() error {
bot.RunWithContext(context.Background()) return bot.RunWithContext(context.Background())
} }
+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" "strconv"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// BotOpts holds configuration options for initializing a Bot. // BotOpts holds configuration options for initializing a Bot.
// //
// Values are loaded from environment variables via LoadOptsFromEnv(). // Values are loaded from environment variables via LoadOptsFromEnv().
// Use NewOpts() to create a zero-value struct and set fields manually. // Use &BotOpts{} to create a value and set fields manually.
type BotOpts struct { type BotOpts struct {
// Token is the Telegram bot token (required). // Token is the Telegram bot token (required).
Token string Token string
@@ -56,7 +56,11 @@ type BotOpts struct {
// Use this to prioritize responsiveness over reliability. // Use this to prioritize responsiveness over reliability.
DropRLOverflow bool DropRLOverflow bool
// MaxWorkers is the maximum number of concurrency running update handlers. // StrictPayloadType disables callback payload fallback decoding.
// When enabled, the bot accepts only the configured default payload type.
StrictPayloadType bool
// MaxWorkers is the maximum number of update handlers that may run concurrently.
MaxWorkers int MaxWorkers int
} }
@@ -75,20 +79,31 @@ type BotOpts struct {
// - API_URL: custom API endpoint // - API_URL: custom API endpoint
// - RATE_LIMIT: max requests per second (default: 30) // - RATE_LIMIT: max requests per second (default: 30)
// - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow // - DROP_RL_OVERFLOW: "true" to drop updates on rate limit overflow
// - STRICT_PAYLOAD_TYPE: "true" to reject callback payloads encoded in a different format
// - MAX_WORKERS: maximum number of concurrent update handlers (default: 32)
// //
// Returns a populated BotOpts. If TG_TOKEN is missing, behavior is undefined. // Returns a populated BotOpts.
// NewBot validates required fields and returns ErrTokenRequired when TG_TOKEN is missing.
func LoadOptsFromEnv() *BotOpts { func LoadOptsFromEnv() *BotOpts {
rateLimit := 30 rateLimit := 30
maxWorkers := 32
stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES"))
updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes))
for _, updateType := range stringUpdateTypes {
updateTypes = append(updateTypes, tgapi.UpdateType(updateType))
}
if rl := os.Getenv("RATE_LIMIT"); rl != "" { if rl := os.Getenv("RATE_LIMIT"); rl != "" {
if n, err := strconv.Atoi(rl); err == nil { if n, err := strconv.Atoi(rl); err == nil {
rateLimit = n rateLimit = n
} }
} }
stringUpdateTypes := splitEnvList(os.Getenv("UPDATE_TYPES")) if mw := os.Getenv("MAX_WORKERS"); mw != "" {
updateTypes := make([]tgapi.UpdateType, 0, len(stringUpdateTypes)) if n, err := strconv.Atoi(os.Getenv("MAX_WORKERS")); err == nil {
for _, updateType := range stringUpdateTypes { maxWorkers = n
updateTypes = append(updateTypes, tgapi.UpdateType(updateType)) }
} }
return &BotOpts{ return &BotOpts{
@@ -106,8 +121,11 @@ func LoadOptsFromEnv() *BotOpts {
UseTestServer: os.Getenv("USE_TEST_SERVER") == "true", UseTestServer: os.Getenv("USE_TEST_SERVER") == "true",
APIUrl: os.Getenv("API_URL"), APIUrl: os.Getenv("API_URL"),
RateLimit: rateLimit, RateLimit: rateLimit,
DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true", DropRLOverflow: os.Getenv("DROP_RL_OVERFLOW") == "true",
StrictPayloadType: os.Getenv("STRICT_PAYLOAD_TYPE") == "true",
MaxWorkers: maxWorkers,
} }
} }
@@ -196,6 +214,13 @@ func (opts *BotOpts) SetDropRLOverflow(drop bool) *BotOpts {
return opts return opts
} }
// SetStrictPayloadType enables or disables strict callback payload decoding.
// When enabled, the bot accepts only the configured default payload type.
func (opts *BotOpts) SetStrictPayloadType(strict bool) *BotOpts {
opts.StrictPayloadType = strict
return opts
}
// SetMaxWorkers sets the maximum number of concurrent update handlers. // SetMaxWorkers sets the maximum number of concurrent update handlers.
// Must be called before NewBot, as the value is captured during bot creation. // Must be called before NewBot, as the value is captured during bot creation.
// //
+162
View File
@@ -0,0 +1,162 @@
package laniakea
import (
"encoding/json"
"io"
"os"
"regexp"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
// BotOptsFileJson is the JSON file representation of BotOpts.
type BotOptsFileJson struct {
Token string `json:"token"`
UpdateTypes []tgapi.UpdateType `json:"update_types"`
Debug bool `json:"debug"`
ErrorTemplate string `json:"error_template"`
Prefixes []string `json:"prefixes"`
Logger struct {
LoggerBasePath string `json:"base_path"`
UseRequestLogger bool `json:"use_request_logger"`
WriteToFile bool `json:"write_to_file"`
} `json:"logger"`
API struct {
UseTestServer bool `json:"use_test_server"`
APIUrl string `json:"url"`
RateLimit int `json:"rate_limit"`
DropRLOverflow bool `json:"drop_overflow"`
} `json:"api"`
StrictPayloadType bool `json:"strict_payload_type"`
MaxWorkers int `json:"max_workers"`
}
// BotOptsFileJsonCodec encodes and decodes BotOpts using BotOptsFileJson.
type BotOptsFileJsonCodec struct{}
// FromBytes decodes BotOpts from JSON file bytes.
func (codec BotOptsFileJsonCodec) FromBytes(data []byte) (*BotOpts, error) {
fileOpts := new(BotOptsFileJson)
err := json.Unmarshal(data, fileOpts)
if err != nil {
return nil, err
}
opts := &BotOpts{
Token: fileOpts.Token,
UpdateTypes: fileOpts.UpdateTypes,
Debug: fileOpts.Debug,
ErrorTemplate: fileOpts.ErrorTemplate,
Prefixes: fileOpts.Prefixes,
LoggerBasePath: fileOpts.Logger.LoggerBasePath,
UseRequestLogger: fileOpts.Logger.UseRequestLogger,
WriteToFile: fileOpts.Logger.WriteToFile,
UseTestServer: fileOpts.API.UseTestServer,
APIUrl: fileOpts.API.APIUrl,
RateLimit: fileOpts.API.RateLimit,
DropRLOverflow: fileOpts.API.DropRLOverflow,
StrictPayloadType: fileOpts.StrictPayloadType,
MaxWorkers: fileOpts.MaxWorkers,
}
return opts, nil
}
// ToBytes encodes BotOpts into JSON file bytes.
func (codec BotOptsFileJsonCodec) ToBytes(opts *BotOpts) ([]byte, error) {
fileOpts := &BotOptsFileJson{
Token: opts.Token,
UpdateTypes: opts.UpdateTypes,
Debug: opts.Debug,
ErrorTemplate: opts.ErrorTemplate,
Prefixes: opts.Prefixes,
Logger: struct {
LoggerBasePath string `json:"base_path"`
UseRequestLogger bool `json:"use_request_logger"`
WriteToFile bool `json:"write_to_file"`
}{
LoggerBasePath: opts.LoggerBasePath,
UseRequestLogger: opts.UseRequestLogger,
WriteToFile: opts.WriteToFile,
},
API: struct {
UseTestServer bool `json:"use_test_server"`
APIUrl string `json:"url"`
RateLimit int `json:"rate_limit"`
DropRLOverflow bool `json:"drop_overflow"`
}{
UseTestServer: opts.UseTestServer,
APIUrl: opts.APIUrl,
RateLimit: opts.RateLimit,
DropRLOverflow: opts.DropRLOverflow,
},
StrictPayloadType: opts.StrictPayloadType,
MaxWorkers: opts.MaxWorkers,
}
data, err := json.Marshal(fileOpts)
if err != nil {
return nil, err
}
return data, nil
}
func (codec BotOptsFileJsonCodec) Load(filename string) (*BotOpts, error) {
return LoadBotOptsFile(codec, filename)
}
func (codec BotOptsFileJsonCodec) Save(filename string, opts *BotOpts) error {
return SaveBotOptsFile(codec, filename, opts)
}
var envParameterRegex = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`)
// BotOptsFileCodec decodes and encodes BotOpts file formats.
type BotOptsFileCodec interface {
FromBytes([]byte) (*BotOpts, error)
ToBytes(*BotOpts) ([]byte, error)
Load(filename string) (*BotOpts, error)
Save(filename string, opts *BotOpts) error
}
// LoadBotOptsFile reads a config file, expands env placeholders, and decodes BotOpts.
func LoadBotOptsFile(codec BotOptsFileCodec, filename string) (*BotOpts, error) {
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer func() { _ = f.Close() }()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
data = expandEnvPlaceholdersInFile(data)
return codec.FromBytes(data)
}
// SaveBotOptsFile encodes BotOpts with codec and writes the result to filename.
func SaveBotOptsFile(codec BotOptsFileCodec, filename string, opts *BotOpts) error {
data, err := codec.ToBytes(opts)
if err != nil {
return err
}
err = os.WriteFile(filename, data, 0644)
if err != nil {
return err
}
return nil
}
func expandEnvPlaceholdersInFile(data []byte) []byte {
return envParameterRegex.ReplaceAllFunc(data, func(match []byte) []byte {
group := envParameterRegex.FindSubmatch(match)
if len(group) != 2 {
return match
}
key := group[1]
value := os.Getenv(string(key))
return []byte(value)
})
}
+116
View File
@@ -0,0 +1,116 @@
package laniakea
import (
"os"
"path/filepath"
"reflect"
"testing"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func TestBotOptsFileJsonCodecRoundTrip(t *testing.T) {
codec := BotOptsFileJsonCodec{}
want := &BotOpts{
Token: "TOKEN",
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage, tgapi.UpdateTypeCallbackQuery},
Debug: true,
ErrorTemplate: "Error: %s",
Prefixes: []string{"/", "!"},
LoggerBasePath: "/tmp/logs",
UseRequestLogger: true,
WriteToFile: true,
UseTestServer: true,
APIUrl: "https://api.example.invalid",
RateLimit: 42,
DropRLOverflow: true,
StrictPayloadType: true,
MaxWorkers: 64,
}
data, err := codec.ToBytes(want)
if err != nil {
t.Fatalf("ToBytes returned error: %v", err)
}
got, err := codec.FromBytes(data)
if err != nil {
t.Fatalf("FromBytes returned error: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("round-trip mismatch:\n got: %#v\nwant: %#v", got, want)
}
}
func TestLoadBotOptsFileExpandsEnvPlaceholders(t *testing.T) {
t.Setenv("TG_TOKEN", "TOKEN_FROM_ENV")
t.Setenv("BOT_API_URL", "https://api.example.invalid")
dir := t.TempDir()
filename := filepath.Join(dir, "config.json")
data := []byte(`{
"token": "{{ TG_TOKEN }}",
"api": {
"url": "{{BOT_API_URL}}"
},
"error_template": "Error: %s"
}`)
if err := os.WriteFile(filename, data, 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename)
if err != nil {
t.Fatalf("LoadBotOptsFile returned error: %v", err)
}
if got.Token != "TOKEN_FROM_ENV" {
t.Fatalf("unexpected token: got %q want %q", got.Token, "TOKEN_FROM_ENV")
}
if got.APIUrl != "https://api.example.invalid" {
t.Fatalf("unexpected api url: got %q want %q", got.APIUrl, "https://api.example.invalid")
}
if got.ErrorTemplate != "Error: %s" {
t.Fatalf("unexpected error template: got %q", got.ErrorTemplate)
}
}
func TestLoadBotOptsFileReturnsDecodeError(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "config.json")
if err := os.WriteFile(filename, []byte(`{"token":`), 0o644); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
if _, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename); err == nil {
t.Fatal("expected decode error, got nil")
}
}
func TestSaveBotOptsFileWritesEncodedData(t *testing.T) {
dir := t.TempDir()
filename := filepath.Join(dir, "config.json")
want := &BotOpts{
Token: "TOKEN",
UpdateTypes: []tgapi.UpdateType{tgapi.UpdateTypeMessage},
ErrorTemplate: "Error: %s",
Prefixes: []string{"/"},
APIUrl: "https://api.example.invalid",
RateLimit: 30,
MaxWorkers: 32,
}
if err := SaveBotOptsFile(BotOptsFileJsonCodec{}, filename, want); err != nil {
t.Fatalf("SaveBotOptsFile returned error: %v", err)
}
got, err := LoadBotOptsFile(BotOptsFileJsonCodec{}, filename)
if err != nil {
t.Fatalf("LoadBotOptsFile returned error: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("saved file mismatch:\n got: %#v\nwant: %#v", got, want)
}
}
+10 -1
View File
@@ -4,7 +4,7 @@ import (
"reflect" "reflect"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) { func TestLoadOptsFromEnvIgnoresEmptyUpdateTypes(t *testing.T) {
@@ -45,3 +45,12 @@ func TestLoadPrefixesFromEnvDropsEmptyValues(t *testing.T) {
t.Fatalf("unexpected prefixes: got %v want %v", got, want) t.Fatalf("unexpected prefixes: got %v want %v", got, want)
} }
} }
func TestLoadOptsFromEnvReadsStrictPayloadType(t *testing.T) {
t.Setenv("STRICT_PAYLOAD_TYPE", "true")
opts := LoadOptsFromEnv()
if !opts.StrictPayloadType {
t.Fatal("expected StrictPayloadType to be enabled")
}
}
+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))
}
}
+179
View File
@@ -0,0 +1,179 @@
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).AddReplacer(bot.token, "<TOKEN>")
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).AddReplacer(bot.token, "<TOKEN>")
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,
messageFallback: p.messageFallback,
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(bot, opts))
}
r.HandleFunc(opts.Path, updateHandler(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" "errors"
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// CmdRegexp matches command names allowed for Telegram command registration. // CmdRegexp matches command names allowed for Telegram command registration.
var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$") var CmdRegexp = regexp.MustCompile("^[_a-z0-9]{1,32}$")
// ErrTooManyCommands is returned when the total number of registered commands // ErrTooManyCommands is returned when the total number of registered commands
// exceeds Telegram's limit of 100 bot commands per bot. // exceeds Telegram's limit of 100 bot commands per bot.
@@ -20,7 +21,7 @@ var CmdRegexp = regexp.MustCompile("^[a-zA-Z0-9]+$")
// bot initialization. // bot initialization.
var ErrTooManyCommands = errors.New("too many commands. max 100") var ErrTooManyCommands = errors.New("too many commands. max 100")
// generateBotCommand builds a BotCommand description with generated usage text. // Internal helper to build a BotCommand description with generated usage text.
func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand { func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
desc := "" desc := ""
if len(cmd.description) > 0 { if len(cmd.description) > 0 {
@@ -44,13 +45,20 @@ func generateBotCommand[T any](cmd *Command[T]) tgapi.BotCommand {
return tgapi.BotCommand{Command: cmd.command, Description: usage} return tgapi.BotCommand{Command: cmd.command, Description: usage}
} }
// checkCmdRegex reports whether cmd matches CmdRegexp. // Internal helper to validate Telegram command names.
func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) } func checkCmdRegex(cmd string) bool { return CmdRegexp.MatchString(cmd) }
// gatherCommandsForPlugin collects non-skipped, valid commands from one plugin. // Internal helper to collect non-skipped, valid commands from one plugin.
func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand { func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0) commands := make([]tgapi.BotCommand, 0)
for _, cmd := range pl.commands { names := make([]string, 0, len(pl.commands))
for name := range pl.commands {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
cmd := pl.commands[name]
if cmd.skipAutoCmd { if cmd.skipAutoCmd {
continue continue
} }
@@ -62,9 +70,7 @@ func gatherCommandsForPlugin[T any](pl Plugin[T]) []tgapi.BotCommand {
return commands return commands
} }
// gatherCommands collects all commands from all plugins // Internal helper to collect all auto-generated commands from registered plugins.
// and converts them into tgapi.BotCommand objects.
// See gatherCommandsForPlugin.
func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand { func gatherCommands[T any](bot *Bot[T]) []tgapi.BotCommand {
commands := make([]tgapi.BotCommand, 0) commands := make([]tgapi.BotCommand, 0)
for _, pl := range bot.plugins { for _, pl := range bot.plugins {
@@ -106,7 +112,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
} }
// Clear existing commands to avoid duplication or stale entries // Clear existing commands to avoid duplication or stale entries
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{}) _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{})
if err != nil { if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err) return fmt.Errorf("failed to delete existing commands: %w", err)
} }
@@ -119,7 +125,7 @@ func (bot *Bot[T]) AutoGenerateCommands() error {
} }
for _, scope := range scopes { for _, scope := range scopes {
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{ _, err = bot.api.SetMyCommands(tgapi.SetMyCommands{
Commands: commands, Commands: commands,
Scope: scope, Scope: scope,
}) })
@@ -153,12 +159,12 @@ func (bot *Bot[T]) AutoGenerateCommandsForScope(scope *tgapi.BotCommandScope) er
return ErrTooManyCommands return ErrTooManyCommands
} }
_, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommandsP{Scope: scope}) _, err := bot.api.DeleteMyCommands(tgapi.DeleteMyCommands{Scope: scope})
if err != nil { if err != nil {
return fmt.Errorf("failed to delete existing commands: %w", err) return fmt.Errorf("failed to delete existing commands: %w", err)
} }
_, err = bot.api.SetMyCommands(tgapi.SetMyCommandsP{ _, err = bot.api.SetMyCommands(tgapi.SetMyCommands{
Commands: commands, Commands: commands,
Scope: scope, Scope: scope,
}) })
+29 -8
View File
@@ -4,13 +4,14 @@ import (
"errors" "errors"
"io" "io"
"net/http" "net/http"
"reflect"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -37,21 +38,21 @@ func TestAutoGenerateCommandsChecksLimitBeforeDelete(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
plugin := NewPlugin[NoDB]("overflow") plugin := NewPlugin[NoData]("overflow")
exec := func(ctx *MsgContext, db *NoDB) {} exec := func(ctx *MsgContext, db NoData) error { return nil }
for i := 0; i < 101; i++ { for i := 0; i < 101; i++ {
plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i))) plugin.AddCommand(NewCommand(exec, "cmd"+strconv.Itoa(i)))
} }
bot := &Bot[NoDB]{ bot := &Bot[NoData]{
api: api, api: api,
logger: slog.CreateLogger(), logger: slog.CreateLogger(),
plugins: []Plugin[NoDB]{*plugin}, plugins: []Plugin[NoData]{*plugin},
} }
err := bot.AutoGenerateCommands() 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()) 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. Package laniakea provides a modular, extensible framework for building scalable Telegram bots.
It offers a fluent API for configuration and separates concerns through several core concepts: Core concepts:
- Bot: The central instance managing API communication, update processing, logging, - Bot manages Telegram API access, update processing, logging, rate limiting, and dependency injection.
rate limiting, and dependency injection. Created via NewBot[T]. - Plugins group commands, payloads, and non-command update handlers behind shared middleware.
- MsgContext provides access to the current update and reply/edit/delete helpers.
- Plugins: Organize commands and payloads into reusable units. - InlineKeyboard builds callback-driven keyboards and structured payloads.
A plugin can have multiple commands and shared middlewares. - DraftProvider accumulates multi-step replies before sending them.
- L10n stores key-based translations with fallback behavior.
- Commands: Named bot commands with descriptions, argument validation, and - Runners execute startup or background tasks alongside the polling loop.
execution logic. Automatically registrable across different chat scopes.
- Middleware: Functions that intercept and modify updates before they reach plugins.
Useful for authentication, logging, validation, etc. Return false to stop processing.
- MsgContext: Provides access to the incoming update and convenient methods for
responding, editing, deleting, and translating messages. Includes built-in rate limiting
and error handling. ⚠️ MarkdownV2 methods require manual escaping via EscapeMarkdownV2().
- InlineKeyboard: A fluent builder for constructing inline keyboards with styled buttons,
icons, URLs, and structured callback data (JSON or Base64).
- DraftProvider: Manages ephemeral, multi-step message drafts with automatic ID generation
(random or linear). Drafts can be built incrementally and flushed atomically.
- L10n: Simple key-based localization system with fallback language support.
- Runners: Background goroutines for periodic tasks or oneoff initialization,
with configurable timeouts and async execution.
- RateLimiting & Logging: Builtin rate limiter (respects Telegram's retry_after)
and structured logging (JSON stdout + optional file output) with requestlevel tracing.
- Dependency Injection: Pass any custom database context (e.g., *sql.DB) to all handlers
via the type parameter T in Bot[T].
Example usage: Example usage:
bot := laniakea.NewBot[mydb.DBContext](laniakea.LoadOptsFromEnv()). bot, err := laniakea.NewBot[*mydb.AppData](laniakea.LoadOptsFromEnv())
DatabaseContext(&myDB). if err != nil {
return err
}
bot.SetAppData(myDB).
AddUpdateType(tgapi.UpdateTypeMessage). AddUpdateType(tgapi.UpdateTypeMessage).
AddPrefixes("/", "!"). AddPrefixes("/", "!").
AddPlugins(&startPlugin, &helpPlugin). AddPlugins(&startPlugin, &helpPlugin).
AddMiddleware(&authMiddleware, &logMiddleware). AddMiddleware(authMiddleware, logMiddleware).
AddRunner(&cleanupRunner). AddRunner(cleanupRunner).
AddL10n(l10n.New()) SetL10n(l10n.New())
bot.Run() return bot.Run()
All public methods are safe for concurrent use unless stated otherwise. Configure bots, plugins, and localization before starting Run, RunWithContext, or RunWebHookWithContext.
Direct field access is not recommended; use provided accessors (e.g., GetDBContext, SetUpdateOffset). Runtime accessors are safe for concurrent use unless stated otherwise.
*/ */
package laniakea package laniakea
+17 -18
View File
@@ -1,18 +1,14 @@
package laniakea package laniakea
import ( import (
"errors"
"math/rand/v2" "math/rand/v2"
"sync" "sync"
"sync/atomic" "sync/atomic"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// ErrDraftChatIDZero is returned when a draft is used without setting a chat ID. // Interface for generating unique draft IDs.
var ErrDraftChatIDZero = errors.New("zero draft chat ID")
// draftIdGenerator defines an interface for generating unique draft IDs.
type draftIdGenerator interface { type draftIdGenerator interface {
// Next returns the next unique draft ID. // Next returns the next unique draft ID.
Next() uint64 Next() uint64
@@ -38,12 +34,9 @@ func (g *LinearDraftIdGenerator) Next() uint64 {
return g.lastId.Add(1) return g.lastId.Add(1)
} }
// DraftProvider manages a collection of Drafts and provides methods to create and // DraftProvider manages a collection of Drafts and a shared draft ID generator.
// configure them. It holds shared configuration (chat, parse mode, entities) and
// a draft ID generator.
// //
// DraftProvider is NOT thread-safe. Concurrent access from multiple goroutines // DraftProvider is safe for concurrent use.
// requires external synchronization.
type DraftProvider struct { type DraftProvider struct {
mu sync.RWMutex mu sync.RWMutex
api *tgapi.API api *tgapi.API
@@ -133,10 +126,7 @@ type Draft struct {
// NewDraft creates a new draft with the provided parse mode. // NewDraft creates a new draft with the provided parse mode.
// //
// The draft inherits the provider's chatID, messageThreadID, and entities. // The caller must set a chat with SetChat before Push or Flush.
// If parseMode is zero, the provider's default parseMode is used.
//
// Panics if chatID is zero — call SetChat() on the provider first.
func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft { func (p *DraftProvider) NewDraft(parseMode tgapi.ParseMode) *Draft {
id := p.generator.Next() id := p.generator.Next()
draft := &Draft{ draft := &Draft{
@@ -224,8 +214,14 @@ func (d *Draft) Flush() error {
if d.Message == "" { if d.Message == "" {
return nil return nil
} }
if d.chatID == 0 {
return ErrDraftChatIDZero
}
if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageP{ params := tgapi.SendMessage{
ChatID: d.chatID, ChatID: d.chatID,
ParseMode: d.parseMode, ParseMode: d.parseMode,
Entities: d.entities, Entities: d.entities,
@@ -242,13 +238,16 @@ func (d *Draft) Flush() error {
return err return err
} }
// push is the internal helper for Push(). It updates the server draft via SendMessageDraft. // Internal helper for Push that updates the server-side draft.
func (d *Draft) push(text string) error { func (d *Draft) push(text string) error {
if d.chatID == 0 { if d.chatID == 0 {
return ErrDraftChatIDZero return ErrDraftChatIDZero
} }
d.Message += text d.Message += text
params := tgapi.SendMessageDraftP{ if err := validateMessageText(d.Message); err != nil {
return err
}
params := tgapi.SendMessageDraft{
ChatID: d.chatID, ChatID: d.chatID,
DraftID: d.ID, DraftID: d.ID,
Text: d.Message, 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
}
+8 -6
View File
@@ -1,17 +1,19 @@
module git.nix13.pw/scuroneko/laniakea module git.scuroneko.dev/scuroneko/laniakea
go 1.26 go 1.26
retract v1.0.0-rc.5
require ( require (
git.nix13.pw/scuroneko/extypes v1.2.2 git.scuroneko.dev/scuroneko/extypes v1.2.3
git.nix13.pw/scuroneko/slog v1.1.2 git.scuroneko.dev/scuroneko/slog v1.2.0
github.com/alitto/pond/v2 v2.7.0 github.com/alitto/pond/v2 v2.7.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
) )
require ( require (
github.com/fatih/color v1.18.0 // indirect github.com/fatih/color v1.19.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.21 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.43.0 // indirect
) )
+10 -11
View File
@@ -1,17 +1,16 @@
git.nix13.pw/scuroneko/extypes v1.2.2 h1:N54c1ejrPs1yfIkvYuwqI7B1+8S9mDv2GqQA6sct4dk= git.scuroneko.dev/scuroneko/extypes v1.2.3 h1:n7QsfTZEn9fJNZLXGH/LkNq4cADaRk+LTu6LNMv9y6s=
git.nix13.pw/scuroneko/extypes v1.2.2/go.mod h1:b4XYk1OW1dVSiE2MT/OMuX/K/UItf1swytX6eroVYnk= git.scuroneko.dev/scuroneko/extypes v1.2.3/go.mod h1:MhYpXC6sloLOpoM2guf64eSOrz+ET/QJZ8toobc3Ors=
git.nix13.pw/scuroneko/slog v1.1.2 h1:pl7tV5FN25Yso7sLYoOgBXi9+jLo5BDJHWmHlNPjpY0= git.scuroneko.dev/scuroneko/slog v1.2.0 h1:xbwzrMcmN0NG/zTgEn508mn2JVnfZN5z/Zsi3PREfDM=
git.nix13.pw/scuroneko/slog v1.1.2/go.mod h1:UcfRIHDqpVQHahBGM93awLDK8//AsAvOqBwwbWqMkjM= git.scuroneko.dev/scuroneko/slog v1.2.0/go.mod h1:r+oz9NzvvdtWd9/PjeS+n5vQoNHL38BdcdLoBtJPvFU=
github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg= github.com/alitto/pond/v2 v2.7.0 h1:c76L+yN916m/DRXjGCeUBHHu92uWnh/g1bwVk4zyyXg=
github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE= github.com/alitto/pond/v2 v2.7.0/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+111 -126
View File
@@ -1,151 +1,116 @@
package laniakea package laniakea
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "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. // ErrInvalidPayloadType is returned when callback payload encoding type is unknown.
var ErrInvalidPayloadType = errors.New("invalid payload type") var ErrInvalidPayloadType = errors.New("invalid payload type")
func (bot *Bot[T]) handle(u *tgapi.Update) { func (bot *Bot[T]) handle(parentCtx context.Context, u *tgapi.Update) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r)) bot.logger.Errorln(fmt.Sprintf("panic in handle: %v", r))
} }
}() }()
startTime := time.Now()
ctx := &MsgContext{ ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
msgCtx := &MsgContext{
Update: *u, Api: bot.api, Update: *u, Api: bot.api,
botLogger: bot.logger, Logger: bot.logger,
errorTemplate: bot.errorTemplate, errorTemplate: bot.errorTemplate,
l10n: bot.l10n, l10n: bot.l10n,
draftProvider: bot.draftProvider, draftProvider: bot.draftProvider,
sceneRuntime: bot,
observer: bot.observer,
payloadType: bot.payloadType, 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 { for _, middleware := range bot.middlewares {
if !middleware.Execute(ctx, bot.dbContext) { if !middleware.Execute(msgCtx, bot.appData) {
return return
} }
} }
if u.CallbackQuery != nil { sceneHandled, err := bot.tryHandleScene(msgCtx)
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
}
plugin.executeCmd(cmd, ctx, bot.dbContext)
return
}
}
}
func (bot *Bot[T]) handleCallback(update *tgapi.Update, ctx *MsgContext) {
data, err := bot.decodePayload(update.CallbackQuery.Data)
if err != nil { if err != nil {
bot.logger.Errorln(err) 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 return
} }
ctx.FromID = update.CallbackQuery.From.ID handled := false
ctx.From = &update.CallbackQuery.From switch u.Type {
if update.CallbackQuery.Message != nil { case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost:
ctx.Msg = update.CallbackQuery.Message handled = bot.handleMessage(u, msgCtx)
ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID case tgapi.UpdateTypeCallbackQuery:
} handled = bot.handleCallback(u, msgCtx)
if update.CallbackQuery.InlineMessageID != nil { default:
ctx.InlineMsgId = *update.CallbackQuery.InlineMessageID handled = bot.handleUpdate(u, msgCtx)
}
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
}
plugin.executePayload(data.Command, ctx, bot.dbContext)
return
} }
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) { func cloneMsgContext(src *MsgContext) *MsgContext {
for _, prefix := range bot.prefixes { cloned := *src
if prefix == "" { if src.Args != nil {
continue cloned.Args = append([]string(nil), src.Args...)
}
if strings.HasPrefix(text, prefix) {
return prefix, true
}
} }
return "", false return &cloned
} }
func encodeJsonPayload(d CallbackData) (string, error) { func encodeJsonPayload(d CallbackData) (string, error) {
@@ -155,11 +120,13 @@ func encodeJsonPayload(d CallbackData) (string, error) {
} }
return string(b), nil return string(b), nil
} }
func decodeJsonPayload(s string) (CallbackData, error) { func decodeJsonPayload(s string) (CallbackData, error) {
var data CallbackData var data CallbackData
err := json.Unmarshal([]byte(s), &data) err := json.Unmarshal([]byte(s), &data)
return data, err return data, err
} }
func encodeBase64Payload(d CallbackData) (string, error) { func encodeBase64Payload(d CallbackData) (string, error) {
data, err := encodeJsonPayload(d) data, err := encodeJsonPayload(d)
if err != nil { if err != nil {
@@ -170,15 +137,6 @@ func encodeBase64Payload(d CallbackData) (string, error) {
return string(dst), nil 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) { func decodeBase64Payload(s string) (CallbackData, error) {
b, err := base64.RawURLEncoding.DecodeString(s) b, err := base64.RawURLEncoding.DecodeString(s)
if err != nil { if err != nil {
@@ -186,19 +144,46 @@ func decodeBase64Payload(s string) (CallbackData, error) {
} }
return decodeJsonPayload(string(b)) return decodeJsonPayload(string(b))
} }
func decodePayload(payloadType BotPayloadType, s string) (CallbackData, error) {
func decodePayload(payloadType BotPayloadType, s string, strict bool) (CallbackData, BotPayloadType, error) {
switch payloadType { switch payloadType {
case BotPayloadBase64: case BotPayloadBase64:
return decodeBase64Payload(s) data, err := decodeBase64Payload(s)
if err == nil {
return data, BotPayloadBase64, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadBase64)
}
data, err = decodeJsonPayload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadJson, nil
case BotPayloadJson: case BotPayloadJson:
return decodeJsonPayload(s) data, err := decodeJsonPayload(s)
if err == nil {
return data, BotPayloadJson, nil
}
if strict {
return CallbackData{}, "", fmt.Errorf("%w: expected %s", ErrPayloadTypeMismatch, BotPayloadJson)
}
data, err = decodeBase64Payload(s)
if err != nil {
return CallbackData{}, "", err
}
return data, BotPayloadBase64, nil
} }
return CallbackData{}, ErrInvalidPayloadType return CallbackData{}, "", ErrInvalidPayloadType
} }
// func (bot *Bot[T]) encodePayload(d CallbackData) (string, error) {
// return encodePayload(bot.payloadType, d)
// }
func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) { func (bot *Bot[T]) decodePayload(s string) (CallbackData, error) {
return decodePayload(bot.payloadType, s) data, decodedType, err := decodePayload(bot.payloadType, s, bot.strictPayloadType)
if err != nil {
return CallbackData{}, err
}
if decodedType == BotPayloadBase64 && bot.debug && bot.logger != nil {
bot.logger.Debugf("decoded callback payload base64->json: raw=%q json=%s", s, data.ToJson())
}
return data, nil
} }
+1221 -2
View File
File diff suppressed because it is too large Load Diff
+27 -20
View File
@@ -3,17 +3,16 @@ package laniakea
import ( import (
"fmt" "fmt"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// ButtonStyleDanger, ButtonStyleSuccess, ButtonStylePrimary are predefined
// Telegram keyboard button styles for visual feedback.
//
// These values map directly to Telegram Bot API's InlineKeyboardButton style field.
const ( const (
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger" // ButtonStyleDanger marks a destructive inline keyboard action.
ButtonStyleDanger tgapi.KeyboardButtonStyle = "danger"
// ButtonStyleSuccess marks a confirmatory inline keyboard action.
ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success" ButtonStyleSuccess tgapi.KeyboardButtonStyle = "success"
// ButtonStylePrimary marks a primary inline keyboard action.
ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary" ButtonStylePrimary tgapi.KeyboardButtonStyle = "primary"
) )
@@ -83,8 +82,7 @@ func (b InlineKbButtonBuilder) SetCallbackDataBase64(cmd string, args ...any) In
return b return b
} }
// build converts the builder state into a tgapi.InlineKeyboardButton. // Internal helper that converts the builder state into a Telegram button.
// This method is typically called internally by InlineKeyboard.AddButton().
func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton { func (b InlineKbButtonBuilder) build() tgapi.InlineKeyboardButton {
return tgapi.InlineKeyboardButton{ return tgapi.InlineKeyboardButton{
Text: b.text, Text: b.text,
@@ -138,16 +136,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. // AddCallbackButton and AddCallbackButtonStyle methods.
// It should be one of BotPayloadJson or BotPayloadBase64. // It overrides the bot's default payload type for this keyboard only.
func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard { func (in *InlineKeyboard) SetPayloadType(t BotPayloadType) *InlineKeyboard {
in.payloadType = t in.payloadType = t
return in return in
} }
// append adds a button to the current line. If the line is full, it auto-flushes. // GetPayloadType returns the keyboard-local callback payload encoding type.
// This is an internal helper used by other builder methods. func (in *InlineKeyboard) GetPayloadType() BotPayloadType { return in.payloadType }
// 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 { func (in *InlineKeyboard) append(button tgapi.InlineKeyboardButton) *InlineKeyboard {
if in.CurrentLine.Len() == in.maxRow { if in.CurrentLine.Len() == in.maxRow {
in.AddLine() in.AddLine()
@@ -235,12 +242,12 @@ type CallbackData struct {
// (int, string, bool, float64) but may not serialize complex structs meaningfully. // (int, string, bool, float64) but may not serialize complex structs meaningfully.
// //
// Use this to build callback payloads for bot command routing. // Use this to build callback payloads for bot command routing.
func NewCallbackData(command string, args ...any) *CallbackData { func NewCallbackData(command string, args ...any) CallbackData {
stringArgs := make([]string, len(args)) stringArgs := make([]string, len(args))
for i, arg := range args { for i, arg := range args {
stringArgs[i] = fmt.Sprint(arg) stringArgs[i] = fmt.Sprint(arg)
} }
return &CallbackData{ return CallbackData{
Command: command, Command: command,
Args: stringArgs, Args: stringArgs,
} }
@@ -253,8 +260,8 @@ func NewCallbackData(command string, args ...any) *CallbackData {
// //
// This fallback ensures the bot receives a valid JSON payload even if internal // This fallback ensures the bot receives a valid JSON payload even if internal
// errors occur — avoiding "invalid callback_data" errors from Telegram. // errors occur — avoiding "invalid callback_data" errors from Telegram.
func (d *CallbackData) ToJson() string { func (d CallbackData) ToJson() string {
data, err := encodeJsonPayload(*d) data, err := encodeJsonPayload(d)
if err != nil { if err != nil {
// Fallback: return minimal valid JSON to avoid Telegram API rejection // Fallback: return minimal valid JSON to avoid Telegram API rejection
return `{"cmd":""}` return `{"cmd":""}`
@@ -264,8 +271,8 @@ func (d *CallbackData) ToJson() string {
// ToBase64 serializes the CallbackData to a JSON string and then encodes it as Base64. // ToBase64 serializes the CallbackData to a JSON string and then encodes it as Base64.
// Returns an empty string if serialization or encoding fails. // Returns an empty string if serialization or encoding fails.
func (d *CallbackData) ToBase64() string { func (d CallbackData) ToBase64() string {
s, err := encodeBase64Payload(*d) s, err := encodeBase64Payload(d)
if err != nil { if err != nil {
return `` return ``
} }
@@ -275,7 +282,7 @@ func (d *CallbackData) ToBase64() string {
// Encode serializes the CallbackData according to the specified payload type. // Encode serializes the CallbackData according to the specified payload type.
// Supported types: BotPayloadJson and BotPayloadBase64. // Supported types: BotPayloadJson and BotPayloadBase64.
// For unknown types, returns an empty string. // For unknown types, returns an empty string.
func (d *CallbackData) Encode(t BotPayloadType) string { func (d CallbackData) Encode(t BotPayloadType) string {
switch t { switch t {
case BotPayloadBase64: case BotPayloadBase64:
return d.ToBase64() return d.ToBase64()
+97
View File
@@ -0,0 +1,97 @@
package laniakea
import (
"errors"
"reflect"
"strings"
"testing"
)
func TestInlineKeyboardWrapsRowsAndEncodesJSONPayloads(t *testing.T) {
kb := NewInlineKeyboardJson(2).
AddCallbackButton("A", "cmd", 1).
AddCallbackButton("B", "cmd", 2).
AddCallbackButton("C", "cmd", 3)
markup := kb.Get()
if got := len(markup.InlineKeyboard); got != 2 {
t.Fatalf("unexpected row count: %d", got)
}
if got := len(markup.InlineKeyboard[0]); got != 2 {
t.Fatalf("unexpected first row size: %d", got)
}
if got := len(markup.InlineKeyboard[1]); got != 1 {
t.Fatalf("unexpected second row size: %d", got)
}
if !strings.Contains(markup.InlineKeyboard[0][0].CallbackData, `"cmd":"cmd"`) {
t.Fatalf("expected JSON callback payload, got %q", markup.InlineKeyboard[0][0].CallbackData)
}
}
func TestInlineKeyboardBuilderPreservesConfiguredButtonFields(t *testing.T) {
kb := NewInlineKeyboardBase64(3).
AddButton(
NewInlineKbButton("Docs").
SetStyle(ButtonStylePrimary).
SetUrl("https://example.test"),
)
button := kb.Get().InlineKeyboard[0][0]
if button.Style != ButtonStylePrimary {
t.Fatalf("unexpected style: %q", button.Style)
}
if button.URL != "https://example.test" {
t.Fatalf("unexpected url: %q", button.URL)
}
}
func TestInlineKeyboardGetPayloadTypeReturnsLocalOverride(t *testing.T) {
kb := NewInlineKeyboardJson(2)
if got := kb.GetPayloadType(); got != BotPayloadJson {
t.Fatalf("unexpected initial payload type: %q", got)
}
kb.SetPayloadType(BotPayloadBase64)
if got := kb.GetPayloadType(); got != BotPayloadBase64 {
t.Fatalf("unexpected updated payload type: %q", got)
}
}
func TestDecodePayloadAcceptsBase64KeyboardPayloadWhenBotPrefersJSON(t *testing.T) {
kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, false)
if err != nil {
t.Fatalf("decodePayload returned error: %v", err)
}
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
}
}
func TestDecodePayloadAcceptsJSONKeyboardPayloadWhenBotPrefersBase64(t *testing.T) {
kb := NewInlineKeyboardJson(1).
AddCallbackButton("A", "cmd", 1, "two")
got, _, err := decodePayload(BotPayloadBase64, kb.Get().InlineKeyboard[0][0].CallbackData, false)
if err != nil {
t.Fatalf("decodePayload returned error: %v", err)
}
want := CallbackData{Command: "cmd", Args: []string{"1", "two"}}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected payload: got %#v want %#v", got, want)
}
}
func TestDecodePayloadStrictRejectsMismatchedType(t *testing.T) {
kb := NewInlineKeyboardBase64(1).
AddCallbackButton("A", "cmd", 1)
_, _, err := decodePayload(BotPayloadJson, kb.Get().InlineKeyboard[0][0].CallbackData, true)
if !errors.Is(err, ErrPayloadTypeMismatch) {
t.Fatalf("expected ErrPayloadTypeMismatch, got %v", err)
}
}
+33 -38
View File
@@ -1,21 +1,18 @@
package laniakea package laniakea
// DictEntry represents a single localized entry with language-to-text mappings. import "sync"
// Example: {"ru": "Привет", "en": "Hello"}.
// DictEntry maps language codes to translated strings.
type DictEntry map[string]string type DictEntry map[string]string
// L10n is a localization manager that maps keys to language-specific strings. // L10n stores translations with a configurable fallback language and is safe for concurrent use.
type L10n struct { type L10n struct {
entries map[string]DictEntry // Map of translation keys to language dictionaries mu sync.RWMutex
fallbackLang string // Language code to use when requested language is missing entries map[string]DictEntry
fallbackLang string
} }
// NewL10n creates a new L10n instance with the specified fallback language. // NewL10n creates a localization store with the given fallback language.
// The fallback language is used when a requested language is not available
// for a given key.
//
// Example: NewL10n("en") will return "Hello" for key "greeting" if "ru" is requested
// but no "ru" entry exists.
func NewL10n(fallbackLanguage string) *L10n { func NewL10n(fallbackLanguage string) *L10n {
return &L10n{ return &L10n{
entries: make(map[string]DictEntry), entries: make(map[string]DictEntry),
@@ -23,54 +20,52 @@ func NewL10n(fallbackLanguage string) *L10n {
} }
} }
// AddDictEntry adds a new translation entry for the given key. // AddDictEntry stores translations for key.
// The value must be a DictEntry mapping language codes (e.g., "en", "ru") to their translated strings.
//
// If a key already exists, it is overwritten.
//
// Returns the L10n instance for method chaining.
func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n { func (l *L10n) AddDictEntry(key string, value DictEntry) *L10n {
l.entries[key] = value l.mu.Lock()
defer l.mu.Unlock()
if l.entries == nil {
l.entries = make(map[string]DictEntry)
}
l.entries[key] = cloneDictEntry(value)
return l return l
} }
// GetFallbackLanguage returns the currently configured fallback language code. // GetFallbackLanguage returns the currently configured fallback language code.
func (l *L10n) GetFallbackLanguage() string { func (l *L10n) GetFallbackLanguage() string {
l.mu.RLock()
defer l.mu.RUnlock()
return l.fallbackLang return l.fallbackLang
} }
// Translate retrieves the translation for the given key and language. // Translate returns the translation for key in lang, falling back to the configured language or the key itself.
//
// Behavior:
// - If the key exists and the language has a translation → returns the translation
// - If the key exists but the language is missing → returns the fallback language's value
// - If the key does not exist → returns the key string itself (as fallback)
//
// Example:
//
// l.AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Привет"})
// l.Translate("en", "greeting") → "Hello"
// l.Translate("es", "greeting") → "Hello" (fallback to "en")
// l.Translate("en", "unknown") → "unknown" (key not found)
//
// This behavior ensures that missing translations do not break UI or logs —
// instead, the original key is displayed, making it easy to identify gaps.
func (l *L10n) Translate(lang, key string) string { func (l *L10n) Translate(lang, key string) string {
l.mu.RLock()
defer l.mu.RUnlock()
entries, exists := l.entries[key] entries, exists := l.entries[key]
if !exists { if !exists {
return key // Return key as fallback when translation is missing return key
} }
// Try requested language
if translation, ok := entries[lang]; ok { if translation, ok := entries[lang]; ok {
return translation return translation
} }
// Fall back to configured fallback language
if fallback, ok := entries[l.fallbackLang]; ok { if fallback, ok := entries[l.fallbackLang]; ok {
return fallback return fallback
} }
// If fallback language is also missing, return the key
return key return key
} }
func cloneDictEntry(src DictEntry) DictEntry {
if src == nil {
return nil
}
cloned := make(DictEntry, len(src))
for lang, text := range src {
cloned[lang] = text
}
return cloned
}
+77
View File
@@ -0,0 +1,77 @@
package laniakea
import (
"fmt"
"sync"
"testing"
)
func TestL10nTranslateUsesFallbackAndKey(t *testing.T) {
l10n := NewL10n("en").
AddDictEntry("greeting", DictEntry{"en": "Hello", "ru": "Privet"}).
AddDictEntry("partial", DictEntry{"ru": "Tolko ru"})
tests := []struct {
name string
lang string
key string
want string
}{
{name: "exact match", lang: "ru", key: "greeting", want: "Privet"},
{name: "fallback language", lang: "es", key: "greeting", want: "Hello"},
{name: "missing fallback returns key", lang: "en", key: "partial", want: "partial"},
{name: "unknown key returns key", lang: "en", key: "unknown", want: "unknown"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := l10n.Translate(tt.lang, tt.key); got != tt.want {
t.Fatalf("unexpected translation: got %q want %q", got, tt.want)
}
})
}
}
func TestL10nAddDictEntryCopiesInput(t *testing.T) {
l10n := NewL10n("en")
entry := DictEntry{"en": "Hello"}
l10n.AddDictEntry("greeting", entry)
entry["en"] = "Mutated"
if got := l10n.Translate("en", "greeting"); got != "Hello" {
t.Fatalf("unexpected translation after external mutation: got %q", got)
}
}
func TestL10nZeroValueIsUsable(t *testing.T) {
var l10n L10n
l10n.AddDictEntry("greeting", DictEntry{"en": "Hello"})
if got := l10n.Translate("en", "greeting"); got != "Hello" {
t.Fatalf("unexpected translation from zero-value l10n: got %q", got)
}
}
func TestL10nConcurrentAccess(t *testing.T) {
l10n := NewL10n("en")
l10n.AddDictEntry("base", DictEntry{"en": "Hello"})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 100; j++ {
l10n.AddDictEntry(fmt.Sprintf("key-%d-%d", i, j), DictEntry{"en": "value"})
_ = l10n.Translate("en", "base")
}
}(i)
}
wg.Wait()
if got := l10n.Translate("en", "base"); got != "Hello" {
t.Fatalf("unexpected translation after concurrent access: got %q", got)
}
}
+3 -3
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
) )
// Updates fetches new updates from Telegram API using long polling. // Updates fetches new updates from Telegram API using long polling.
@@ -43,8 +43,8 @@ import (
func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) { func (bot *Bot[T]) Updates(ctx context.Context) ([]tgapi.Update, error) {
offset := bot.GetUpdateOffset() offset := bot.GetUpdateOffset()
params := tgapi.UpdateParams{ params := tgapi.UpdateParams{
Offset: Ptr(offset), Offset: new(offset),
Timeout: Ptr(30), Timeout: new(30),
AllowedUpdates: bot.GetUpdateTypes(), AllowedUpdates: bot.GetUpdateTypes(),
} }
+397 -66
View File
@@ -2,36 +2,87 @@ package laniakea
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log"
"reflect"
"strconv"
"strings"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// MsgContext holds the context for handling a Telegram message or callback query. // MsgContext holds the normalized per-update context passed to command, payload,
// It provides methods to respond, edit, delete, and translate messages, as well as // scene, middleware, and generic update handlers.
// manage inline keyboards and message drafts. //
// 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 { type MsgContext struct {
Api *tgapi.API Api *tgapi.API
Update tgapi.Update 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 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
InlineMsgId string // Logger is the logger assigned by the matched plugin for the current handler call.
CallbackMsgId int // It may fall back to the bot logger when the plugin has no dedicated logger.
Logger *slog.Logger
// 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 CallbackQueryId string
FromID int64 // FromID is the normalized sender ID when the current update exposes a user.
Prefix string // It is zero when the update has no user identity.
Text string FromID int64
Args []string // 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 errorTemplate string
botLogger *slog.Logger
l10n *L10n l10n *L10n
draftProvider *DraftProvider draftProvider *DraftProvider
payloadType BotPayloadType payloadType BotPayloadType
sceneRuntime sceneRuntime
observer Observer
ctx context.Context
} }
// AnswerMessage represents a message sent or edited via MsgContext. // AnswerMessage represents a message sent or edited via MsgContext.
@@ -43,10 +94,13 @@ type AnswerMessage struct {
ctx *MsgContext // internal back-reference ctx *MsgContext // internal back-reference
} }
// edit is an internal helper to edit a message's text with optional keyboard and parse mode. // Internal helper for text edits with optional keyboard and parse mode.
// Used by Edit, EditMarkdown, EditCallback, etc.
func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
params := tgapi.EditMessageTextP{ if err := validateMessageText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageText{
Text: text, Text: text,
ParseMode: parseMode, ParseMode: parseMode,
} }
@@ -57,15 +111,15 @@ func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard
case ctx.InlineMsgId != "": case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId params.InlineMessageID = ctx.InlineMsgId
default: default:
ctx.botLogger.Errorln("Can't edit message: no valid message target") ctx.Logger.Errorln(ErrEditTargetMissing)
return nil return nil
} }
if keyboard != nil { if keyboard != nil {
params.ReplyMarkup = keyboard.Get() params.ReplyMarkup = keyboard.Get()
} }
msg, _, err := ctx.Api.EditMessageText(params) msg, _, err := ctx.Api.EditMessageTextWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
resultMessageID := messageId resultMessageID := messageId
@@ -91,11 +145,10 @@ func (m *AnswerMessage) EditMarkdown(text string) *AnswerMessage {
return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2) return m.ctx.edit(m.MessageID, text, nil, tgapi.ParseMDV2)
} }
// editCallback is an internal helper to edit the message associated with a callback query. // Internal helper for editing callback-linked messages.
// Supports both regular callback messages and inline callback messages.
func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) editCallback(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" { if ctx.CallbackMsgId == 0 && ctx.InlineMsgId == "" {
ctx.botLogger.Errorln("Can't edit non-callback update message") ctx.Logger.Errorln(ErrCallbackMessageMissing)
return nil return nil
} }
return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode) return ctx.edit(ctx.CallbackMsgId, text, keyboard, parseMode)
@@ -125,10 +178,13 @@ func (ctx *MsgContext) EditCallbackfMarkdown(format string, keyboard *InlineKeyb
return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2) return ctx.editCallback(fmt.Sprintf(format, args...), keyboard, tgapi.ParseMDV2)
} }
// editPhotoText edits the caption of a photo/video message. // Internal helper for media-caption edits.
// Returns nil when no valid edit target is available for the current context.
func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
params := tgapi.EditMessageCaptionP{ if err := validateCaptionText(text); err != nil {
ctx.Logger.Errorln(err)
return nil
}
params := tgapi.EditMessageCaption{
Caption: text, Caption: text,
ParseMode: parseMode, ParseMode: parseMode,
} }
@@ -139,16 +195,16 @@ func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeybo
case ctx.InlineMsgId != "": case ctx.InlineMsgId != "":
params.InlineMessageID = ctx.InlineMsgId params.InlineMessageID = ctx.InlineMsgId
default: default:
ctx.botLogger.Errorln("Can't edit caption: no valid message target") ctx.Logger.Errorln(ErrEditTargetMissing)
return nil return nil
} }
if kb != nil { if kb != nil {
params.ReplyMarkup = kb.Get() params.ReplyMarkup = kb.Get()
} }
msg, _, err := ctx.Api.EditMessageCaption(params) msg, _, err := ctx.Api.EditMessageCaptionWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
resultMessageID := messageId resultMessageID := messageId
@@ -184,14 +240,17 @@ func (m *AnswerMessage) EditCaptionKeyboardMarkdown(text string, kb *InlineKeybo
return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2) return m.ctx.editPhotoText(m.MessageID, text, kb, tgapi.ParseMDV2)
} }
// answer sends a new message with optional keyboard and parse mode. // Internal helper for message replies with optional keyboard and parse mode.
// Uses API limiter to respect Telegram rate limits per chat.
func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't answer message without a message") ctx.Logger.Errorln(ErrMessageContextNil)
return nil 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, ChatID: ctx.Msg.Chat.ID,
Text: text, Text: text,
ParseMode: parseMode, ParseMode: parseMode,
@@ -206,9 +265,9 @@ func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard, parseMode t
params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID params.DirectMessagesTopicID = ctx.Msg.DirectMessageTopic.TopicID
} }
msg, err := ctx.Api.SendMessage(params) msg, err := ctx.Api.SendMessageWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
return &AnswerMessage{ return &AnswerMessage{
@@ -221,6 +280,14 @@ func (ctx *MsgContext) Answer(text string) *AnswerMessage {
return ctx.answer(text, nil, tgapi.ParseNone) return ctx.answer(text, nil, tgapi.ParseNone)
} }
// AnswerLong sends one or more plain-text messages if text exceeds Telegram's limit.
//
// The text is split into Telegram-safe chunks. Returned messages preserve send
// order. If a chunk fails to send, already-sent messages are returned.
func (ctx *MsgContext) AnswerLong(text string) []*AnswerMessage {
return ctx.answerLong(text, nil, tgapi.ParseNone)
}
// AnswerMarkdown sends a message using MarkdownV2 formatting. // AnswerMarkdown sends a message using MarkdownV2 formatting.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -233,6 +300,11 @@ func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage {
return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone) return ctx.answer(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
} }
// AnswerLongf formats a string using fmt.Sprintf and sends it as one or more plain-text messages.
func (ctx *MsgContext) AnswerLongf(template string, args ...any) []*AnswerMessage {
return ctx.answerLong(fmt.Sprintf(template, args...), nil, tgapi.ParseNone)
}
// AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2. // AnswerfMarkdown formats a string using fmt.Sprintf and sends it using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -245,6 +317,13 @@ func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage
return ctx.answer(text, kb, tgapi.ParseNone) return ctx.answer(text, kb, tgapi.ParseNone)
} }
// KeyboardLong sends long plain text split across multiple messages.
//
// The inline keyboard is attached only to the final chunk.
func (ctx *MsgContext) KeyboardLong(text string, kb *InlineKeyboard) []*AnswerMessage {
return ctx.answerLong(text, kb, tgapi.ParseNone)
}
// KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2. // KeyboardMarkdown sends a message with an inline keyboard using MarkdownV2.
// //
// ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here. // ⚠️ WARNING: User input must be escaped with laniakea.EscapeMarkdownV2() before passing here.
@@ -252,13 +331,56 @@ func (ctx *MsgContext) KeyboardMarkdown(text string, keyboard *InlineKeyboard) *
return ctx.answer(text, keyboard, tgapi.ParseMDV2) return ctx.answer(text, keyboard, tgapi.ParseMDV2)
} }
// answerPhoto sends a photo with optional caption and keyboard. func (ctx *MsgContext) answerLong(text string, keyboard *InlineKeyboard, parseMode tgapi.ParseMode) []*AnswerMessage {
func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, parseMode tgapi.ParseMode) *AnswerMessage { if parseMode != tgapi.ParseNone {
if ctx.Msg == nil { ctx.Logger.Errorln(ErrMessageSplitImpossible)
ctx.botLogger.Errorln("Can't answer message without a message")
return nil 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, ChatID: ctx.Msg.Chat.ID,
Caption: text, Caption: text,
ParseMode: parseMode, ParseMode: parseMode,
@@ -274,9 +396,9 @@ func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard, par
params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID) params.DirectMessagesTopicID = int(ctx.Msg.DirectMessageTopic.TopicID)
} }
msg, err := ctx.Api.SendPhoto(params) msg, err := ctx.Api.SendPhotoWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
return nil return nil
} }
return &AnswerMessage{ return &AnswerMessage{
@@ -320,22 +442,22 @@ func (ctx *MsgContext) AnswerPhotofMarkdown(photoId, template string, args ...an
return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2) return ctx.answerPhoto(photoId, fmt.Sprintf(template, args...), nil, tgapi.ParseMDV2)
} }
// delete removes a message by ID. // Internal helper that deletes a message by ID.
func (ctx *MsgContext) delete(messageId int) { func (ctx *MsgContext) delete(messageId int) {
if messageId == 0 { if messageId == 0 {
ctx.botLogger.Errorln("Can't delete message: message ID zero") ctx.Logger.Errorln(ErrMessageIDZero)
return return
} }
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't delete message: no chat message context") ctx.Logger.Errorln(ErrMessageContextNil)
return return
} }
_, err := ctx.Api.DeleteMessage(tgapi.DeleteMessageP{ _, err := ctx.Api.DeleteMessageWithContext(ctx.Context(), tgapi.DeleteMessage{
ChatID: ctx.Msg.Chat.ID, ChatID: ctx.Msg.Chat.ID,
MessageID: messageId, MessageID: messageId,
}) })
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
} }
} }
@@ -345,24 +467,23 @@ func (m *AnswerMessage) Delete() { m.ctx.delete(m.MessageID) }
// CallbackDelete deletes the message that triggered the callback query. // CallbackDelete deletes the message that triggered the callback query.
func (ctx *MsgContext) CallbackDelete() { func (ctx *MsgContext) CallbackDelete() {
if ctx.CallbackMsgId == 0 { if ctx.CallbackMsgId == 0 {
ctx.botLogger.Errorln("Can't delete callback message: no callback message ID") ctx.Logger.Errorln(ErrCallbackMessageMissing)
return return
} }
ctx.delete(ctx.CallbackMsgId) ctx.delete(ctx.CallbackMsgId)
} }
// answerCallbackQuery sends a response to a callback query (optional text/alert/url). // Internal helper that answers a callback query with optional text, alert, or URL.
// Does nothing if CallbackQueryId is empty.
func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) { func (ctx *MsgContext) answerCallbackQuery(url, text string, showAlert bool) {
if len(ctx.CallbackQueryId) == 0 { if len(ctx.CallbackQueryId) == 0 {
return return
} }
_, err := ctx.Api.AnswerCallbackQuery(tgapi.AnswerCallbackQueryP{ _, err := ctx.Api.AnswerCallbackQueryWithContext(ctx.Context(), tgapi.AnswerCallbackQuery{
CallbackQueryID: ctx.CallbackQueryId, CallbackQueryID: ctx.CallbackQueryId,
Text: text, ShowAlert: showAlert, URL: url, Text: text, ShowAlert: showAlert, URL: url,
}) })
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
} }
} }
@@ -381,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. // SendAction sends a chat action (typing, uploading_photo, etc.) to indicate bot activity.
func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) { func (ctx *MsgContext) SendAction(action tgapi.ChatActionType) {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("Can't send action without chat message context") ctx.Logger.Errorln("Can't send action without chat message context")
return return
} }
params := tgapi.SendChatActionP{ params := tgapi.SendChatAction{
ChatID: ctx.Msg.Chat.ID, Action: action, ChatID: ctx.Msg.Chat.ID, Action: action,
} }
if ctx.Msg.MessageThreadID > 0 { if ctx.Msg.MessageThreadID > 0 {
params.MessageThreadID = ctx.Msg.MessageThreadID params.MessageThreadID = ctx.Msg.MessageThreadID
} }
_, err := ctx.Api.SendChatAction(params) _, err := ctx.Api.SendChatActionWithContext(ctx.Context(), params)
if err != nil { if err != nil {
ctx.botLogger.Errorln(err) ctx.Logger.Errorln(err)
} }
} }
// error sends an error message to the user and logs it. // Internal helper that formats, sends, and logs an error.
// Uses errorTemplate to format the message.
// For callbacks: sends as callback answer (no alert).
// For regular messages: sends as plain text.
func (ctx *MsgContext) error(err error) { func (ctx *MsgContext) error(err error) {
if err == nil {
return
}
ctx.Logger.Errorln(err)
if IsInternalError(err) {
return
}
text := fmt.Sprintf(ctx.errorTemplate, err.Error()) text := fmt.Sprintf(ctx.errorTemplate, err.Error())
if ctx.CallbackQueryId != "" { if ctx.CallbackQueryId != "" {
@@ -408,7 +533,6 @@ func (ctx *MsgContext) error(err error) {
} else { } else {
ctx.answer(text, nil, tgapi.ParseNone) ctx.answer(text, nil, tgapi.ParseNone)
} }
ctx.botLogger.Errorln(err)
} }
// Error is an alias for error(). // Error is an alias for error().
@@ -416,15 +540,25 @@ func (ctx *MsgContext) Error(err error) { ctx.error(err) }
func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft { func (ctx *MsgContext) newDraft(parseMode tgapi.ParseMode) *Draft {
if ctx.Msg == nil { if ctx.Msg == nil {
ctx.botLogger.Errorln("can't create draft: ctx.Msg is nil") ctx.Logger.Errorln(ErrMessageContextNil)
return nil
}
if ctx.Api == nil {
ctx.Logger.Errorln(ErrAPIIsNil)
return nil
}
if ctx.draftProvider == nil {
ctx.Logger.Errorln(ErrDraftProviderNil)
return nil return nil
} }
c, cancel := context.WithTimeout(context.Background(), 5*time.Second) if ctx.Api.Limiter != nil {
defer cancel() c, cancel := context.WithTimeout(ctx.Context(), 5*time.Second)
if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil { defer cancel()
ctx.botLogger.Errorln(err) if err := ctx.Api.Limiter.Wait(c, ctx.Msg.Chat.ID); err != nil {
return nil ctx.Logger.Errorln(err)
return nil
}
} }
draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID) draft := ctx.draftProvider.NewDraft(parseMode).SetChat(ctx.Msg.Chat.ID, ctx.Msg.MessageThreadID)
@@ -459,3 +593,200 @@ func (ctx *MsgContext) Translate(key string) string {
func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard { func (ctx *MsgContext) NewInlineKeyboard(maxRow int) *InlineKeyboard {
return NewInlineKeyboard(ctx.payloadType, maxRow) return NewInlineKeyboard(ctx.payloadType, maxRow)
} }
func bindPositional(args []string, dst any) error {
v := reflect.ValueOf(dst)
if v.Kind() != reflect.Pointer || v.IsNil() {
return ErrBindArgsTargetNotPointer
}
v = v.Elem()
if v.Kind() != reflect.Struct {
return ErrBindArgsTargetNotStruct
}
t := v.Type()
fields := make([]int, 0, v.NumField())
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
if !field.CanSet() {
continue
}
fields = append(fields, i)
}
argIndex := 0
for fieldPos, fieldIndex := range fields {
field := v.Field(fieldIndex)
fieldType := t.Field(fieldIndex)
if argIndex >= len(args) {
// Leave trailing fields at their zero values when arguments run out.
break
}
isLastBindableField := fieldPos == len(fields)-1
raw := args[argIndex]
if isLastBindableField && field.Kind() == reflect.String {
raw = strings.Join(args[argIndex:], " ")
}
switch field.Kind() {
case reflect.String:
field.SetString(raw)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
n, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetUint(n)
case reflect.Float32, reflect.Float64:
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetFloat(f)
case reflect.Bool:
b, err := strconv.ParseBool(raw)
if err != nil {
return fmt.Errorf("%w: field %s: %v", ErrBindArgsConversion, fieldType.Name, err)
}
field.SetBool(b)
default:
return fmt.Errorf("%w: field %s: %s", ErrBindArgsUnsupportedFieldType, fieldType.Name, field.Kind())
}
if isLastBindableField && field.Kind() == reflect.String {
break
}
argIndex++
}
return nil
}
// BindArgs binds positional command arguments from ctx.Args into dst.
//
// Exported struct fields are filled in declaration order. When fewer arguments
// are provided than fields, the remaining fields keep their zero values. If the
// final bindable field is a string, it receives the remaining arguments joined
// with spaces.
func (ctx *MsgContext) BindArgs(dst any) error {
return bindPositional(ctx.Args, dst)
}
// Context returns the request-scoped context associated with the current update.
func (ctx *MsgContext) Context() context.Context {
if ctx.ctx == nil {
return context.Background()
}
return ctx.ctx
}
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)
}
+410 -6
View File
@@ -2,13 +2,15 @@ package laniakea
import ( import (
"encoding/json" "encoding/json"
"errors"
"io" "io"
"net/http" "net/http"
"reflect"
"strings" "strings"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) { func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
@@ -37,18 +39,18 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
ctx := &MsgContext{ ctx := &MsgContext{
Api: api, Api: api,
Msg: &tgapi.Message{ 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}, DirectMessageTopic: &tgapi.DirectMessageTopic{TopicID: 77},
}, },
botLogger: slog.CreateLogger(), Logger: slog.CreateLogger(),
} }
answer := ctx.AnswerPhoto("photo-id", "caption") answer := ctx.AnswerPhoto("photo-id", "caption")
@@ -62,3 +64,405 @@ func TestAnswerPhotoIncludesDirectMessagesTopicID(t *testing.T) {
t.Fatalf("unexpected direct_messages_topic_id: %v", got) t.Fatalf("unexpected direct_messages_topic_id: %v", got)
} }
} }
func TestBindArgsBindsScalarFields(t *testing.T) {
type input struct {
ID int
Active bool
Score float64
Name string
}
ctx := &MsgContext{Args: []string{"42", "true", "3.5", "Ada", "Lovelace"}}
var got input
if err := ctx.BindArgs(&got); err != nil {
t.Fatalf("BindArgs returned error: %v", err)
}
want := input{
ID: 42,
Active: true,
Score: 3.5,
Name: "Ada Lovelace",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected bound value: got %#v want %#v", got, want)
}
}
func TestBindArgsLeavesTrailingFieldsZeroWhenArgsRunOut(t *testing.T) {
type input struct {
ID int
Reason string
Admin bool
}
ctx := &MsgContext{Args: []string{"7"}}
var got input
if err := ctx.BindArgs(&got); err != nil {
t.Fatalf("BindArgs returned error: %v", err)
}
if got.ID != 7 {
t.Fatalf("unexpected ID: got %d want 7", got.ID)
}
if got.Reason != "" {
t.Fatalf("expected zero-value Reason, got %q", got.Reason)
}
if got.Admin {
t.Fatal("expected zero-value Admin")
}
}
func TestBindArgsRejectsInvalidTargets(t *testing.T) {
ctx := &MsgContext{Args: []string{"1"}}
if err := ctx.BindArgs(nil); !errors.Is(err, ErrBindArgsTargetNotPointer) {
t.Fatalf("expected ErrBindArgsTargetNotPointer for nil target, got %v", err)
}
var notStruct int
if err := ctx.BindArgs(&notStruct); !errors.Is(err, ErrBindArgsTargetNotStruct) {
t.Fatalf("expected ErrBindArgsTargetNotStruct for non-struct target, got %v", err)
}
}
func TestBindArgsReportsConversionFailures(t *testing.T) {
type input struct {
ID int
}
ctx := &MsgContext{Args: []string{"oops"}}
var got input
err := ctx.BindArgs(&got)
if err == nil {
t.Fatal("expected BindArgs to fail")
}
if !errors.Is(err, ErrBindArgsConversion) {
t.Fatalf("expected ErrBindArgsConversion, got %v", err)
}
if !strings.Contains(err.Error(), "field ID") {
t.Fatalf("expected field name in error, got %v", err)
}
}
func TestBindArgsRejectsUnsupportedFieldTypes(t *testing.T) {
type input struct {
Tags []string
}
ctx := &MsgContext{Args: []string{"tag"}}
var got input
err := ctx.BindArgs(&got)
if err == nil {
t.Fatal("expected BindArgs to fail")
}
if !errors.Is(err, ErrBindArgsUnsupportedFieldType) {
t.Fatalf("expected ErrBindArgsUnsupportedFieldType, got %v", err)
}
}
func 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)
}
}
+297
View File
@@ -0,0 +1,297 @@
package laniakea
import (
"strings"
"time"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
)
func (bot *Bot[T]) handleMessage(update *tgapi.Update, ctx *MsgContext) bool {
text, ok := messageText(update)
if !ok {
return false
}
prefix, cmd, args := bot.parseCommand(text)
if cmd == "" {
return bot.handleFallback(update, ctx)
}
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 bot.handleFallback(update, ctx)
}
func (bot *Bot[T]) handleFallback(update *tgapi.Update, ctx *MsgContext) bool {
text, ok := messageText(update)
if !ok {
return false
}
prefix, _, _ := bot.parseCommand(text)
handled := false
for _, plugin := range bot.plugins {
if plugin.messageFallback == nil {
continue
}
pluginCtx := cloneMsgContext(ctx)
pluginCtx.Prefix = prefix
pluginCtx.Text = text
pluginCtx.Args = strings.Fields(text)
if plugin.logger != nil {
pluginCtx.Logger = plugin.logger
}
if !plugin.executeMiddlewares(pluginCtx, bot.appData) {
continue
}
startTime := time.Now()
bot.safeEmitEvent(pluginCtx.Context(), HandlerStartedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerMessageKind,
HandlerName: "message_fallback",
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
})
err := plugin.messageFallback(pluginCtx, bot.appData)
endEvent := HandlerFinishedEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerMessageKind,
HandlerName: "message_fallback",
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 {
pluginCtx.error(err)
bot.safeEmitEvent(pluginCtx.Context(), ErrorEvent{
UpdateID: update.UpdateID,
UpdateType: update.Type,
Plugin: plugin.name,
HandlerKind: HandlerMessageKind,
HandlerName: "message_fallback",
FromID: pluginCtx.FromID,
ChatID: pluginCtx.ChatID,
Err: err,
UserFacing: IsUserError(err),
})
}
handled = true
}
return handled
}
func messageText(update *tgapi.Update) (string, 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
}
return text, true
}
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 "", "", ""
}
+186
View File
@@ -0,0 +1,186 @@
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"
// HandlerMessageKind identifies a message fallback handler.
HandlerMessageKind HandlerEventKind = "message"
// 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)
}
}
+175 -53
View File
@@ -4,10 +4,13 @@ import (
"errors" "errors"
"regexp" "regexp"
"git.nix13.pw/scuroneko/extypes" "git.scuroneko.dev/scuroneko/extypes"
"git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.scuroneko.dev/scuroneko/laniakea/utils"
"git.scuroneko.dev/scuroneko/slog"
) )
// CommandValueType defines the expected type of a command argument. // CommandValueType defines the expected type of command argument.
type CommandValueType string type CommandValueType string
const ( const (
@@ -15,7 +18,7 @@ const (
CommandValueStringType CommandValueType = "string" CommandValueStringType CommandValueType = "string"
// CommandValueIntType expects a decimal integer (digits only). // CommandValueIntType expects a decimal integer (digits only).
CommandValueIntType CommandValueType = "int" CommandValueIntType CommandValueType = "int"
// CommandValueBoolType is reserved for future use (not implemented). // CommandValueBoolType expects a exact "true" or "false".
CommandValueBoolType CommandValueType = "bool" CommandValueBoolType CommandValueType = "bool"
// CommandValueAnyType accepts any input without validation. // CommandValueAnyType accepts any input without validation.
CommandValueAnyType CommandValueType = "any" CommandValueAnyType CommandValueType = "any"
@@ -37,6 +40,11 @@ var ErrCmdArgCountMismatch = errors.New("command arg count mismatch")
// ErrCmdArgRegexpMismatch is returned when an argument fails regex validation. // ErrCmdArgRegexpMismatch is returned when an argument fails regex validation.
var ErrCmdArgRegexpMismatch = errors.New("command arg regexp mismatch") 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, // CommandArg defines a single argument for a command, including type, regex,
// and whether it is required. // and whether it is required.
type CommandArg struct { type CommandArg struct {
@@ -49,12 +57,12 @@ type CommandArg struct {
// NewCommandArg creates a new CommandArg with the given text and type. // NewCommandArg creates a new CommandArg with the given text and type.
// Uses a default regex based on the type (string or int). // Uses a default regex based on the type (string or int).
// For CommandValueAnyType, no validation is performed. // For CommandValueAnyType, no validation is performed.
func NewCommandArg(text string) *CommandArg { func NewCommandArg(text string) CommandArg {
return &CommandArg{CommandValueAnyType, text, CommandRegexString, false} return CommandArg{CommandValueAnyType, text, CommandRegexString, false}
} }
// SetValueType sets expected value type and switches built-in validation regexp. // SetValueType sets expected value type and switches built-in validation regexp.
func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg { func (c CommandArg) SetValueType(t CommandValueType) CommandArg {
regex := CommandRegexString regex := CommandRegexString
switch t { switch t {
case CommandValueIntType: case CommandValueIntType:
@@ -71,18 +79,19 @@ func (c *CommandArg) SetValueType(t CommandValueType) *CommandArg {
// SetRequired marks this argument as required. // SetRequired marks this argument as required.
// Returns the receiver for method chaining. // Returns the receiver for method chaining.
func (c *CommandArg) SetRequired() *CommandArg { func (c CommandArg) SetRequired() CommandArg {
c.required = true c.required = true
return c return c
} }
// CommandExecutor is the function type that executes a command. // CommandExecutor is the function type that executes a command.
// It receives the message context and a database context (generic). // It receives the message context and injected application data.
type CommandExecutor[T DbContext] func(ctx *MsgContext, dbContext *T) // Returning a non-nil error routes it through the bot's error handler.
type CommandExecutor[T AppData] func(ctx *MsgContext, dbContext T) error
// Command represents a bot command with arguments, description, and executor. // Command represents a bot command with arguments, description, and executor.
// Can be registered in a Plugin and optionally skipped from auto-generation. // Can be registered in a Plugin and optionally skipped from auto-generation.
type Command[T DbContext] struct { type Command[T AppData] struct {
command string // The command trigger (e.g., "/start") command string // The command trigger (e.g., "/start")
description string // Human-readable description for help description string // Human-readable description for help
exec CommandExecutor[T] // Function to execute when command is triggered exec CommandExecutor[T] // Function to execute when command is triggered
@@ -122,14 +131,12 @@ func (c *Command[T]) SkipCommandAutoGen() *Command[T] {
return c return c
} }
// validateArgs checks if the provided arguments match the command's requirements. // Internal helper that validates provided command arguments.
// Returns ErrCmdArgCountMismatch if too few arguments are provided.
// Returns ErrCmdArgRegexpMismatch if any argument fails regex validation.
func (c *Command[T]) validateArgs(args []string) error { func (c *Command[T]) validateArgs(args []string) error {
// Count required args for i := range c.args.Len() {
requiredCount := c.args.Filter(func(a CommandArg) bool { return a.required }).Len() if i >= len(args) && c.args.Get(i).required {
if len(args) < requiredCount { return ErrCmdArgCountMismatch
return ErrCmdArgCountMismatch }
} }
// Validate each argument against its regex // Validate each argument against its regex
@@ -151,25 +158,48 @@ func (c *Command[T]) validateArgs(args []string) error {
// Plugin represents a collection of commands and payloads (e.g., callback handlers), // Plugin represents a collection of commands and payloads (e.g., callback handlers),
// with shared middleware and configuration. // with shared middleware and configuration.
type Plugin[T DbContext] struct { //
// 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 AppData] struct {
name string // Name of the plugin (e.g., "admin", "user") name string // Name of the plugin (e.g., "admin", "user")
commands map[string]*Command[T] // Registered commands (triggered by message) commands map[string]*Command[T] // Registered commands (triggered by message)
payloads map[string]*Command[T] // Registered payloads (triggered by callback data) payloads map[string]*Command[T] // Registered payloads (triggered by callback data)
scenes map[string]*Scene[T] // Optional scenes for multi-step interactions
middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads middlewares extypes.Slice[Middleware[T]] // Shared middlewares for all commands/payloads
skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help skipAutoCmd bool // If true, all commands in this plugin are excluded from auto-help
logger *slog.Logger
messageFallback CommandExecutor[T]
handlers map[tgapi.UpdateType]CommandExecutor[T]
onClose func() error
} }
// NewPlugin creates a new Plugin with the given name. // NewPlugin creates a new Plugin with the given name.
func NewPlugin[T DbContext](name string) *Plugin[T] { func NewPlugin[T AppData](name string) *Plugin[T] {
return &Plugin[T]{ return &Plugin[T]{
name, make(map[string]*Command[T]), name: name,
make(map[string]*Command[T]), extypes.Slice[Middleware[T]]{}, false, commands: make(map[string]*Command[T]),
payloads: make(map[string]*Command[T]),
middlewares: make(extypes.Slice[Middleware[T]], 0),
scenes: make(map[string]*Scene[T]),
skipAutoCmd: false,
logger: nil,
handlers: make(map[tgapi.UpdateType]CommandExecutor[T]),
} }
} }
// AddCommand registers a command in the plugin. // AddCommand registers a command in the plugin.
// The command's .command field is used as the key. // The command's .command field is used as the key.
func (p *Plugin[T]) AddCommand(command *Command[T]) *Plugin[T] { 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 p.commands[command.command] = command
return p return p
} }
@@ -185,6 +215,12 @@ func (p *Plugin[T]) NewCommand(exec CommandExecutor[T], command string, args ...
// AddPayload registers a payload (e.g., callback query data) in the plugin. // AddPayload registers a payload (e.g., callback query data) in the plugin.
// Payloads are triggered by inline button callback_data, not by message text. // Payloads are triggered by inline button callback_data, not by message text.
func (p *Plugin[T]) AddPayload(command *Command[T]) *Plugin[T] { 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 p.payloads[command.command] = command
return p return p
} }
@@ -197,6 +233,49 @@ func (p *Plugin[T]) NewPayload(exec CommandExecutor[T], command string, args ...
return cmd return cmd
} }
// AddScene registers a multi-step scene in the plugin.
func (p *Plugin[T]) AddScene(scene *Scene[T]) *Plugin[T] {
if scene == nil {
return p
}
scene.PluginName = p.name
scene.setPluginName(p.name)
p.scenes[scene.Name] = scene
return p
}
// NewScene creates, registers, and returns a new scene owned by the plugin.
func (p *Plugin[T]) NewScene(name string) *Scene[T] {
scene := NewScene[T](name)
scene.setPluginName(p.name)
p.AddScene(scene)
return scene
}
// 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)
}
// AddUpdateHandler registers a handler for a non-command update type.
// Message, channel post, and callback query updates stay on the command/payload flow.
func (p *Plugin[T]) AddUpdateHandler(t tgapi.UpdateType, handler CommandExecutor[T]) *Plugin[T] {
switch t {
case tgapi.UpdateTypeMessage, tgapi.UpdateTypeChannelPost, tgapi.UpdateTypeCallbackQuery:
if p.logger == nil {
logger := utils.CreateLogger(p.name, utils.GetLoggerLevel())
logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
_ = logger.Close()
return p
}
p.logger.Warnf("%s can't be registred through AddUpdateHandler. Use AddPayload/NewPayload or AddCommand/NewCommand", t)
return p
}
p.handlers[t] = handler
return p
}
// AddMiddleware adds a middleware to the plugin's global middleware chain. // AddMiddleware adds a middleware to the plugin's global middleware chain.
// Middlewares are executed before any command or payload. // Middlewares are executed before any command or payload.
func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] { func (p *Plugin[T]) AddMiddleware(middleware Middleware[T]) *Plugin[T] {
@@ -210,61 +289,104 @@ func (p *Plugin[T]) SkipCommandAutoGen() *Plugin[T] {
return p return p
} }
// executeCmd finds and executes a command by its trigger string. // SetLogger sets the logger used for this plugin's handlers.
// Validates arguments and runs middlewares before executor. //
// On error, sends an error message to the user via ctx.error(). // Call this before Bot.AddPlugins. If the plugin is already registered, changing
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, dbContext *T) { // the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) SetLogger(l *slog.Logger) *Plugin[T] {
p.logger = l
return p
}
// RemoveLogger clears the custom logger for this plugin.
//
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
// the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) RemoveLogger() *Plugin[T] {
p.logger = nil
return p
}
// SetOnClose registers a callback invoked from Plugin.Close after the plugin
// logger is closed.
//
// Call this before Bot.AddPlugins. If the plugin is already registered, changing
// the original *Plugin does not update the Bot's internal copy.
func (p *Plugin[T]) SetOnClose(f func() error) *Plugin[T] {
p.onClose = f
return p
}
// SetMessageFallback registers a fallback handler for messages that do not
// match a command.
func (p *Plugin[T]) SetMessageFallback(handler CommandExecutor[T]) *Plugin[T] {
p.messageFallback = handler
return p
}
// Close releases plugin-owned resources such as its logger and optional
// OnClose callback.
func (p *Plugin[T]) Close() error {
var e []error
if p.logger != nil {
if err := p.logger.Close(); err != nil {
e = append(e, err)
}
}
if p.onClose != nil {
if err := p.onClose(); err != nil {
e = append(e, err)
}
}
return errors.Join(e...)
}
// Internal helper that validates and executes a command handler.
func (p *Plugin[T]) executeCmd(cmd string, ctx *MsgContext, db T) error {
command, exists := p.commands[cmd] command, exists := p.commands[cmd]
if !exists { if !exists {
ctx.error(errors.New("command not found")) return AsInternalError(errCommandNotFound)
return
} }
if err := command.validateArgs(ctx.Args); err != nil { if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err) return AsUserError(err)
return
} }
// Run command-specific middlewares // Run command-specific middlewares
for _, m := range command.middlewares { for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) { if !m.Execute(ctx, db) {
return return AsInternalError(errors.New("middleware blocked call"))
} }
} }
// Execute command // Execute command
command.exec(ctx, dbContext) return command.exec(ctx, db)
} }
// executePayload finds and executes a payload by its callback_data string. // Internal helper that validates and executes a payload handler.
// Validates arguments and runs middlewares before executor. func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, db T) error {
// On error, sends an error message to the user via ctx.error().
func (p *Plugin[T]) executePayload(payload string, ctx *MsgContext, dbContext *T) {
command, exists := p.payloads[payload] command, exists := p.payloads[payload]
if !exists { if !exists {
ctx.error(errors.New("payload not found")) return AsInternalError(errPayloadNotFound)
return
} }
if err := command.validateArgs(ctx.Args); err != nil { if err := command.validateArgs(ctx.Args); err != nil {
ctx.error(err) return AsUserError(err)
return
} }
// Run command-specific middlewares // Run command-specific middlewares
for _, m := range command.middlewares { for _, m := range command.middlewares {
if !m.Execute(ctx, dbContext) { if !m.Execute(ctx, db) {
return return AsInternalError(errors.New("middleware blocked call"))
} }
} }
// Execute payload // Execute payload
command.exec(ctx, dbContext) return command.exec(ctx, db)
} }
// executeMiddlewares runs all plugin middlewares in order. // Internal helper that runs plugin middlewares in order.
// Returns false if any middleware returns false (blocks execution). func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db T) bool {
func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
for _, m := range p.middlewares { for _, m := range p.middlewares {
if !m.Execute(ctx, db) { if !m.Execute(ctx, db) {
return false return false
@@ -276,11 +398,11 @@ func (p *Plugin[T]) executeMiddlewares(ctx *MsgContext, db *T) bool {
// MiddlewareExecutor is the function type for middleware logic. // MiddlewareExecutor is the function type for middleware logic.
// Returns true to continue execution, false to block it. // Returns true to continue execution, false to block it.
// If async, return value is ignored. // If async, return value is ignored.
type MiddlewareExecutor[T DbContext] func(ctx *MsgContext, db *T) bool type MiddlewareExecutor[T AppData] func(ctx *MsgContext, db T) bool
// Middleware represents a reusable execution interceptor. // Middleware represents a reusable execution interceptor.
// Can be synchronous (blocking) or asynchronous (non-blocking). // Can be synchronous (blocking) or asynchronous (non-blocking).
type Middleware[T DbContext] struct { type Middleware[T AppData] struct {
name string // Human-readable name for logging/debugging name string // Human-readable name for logging/debugging
executor MiddlewareExecutor[T] // Function to execute executor MiddlewareExecutor[T] // Function to execute
order int // Optional sort order (not used yet) order int // Optional sort order (not used yet)
@@ -288,19 +410,19 @@ type Middleware[T DbContext] struct {
} }
// NewMiddleware creates a new synchronous middleware. // NewMiddleware creates a new synchronous middleware.
func NewMiddleware[T DbContext](name string, executor MiddlewareExecutor[T]) *Middleware[T] { func NewMiddleware[T AppData](name string, executor MiddlewareExecutor[T]) Middleware[T] {
return &Middleware[T]{name, executor, 0, false} return Middleware[T]{name, executor, 0, false}
} }
// SetOrder sets the execution order (currently ignored). // SetOrder sets the execution order (currently ignored).
func (m *Middleware[T]) SetOrder(order int) *Middleware[T] { func (m Middleware[T]) SetOrder(order int) Middleware[T] {
m.order = order m.order = order
return m return m
} }
// SetAsync marks the middleware to run asynchronously. // SetAsync marks the middleware to run asynchronously.
// Execution continues regardless of its return value. // Execution continues regardless of its return value.
func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] { func (m Middleware[T]) SetAsync(async bool) Middleware[T] {
m.async = async m.async = async
return m return m
} }
@@ -308,7 +430,7 @@ func (m *Middleware[T]) SetAsync(async bool) *Middleware[T] {
// Execute runs the middleware. // Execute runs the middleware.
// If async, runs in a goroutine and returns true immediately. // If async, runs in a goroutine and returns true immediately.
// Otherwise, returns the result of the executor. // Otherwise, returns the result of the executor.
func (m *Middleware[T]) Execute(ctx *MsgContext, db *T) bool { func (m Middleware[T]) Execute(ctx *MsgContext, db T) bool {
if m.async { if m.async {
ctx := *ctx // copy context to avoid race condition ctx := *ctx // copy context to avoid race condition
go func(ctx MsgContext) { go func(ctx MsgContext) {
+18 -2
View File
@@ -6,7 +6,7 @@ import (
) )
func TestValidateArgsRequiresFullMatch(t *testing.T) { func TestValidateArgsRequiresFullMatch(t *testing.T) {
intCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "int", *NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired()) intCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "int", NewCommandArg("n").SetValueType(CommandValueIntType).SetRequired())
if err := intCmd.validateArgs([]string{"123"}); err != nil { if err := intCmd.validateArgs([]string{"123"}); err != nil {
t.Fatalf("expected valid integer argument, got %v", err) t.Fatalf("expected valid integer argument, got %v", err)
} }
@@ -14,7 +14,7 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err) t.Fatalf("expected ErrCmdArgRegexpMismatch for partial int match, got %v", err)
} }
boolCmd := NewCommand[NoDB](func(ctx *MsgContext, db *NoDB) {}, "bool", *NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired()) boolCmd := NewCommand(func(ctx *MsgContext, db NoData) error { return nil }, "bool", NewCommandArg("flag").SetValueType(CommandValueBoolType).SetRequired())
if err := boolCmd.validateArgs([]string{"false"}); err != nil { if err := boolCmd.validateArgs([]string{"false"}); err != nil {
t.Fatalf("expected valid bool argument, got %v", err) t.Fatalf("expected valid bool argument, got %v", err)
} }
@@ -22,3 +22,19 @@ func TestValidateArgsRequiresFullMatch(t *testing.T) {
t.Fatalf("expected ErrCmdArgRegexpMismatch for partial bool match, got %v", err) t.Fatalf("expected ErrCmdArgRegexpMismatch for partial bool match, got %v", err)
} }
} }
func TestValidateArgsEnforcesRequiredArgIndex(t *testing.T) {
cmd := NewCommand(
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("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(
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(
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(
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(
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(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(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("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("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 // RunnerFn is the function type for a runner. It receives a pointer to
// the Bot and returns an error if execution fails. // 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 // Runner represents a configurable background or one-time task to be
// executed by a Bot. // 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=true, async=true: Run once in a goroutine (non-blocking).
// - onetime=false, async=true: Run repeatedly in a goroutine with timeout. // - onetime=false, async=true: Run repeatedly in a goroutine with timeout.
// - onetime=false, async=false: Invalid configuration — ignored with warning. // - 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 name string // Human-readable name for logging
onetime bool // If true, runs once; if false, runs periodically onetime bool // If true, runs once; if false, runs periodically
async bool // If true, runs in a goroutine; else, runs synchronously 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. // Builder methods (Onetime, Async, Timeout) can be chained to customize behavior.
// DO NOT call builder methods concurrently or after Execute(). // DO NOT call builder methods concurrently or after Execute().
func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] { func NewRunner[T AppData](name string, fn RunnerFn[T]) Runner[T] {
return &Runner[T]{ return Runner[T]{
name: name, name: name,
fn: fn, fn: fn,
async: true, // Default: run asynchronously async: true, // Default: run asynchronously
@@ -45,7 +45,7 @@ func NewRunner[T DbContext](name string, fn RunnerFn[T]) *Runner[T] {
// Onetime sets whether the runner executes once or repeatedly. // Onetime sets whether the runner executes once or repeatedly.
// If true, the runner runs only once. // If true, the runner runs only once.
// If false, the runner runs in a loop with the configured timeout. // If false, the runner runs in a loop with the configured timeout.
func (r *Runner[T]) Onetime(onetime bool) *Runner[T] { func (r Runner[T]) Onetime(onetime bool) Runner[T] {
r.onetime = onetime r.onetime = onetime
return r return r
} }
@@ -55,7 +55,7 @@ func (r *Runner[T]) Onetime(onetime bool) *Runner[T] {
// If false, the runner blocks the caller during execution. // If false, the runner blocks the caller during execution.
// //
// Note: If onetime=false and async=false, the runner will be skipped with a warning. // Note: If onetime=false and async=false, the runner will be skipped with a warning.
func (r *Runner[T]) Async(async bool) *Runner[T] { func (r Runner[T]) Async(async bool) Runner[T] {
r.async = async r.async = async
return r return r
} }
@@ -69,7 +69,7 @@ func (r *Runner[T]) Async(async bool) *Runner[T] {
// //
// A zero value (time.Duration(0)) is allowed but may trigger a warning // A zero value (time.Duration(0)) is allowed but may trigger a warning
// if used with a background (non-onetime) async runner. // if used with a background (non-onetime) async runner.
func (r *Runner[T]) Timeout(timeout time.Duration) *Runner[T] { func (r Runner[T]) Timeout(timeout time.Duration) Runner[T] {
r.timeout = timeout r.timeout = timeout
return r return r
} }
@@ -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. // 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) { func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.logger.Infoln("Executing runners...") bot.logger.Infoln("Executing runners...")
for _, runner := range bot.runners { for _, runner := range bot.runners {
@@ -107,8 +108,21 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
bot.runnerOnceWG.Add(1) bot.runnerOnceWG.Add(1)
go func(r Runner[T]) { go func(r Runner[T]) {
defer bot.runnerOnceWG.Done() defer bot.runnerOnceWG.Done()
startedAt := time.Now()
err := r.fn(bot) err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
if err != nil { 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) bot.logger.Warnf("Runner %s failed: %s\n", r.name, err)
} }
}(runner) }(runner)
@@ -116,10 +130,22 @@ func (bot *Bot[T]) ExecRunners(ctx context.Context) {
// One-time sync: block until done // One-time sync: block until done
t := time.Now() t := time.Now()
err := runner.fn(bot) err := runner.fn(bot)
elapsed := time.Since(t)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: runner.name,
Duration: elapsed,
Err: err,
})
if err != nil { 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) bot.logger.Warnf("Runner %s failed: %s\n", runner.name, err)
} }
elapsed := time.Since(t)
if elapsed > time.Second*2 { if elapsed > time.Second*2 {
bot.logger.Warnf("Runner %s too slow. Elapsed time %v >= 2s\n", runner.name, elapsed) 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(): case <-ctx.Done():
return return
case <-ticker.C: case <-ticker.C:
startedAt := time.Now()
err := r.fn(bot) err := r.fn(bot)
bot.safeEmitEvent(ctx, RunnerFinishedEvent{
Name: r.name,
Duration: time.Since(startedAt),
Err: err,
})
if err != nil { 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) 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)
}
+259
View File
@@ -0,0 +1,259 @@
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
}
// Unmatched slash-commands should continue through normal bot command routing
// instead of also triggering the active scene step or fallback handler.
return false, nil
}
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
}
}
+700
View File
@@ -0,0 +1,700 @@
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 TestSceneUnmatchedCommandFallsThroughWithoutRunningStep(t *testing.T) {
commandCalled := 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
})
plugin.NewCommand(func(ctx *MsgContext, db NoData) error {
commandCalled = true
return nil
}, "ping")
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")
}
bot.handle(context.Background(), &tgapi.Update{
UpdateID: 5,
Type: tgapi.UpdateTypeMessage,
Message: &tgapi.Message{
MessageID: 10,
Text: "/ping",
Chat: &tgapi.Chat{ID: 100, Type: tgapi.ChatTypePrivate},
From: &tgapi.User{ID: 42},
},
})
if !commandCalled {
t.Fatal("expected normal command routing to handle /ping")
}
if stepCalled {
t.Fatal("scene step must not run for an unmatched slash-command")
}
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 command fallback: %#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
}
+23 -27
View File
@@ -9,8 +9,8 @@ import (
"net/http" "net/http"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
// APIOpts holds configuration options for initializing the Telegram API client. // APIOpts holds configuration options for initializing the Telegram API client.
@@ -95,10 +95,14 @@ type API struct {
} }
// NewAPI creates a new API client from options. // NewAPI creates a new API client from options.
// Always call CloseApi() when done to release resources. // Always call Close() when done to release resources.
func NewAPI(opts *APIOpts) *API { func NewAPI(opts *APIOpts) *API {
l := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("API") l := utils.CreateLogger("API", utils.GetLoggerLevel())
l.AddWriter(l.CreateJsonStdoutWriter()) if opts == nil {
l.Errorln("Set API options")
_ = l.Close()
return nil
}
client := opts.client client := opts.client
if client == nil { if client == nil {
@@ -120,11 +124,14 @@ func NewAPI(opts *APIOpts) *API {
} }
} }
// CloseApi shuts down the internal worker pool and closes the logger. // Close shuts down the internal worker pool and closes the logger.
// Must be called to avoid resource leaks. // Must be called to avoid resource leaks.
// See https://core.telegram.org/bots/api // See https://core.telegram.org/bots/api
func (api *API) CloseApi() error { func (api *API) Close() error {
api.pool.stop() api.pool.stop()
if api.client != nil {
api.client.CloseIdleConnections()
}
return api.logger.Close() return api.logger.Close()
} }
@@ -150,37 +157,29 @@ type ApiResponse[R any] struct {
Parameters *ResponseParameters `json:"parameters,omitempty"` Parameters *ResponseParameters `json:"parameters,omitempty"`
} }
// TelegramRequest is an internal helper struct. // TelegramRequest is a low-level Telegram API request wrapper.
// DO NOT USE NewRequest or NewRequestWithChatID — they are unsafe and discouraged.
// Instead, use explicit methods like SendMessage, GetUpdates, etc.
// //
// Why? Because using generics with arbitrary types P and R leads to: // Prefer method-specific helpers such as SendMessage or GetUpdates. TelegramRequest
// - No compile-time validation of parameters // bypasses method-specific parameter types and convenience helpers, so callers are
// - No IDE autocompletion // responsible for using the correct method name and compatible request and response types.
// - Runtime panics on malformed JSON // In that sense it is an unsafe escape hatch compared with the typed API surface.
// - Hard-to-debug errors
//
// Recommended: Define specific methods for each Telegram method (see below).
type TelegramRequest[R, P any] struct { type TelegramRequest[R, P any] struct {
method string method string
params P params P
chatId int64 chatId int64
} }
// NewRequest creates an untyped TelegramRequest for the given method and params with no chat ID. // NewRequest creates a low-level TelegramRequest with no associated chat ID.
func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] { func NewRequest[R, P any](method string, params P) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, 0} return TelegramRequest[R, P]{method, params, 0}
} }
// NewRequestWithChatID creates an untyped TelegramRequest with an associated chat ID. // NewRequestWithChatID creates a low-level TelegramRequest with an associated chat ID.
// The chat ID is used for per-chat rate limiting. // The chat ID is used for per-chat rate limiting.
func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] { func NewRequestWithChatID[R, P any](method string, params P, chatId int64) TelegramRequest[R, P] {
return TelegramRequest[R, P]{method, params, chatId} return TelegramRequest[R, P]{method, params, chatId}
} }
// doRequest performs a single HTTP request to Telegram API.
// Handles rate limiting, retries on 429, and parses responses.
// Must be called within a worker pool context if using DoWithContext.
func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) { func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, error) {
var zero R var zero R
reqData, err := json.Marshal(r.params) reqData, err := json.Marshal(r.params)
@@ -193,7 +192,6 @@ func (r TelegramRequest[R, P]) doRequest(ctx context.Context, api *API) (R, erro
methodPrefix = "/test" methodPrefix = "/test"
} }
url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method) url := fmt.Sprintf("%s/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, r.method)
req, err := http.NewRequestWithContext(ctx, "POST", url, nil) req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil { if err != nil {
return zero, fmt.Errorf("failed to create request: %w", err) return zero, fmt.Errorf("failed to create request: %w", err)
@@ -297,15 +295,13 @@ func (r TelegramRequest[R, P]) Do(api *API) (R, error) {
return r.DoWithContext(context.Background(), api) return r.DoWithContext(context.Background(), api)
} }
// readBody reads and limits response body to prevent memory exhaustion. // Internal helper that reads and caps a Telegram response body.
// Telegram responses are typically small (<1MB), but we cap at 10MB.
func readBody(body io.ReadCloser) ([]byte, error) { func readBody(body io.ReadCloser) ([]byte, error) {
reader := io.LimitReader(body, 10<<20) // 10 MB reader := io.LimitReader(body, 10<<20) // 10 MB
return io.ReadAll(reader) return io.ReadAll(reader)
} }
// parseBody unmarshals a Telegram API response into a typed ApiResponse. // Internal helper that parses a typed Telegram API response body.
// Only returns an error on malformed JSON; non-OK responses are left for the caller to handle.
func parseBody[R any](data []byte) (ApiResponse[R], error) { func parseBody[R any](data []byte) (ApiResponse[R], error) {
var resp ApiResponse[R] var resp ApiResponse[R]
err := json.Unmarshal(data, &resp) err := json.Unmarshal(data, &resp)
+36 -2
View File
@@ -13,6 +13,15 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req) return fn(req)
} }
type closingTransport struct {
roundTripFunc
closed bool
}
func (t *closingTransport) CloseIdleConnections() {
t.closed = true
}
func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) { func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
var gotPath string var gotPath string
var gotAcceptEncoding string var gotAcceptEncoding string
@@ -35,8 +44,8 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -54,3 +63,28 @@ func TestAPILeavesAcceptEncodingToHTTPTransport(t *testing.T) {
t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding) t.Fatalf("expected empty Accept-Encoding header, got %q", gotAcceptEncoding)
} }
} }
func TestAPICloseClosesIdleConnections(t *testing.T) {
transport := &closingTransport{
roundTripFunc: func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"Test"}}`)),
}, nil
},
}
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(&http.Client{Transport: transport}),
)
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
if !transport.closed {
t.Fatal("expected Close to close idle HTTP connections")
}
}
+116 -36
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// SendPhotoP holds parameters for the sendPhoto method. import "context"
// SendPhoto holds parameters for the sendPhoto method.
// See https://core.telegram.org/bots/api#sendphoto // See https://core.telegram.org/bots/api#sendphoto
type SendPhotoP struct { type SendPhoto struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -27,14 +29,22 @@ type SendPhotoP struct {
// SendPhoto sends a photo. // SendPhoto sends a photo.
// See https://core.telegram.org/bots/api#sendphoto // 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) req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendAudioP holds parameters for the sendAudio method. // 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 SendPhoto) (Message, error) {
req := NewRequestWithChatID[Message]("sendPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAudio holds parameters for the sendAudio method.
// See https://core.telegram.org/bots/api#sendaudio // See https://core.telegram.org/bots/api#sendaudio
type SendAudioP struct { type SendAudio struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -47,6 +57,7 @@ type SendAudioP struct {
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Performer string `json:"performer,omitempty"` Performer string `json:"performer,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Thumbnail string `json:"thumbnail,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
@@ -60,23 +71,33 @@ type SendAudioP struct {
// SendAudio sends an audio file. // SendAudio sends an audio file.
// See https://core.telegram.org/bots/api#sendaudio // 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) req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendDocumentP holds parameters for the sendDocument method. // 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 SendAudio) (Message, error) {
req := NewRequestWithChatID[Message]("sendAudio", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDocument holds parameters for the sendDocument method.
// See https://core.telegram.org/bots/api#senddocument // See https://core.telegram.org/bots/api#senddocument
type SendDocumentP struct { type SendDocument struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Document string `json:"document"` Document string `json:"document"`
Caption string `json:"caption,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
ParseMode ParseMode `json:"parse_mode,omitempty"` Caption string `json:"caption,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
DisableContentTypeDetection bool `json:"disable_content_type_detection,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
@@ -90,24 +111,33 @@ type SendDocumentP struct {
// SendDocument sends a document. // SendDocument sends a document.
// See https://core.telegram.org/bots/api#senddocument // 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) req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendVideoP holds parameters for the sendVideo method. // 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 SendDocument) (Message, error) {
req := NewRequestWithChatID[Message]("sendDocument", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideo holds parameters for the sendVideo method.
// See https://core.telegram.org/bots/api#sendvideo // See https://core.telegram.org/bots/api#sendvideo
type SendVideoP struct { type SendVideo struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Video string `json:"video"` Video string `json:"video"`
Duration int `json:"duration,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
Width int `json:"width,omitempty"` Duration int `json:"duration,omitempty"`
Height int `json:"height,omitempty"` Width int `json:"width,omitempty"`
Cover string `json:"cover,omitempty"` Height int `json:"height,omitempty"`
Cover string `json:"cover,omitempty"`
StartTimestamp int `json:"start_timestamp,omitempty"` StartTimestamp int `json:"start_timestamp,omitempty"`
Caption string `json:"caption,omitempty"` Caption string `json:"caption,omitempty"`
@@ -129,20 +159,29 @@ type SendVideoP struct {
// SendVideo sends a video. // SendVideo sends a video.
// See https://core.telegram.org/bots/api#sendvideo // 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) req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendAnimationP holds parameters for the sendAnimation method. // 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 SendVideo) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideo", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendAnimation holds parameters for the sendAnimation method.
// See https://core.telegram.org/bots/api#sendanimation // See https://core.telegram.org/bots/api#sendanimation
type SendAnimationP struct { type SendAnimation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Animation string `json:"animation"` Animation string `json:"animation"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Width int `json:"width,omitempty"` Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"` Height int `json:"height,omitempty"`
@@ -164,14 +203,22 @@ type SendAnimationP struct {
// SendAnimation sends an animation file (GIF or H.264/MPEG-4 AVC video without sound). // SendAnimation sends an animation file (GIF or H.264/MPEG-4 AVC video without sound).
// See https://core.telegram.org/bots/api#sendanimation // 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) req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendVoiceP holds parameters for the sendVoice method. // 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 SendAnimation) (Message, error) {
req := NewRequestWithChatID[Message]("sendAnimation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVoice holds parameters for the sendVoice method.
// See https://core.telegram.org/bots/api#sendvoice // See https://core.telegram.org/bots/api#sendvoice
type SendVoiceP struct { type SendVoice struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -194,20 +241,29 @@ type SendVoiceP struct {
// SendVoice sends a voice note. // SendVoice sends a voice note.
// See https://core.telegram.org/bots/api#sendvoice // See https://core.telegram.org/bots/api#sendvoice
func (api *API) SendVoice(params *SendVoiceP) (Message, error) { func (api *API) SendVoice(params SendVoice) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID) req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendVideoNoteP holds parameters for the sendVideoNote method. // 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 SendVoice) (Message, error) {
req := NewRequestWithChatID[Message]("sendVoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVideoNote holds parameters for the sendVideoNote method.
// See https://core.telegram.org/bots/api#sendvideonote // See https://core.telegram.org/bots/api#sendvideonote
type SendVideoNoteP struct { type SendVideoNote struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
VideoNote string `json:"video_note"` VideoNote string `json:"video_note"`
Thumbnail string `json:"thumbnail,omitempty"`
Duration int `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Length int `json:"length,omitempty"` Length int `json:"length,omitempty"`
DisableNotification bool `json:"disable_notification,omitempty"` DisableNotification bool `json:"disable_notification,omitempty"`
@@ -222,14 +278,22 @@ type SendVideoNoteP struct {
// SendVideoNote sends a video note (rounded video message). // SendVideoNote sends a video note (rounded video message).
// See https://core.telegram.org/bots/api#sendvideonote // 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) req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendPaidMediaP holds parameters for the sendPaidMedia method. // 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 SendVideoNote) (Message, error) {
req := NewRequestWithChatID[Message]("sendVideoNote", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPaidMedia holds parameters for the sendPaidMedia method.
// See https://core.telegram.org/bots/api#sendpaidmedia // See https://core.telegram.org/bots/api#sendpaidmedia
type SendPaidMediaP struct { type SendPaidMedia struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -253,14 +317,22 @@ type SendPaidMediaP struct {
// SendPaidMedia sends paid media. // SendPaidMedia sends paid media.
// See https://core.telegram.org/bots/api#sendpaidmedia // 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) req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendMediaGroupP holds parameters for the sendMediaGroup method. // 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 SendPaidMedia) (Message, error) {
req := NewRequestWithChatID[Message]("sendPaidMedia", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMediaGroup holds parameters for the sendMediaGroup method.
// See https://core.telegram.org/bots/api#sendmediagroup // See https://core.telegram.org/bots/api#sendmediagroup
type SendMediaGroupP struct { type SendMediaGroup struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -276,7 +348,15 @@ type SendMediaGroupP struct {
// SendMediaGroup sends a group of photos, videos, documents or audios as an album. // SendMediaGroup sends a group of photos, videos, documents or audios as an album.
// See https://core.telegram.org/bots/api#sendmediagroup // 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) req := NewRequestWithChatID[[]Message]("sendMediaGroup", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendMediaGroupWithContext is the context-aware variant of SendMediaGroup.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendmediagroup
func (api *API) SendMediaGroupWithContext(ctx context.Context, params 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 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. // InputMediaType represents the type of input media.
type InputMediaType string type InputMediaType string
@@ -55,12 +303,12 @@ type InputPaidMedia struct {
Type InputPaidMediaType `json:"type"` Type InputPaidMediaType `json:"type"`
Media string `json:"media"` Media string `json:"media"`
Cover string `json:"cover"` Cover *string `json:"cover,omitempty"`
StartTimestamp int64 `json:"start_timestamp"` StartTimestamp *int64 `json:"start_timestamp,omitempty"`
Width int `json:"width"` Width *int `json:"width,omitempty"`
Height int `json:"height"` Height *int `json:"height,omitempty"`
Duration int `json:"duration"` Duration *int `json:"duration,omitempty"`
SupportsStreaming bool `json:"supports_streaming"` SupportsStreaming *bool `json:"supports_streaming,omitempty"`
} }
// PhotoSize represents one size of a photo or a file/sticker thumbnail. // PhotoSize represents one size of a photo or a file/sticker thumbnail.
+177 -31
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// SetMyCommandsP holds parameters for the setMyCommands method. import "context"
// SetMyCommands holds parameters for the setMyCommands method.
// See https://core.telegram.org/bots/api#setmycommands // See https://core.telegram.org/bots/api#setmycommands
type SetMyCommandsP struct { type SetMyCommands struct {
Commands []BotCommand `json:"commands"` Commands []BotCommand `json:"commands"`
Scope *BotCommandScope `json:"scope,omitempty"` Scope *BotCommandScope `json:"scope,omitempty"`
Language string `json:"language_code,omitempty"` Language string `json:"language_code,omitempty"`
@@ -11,14 +13,22 @@ type SetMyCommandsP struct {
// SetMyCommands changes the list of the bot's commands. // SetMyCommands changes the list of the bot's commands.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setmycommands // 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) req := NewRequest[bool]("setMyCommands", params)
return req.Do(api) return req.Do(api)
} }
// DeleteMyCommandsP holds parameters for the deleteMyCommands method. // 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 SetMyCommands) (bool, error) {
req := NewRequest[bool]("setMyCommands", params)
return req.DoWithContext(ctx, api)
}
// DeleteMyCommands holds parameters for the deleteMyCommands method.
// See https://core.telegram.org/bots/api#deletemycommands // See https://core.telegram.org/bots/api#deletemycommands
type DeleteMyCommandsP struct { type DeleteMyCommands struct {
Scope *BotCommandScope `json:"scope,omitempty"` Scope *BotCommandScope `json:"scope,omitempty"`
Language string `json:"language_code,omitempty"` Language string `json:"language_code,omitempty"`
} }
@@ -26,11 +36,19 @@ type DeleteMyCommandsP struct {
// DeleteMyCommands deletes the list of the bot's commands for the given scope and user language. // DeleteMyCommands deletes the list of the bot's commands for the given scope and user language.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#deletemycommands // 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) req := NewRequest[bool]("deleteMyCommands", params)
return req.Do(api) return req.Do(api)
} }
// DeleteMyCommandsWithContext is the context-aware variant of DeleteMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletemycommands
func (api *API) DeleteMyCommandsWithContext(ctx context.Context, params DeleteMyCommands) (bool, error) {
req := NewRequest[bool]("deleteMyCommands", params)
return req.DoWithContext(ctx, api)
}
// GetMyCommands holds parameters for the getMyCommands method. // GetMyCommands holds parameters for the getMyCommands method.
// See https://core.telegram.org/bots/api#getmycommands // See https://core.telegram.org/bots/api#getmycommands
type GetMyCommands struct { type GetMyCommands struct {
@@ -45,6 +63,14 @@ func (api *API) GetMyCommands(params GetMyCommands) ([]BotCommand, error) {
return req.Do(api) return req.Do(api)
} }
// GetMyCommandsWithContext is the context-aware variant of GetMyCommands.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmycommands
func (api *API) GetMyCommandsWithContext(ctx context.Context, params GetMyCommands) ([]BotCommand, error) {
req := NewRequest[[]BotCommand]("getMyCommands", params)
return req.DoWithContext(ctx, api)
}
// SetMyName holds parameters for the setMyName method. // SetMyName holds parameters for the setMyName method.
// See https://core.telegram.org/bots/api#setmyname // See https://core.telegram.org/bots/api#setmyname
type SetMyName struct { type SetMyName struct {
@@ -60,6 +86,14 @@ func (api *API) SetMyName(params SetMyName) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetMyNameWithContext is the context-aware variant of SetMyName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyname
func (api *API) SetMyNameWithContext(ctx context.Context, params SetMyName) (bool, error) {
req := NewRequest[bool]("setMyName", params)
return req.DoWithContext(ctx, api)
}
// GetMyName holds parameters for the getMyName method. // GetMyName holds parameters for the getMyName method.
// See https://core.telegram.org/bots/api#getmyname // See https://core.telegram.org/bots/api#getmyname
type GetMyName struct { type GetMyName struct {
@@ -73,6 +107,14 @@ func (api *API) GetMyName(params GetMyName) (BotName, error) {
return req.Do(api) return req.Do(api)
} }
// GetMyNameWithContext is the context-aware variant of GetMyName.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmyname
func (api *API) GetMyNameWithContext(ctx context.Context, params GetMyName) (BotName, error) {
req := NewRequest[BotName]("getMyName", params)
return req.DoWithContext(ctx, api)
}
// SetMyDescription holds parameters for the setMyDescription method. // SetMyDescription holds parameters for the setMyDescription method.
// See https://core.telegram.org/bots/api#setmydescription // See https://core.telegram.org/bots/api#setmydescription
type SetMyDescription struct { type SetMyDescription struct {
@@ -88,6 +130,14 @@ func (api *API) SetMyDescription(params SetMyDescription) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetMyDescriptionWithContext is the context-aware variant of SetMyDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmydescription
func (api *API) SetMyDescriptionWithContext(ctx context.Context, params SetMyDescription) (bool, error) {
req := NewRequest[bool]("setMyDescription", params)
return req.DoWithContext(ctx, api)
}
// GetMyDescription holds parameters for the getMyDescription method. // GetMyDescription holds parameters for the getMyDescription method.
// See https://core.telegram.org/bots/api#getmydescription // See https://core.telegram.org/bots/api#getmydescription
type GetMyDescription struct { type GetMyDescription struct {
@@ -101,6 +151,14 @@ func (api *API) GetMyDescription(params GetMyDescription) (BotDescription, error
return req.Do(api) return req.Do(api)
} }
// GetMyDescriptionWithContext is the context-aware variant of GetMyDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmydescription
func (api *API) GetMyDescriptionWithContext(ctx context.Context, params GetMyDescription) (BotDescription, error) {
req := NewRequest[BotDescription]("getMyDescription", params)
return req.DoWithContext(ctx, api)
}
// SetMyShortDescription holds parameters for the setMyShortDescription method. // SetMyShortDescription holds parameters for the setMyShortDescription method.
// See https://core.telegram.org/bots/api#setmyshortdescription // See https://core.telegram.org/bots/api#setmyshortdescription
type SetMyShortDescription struct { type SetMyShortDescription struct {
@@ -116,6 +174,14 @@ func (api *API) SetMyShortDescription(params SetMyShortDescription) (bool, error
return req.Do(api) return req.Do(api)
} }
// SetMyShortDescriptionWithContext is the context-aware variant of SetMyShortDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyshortdescription
func (api *API) SetMyShortDescriptionWithContext(ctx context.Context, params SetMyShortDescription) (bool, error) {
req := NewRequest[bool]("setMyShortDescription", params)
return req.DoWithContext(ctx, api)
}
// GetMyShortDescription holds parameters for the getMyShortDescription method. // GetMyShortDescription holds parameters for the getMyShortDescription method.
// See https://core.telegram.org/bots/api#getmyshortdescription // See https://core.telegram.org/bots/api#getmyshortdescription
type GetMyShortDescription struct { type GetMyShortDescription struct {
@@ -129,20 +195,36 @@ func (api *API) GetMyShortDescription(params GetMyShortDescription) (BotShortDes
return req.Do(api) return req.Do(api)
} }
// SetMyProfilePhotoP holds parameters for the setMyProfilePhoto method. // GetMyShortDescriptionWithContext is the context-aware variant of GetMyShortDescription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmyshortdescription
func (api *API) GetMyShortDescriptionWithContext(ctx context.Context, params GetMyShortDescription) (BotShortDescription, error) {
req := NewRequest[BotShortDescription]("getMyShortDescription", params)
return req.DoWithContext(ctx, api)
}
// SetMyProfilePhoto holds parameters for the setMyProfilePhoto method.
// See https://core.telegram.org/bots/api#setmyprofilephoto // See https://core.telegram.org/bots/api#setmyprofilephoto
type SetMyProfilePhotoP struct { type SetMyProfilePhoto struct {
Photo InputProfilePhoto `json:"photo"` Photo InputProfilePhoto `json:"photo"`
} }
// SetMyProfilePhoto changes the bot's profile photo. // SetMyProfilePhoto changes the bot's profile photo.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setmyprofilephoto // 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) req := NewRequest[bool]("setMyProfilePhoto", params)
return req.Do(api) return req.Do(api)
} }
// SetMyProfilePhotoWithContext is the context-aware variant of SetMyProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setmyprofilephoto
func (api *API) SetMyProfilePhotoWithContext(ctx context.Context, params SetMyProfilePhoto) (bool, error) {
req := NewRequest[bool]("setMyProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// RemoveMyProfilePhoto removes the bot's profile photo. // RemoveMyProfilePhoto removes the bot's profile photo.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#removemyprofilephoto // See https://core.telegram.org/bots/api#removemyprofilephoto
@@ -151,37 +233,61 @@ func (api *API) RemoveMyProfilePhoto() (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatMenuButtonP holds parameters for the setChatMenuButton method. // RemoveMyProfilePhotoWithContext is the context-aware variant of RemoveMyProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#removemyprofilephoto
func (api *API) RemoveMyProfilePhotoWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool]("removeMyProfilePhoto", NoParams)
return req.DoWithContext(ctx, api)
}
// SetChatMenuButton holds parameters for the setChatMenuButton method.
// See https://core.telegram.org/bots/api#setchatmenubutton // See https://core.telegram.org/bots/api#setchatmenubutton
type SetChatMenuButtonP struct { type SetChatMenuButton struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id,omitempty"`
MenuButton MenuButtonType `json:"menu_button"` MenuButton *MenuButton `json:"menu_button,omitempty"`
} }
// SetChatMenuButton changes the menu button for a given chat or the default menu button. // SetChatMenuButton changes the menu button for a given chat or the default menu button.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setchatmenubutton // 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) req := NewRequest[bool]("setChatMenuButton", params)
return req.Do(api) return req.Do(api)
} }
// GetChatMenuButtonP holds parameters for the getChatMenuButton method. // 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 SetChatMenuButton) (bool, error) {
req := NewRequest[bool]("setChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// GetChatMenuButton holds parameters for the getChatMenuButton method.
// See https://core.telegram.org/bots/api#getchatmenubutton // See https://core.telegram.org/bots/api#getchatmenubutton
type GetChatMenuButtonP struct { type GetChatMenuButton struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id,omitempty"`
} }
// GetChatMenuButton returns the current menu button for the given chat. // GetChatMenuButton returns the current menu button for the given chat.
// See https://core.telegram.org/bots/api#getchatmenubutton // See https://core.telegram.org/bots/api#getchatmenubutton
func (api *API) GetChatMenuButton(params GetChatMenuButtonP) (BaseMenuButton, error) { func (api *API) GetChatMenuButton(params GetChatMenuButton) (MenuButton, error) {
req := NewRequest[BaseMenuButton]("getChatMenuButton", params) req := NewRequest[MenuButton]("getChatMenuButton", params)
return req.Do(api) return req.Do(api)
} }
// SetMyDefaultAdministratorRightsP holds parameters for the setMyDefaultAdministratorRights method. // 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 GetChatMenuButton) (MenuButton, error) {
req := NewRequest[MenuButton]("getChatMenuButton", params)
return req.DoWithContext(ctx, api)
}
// SetMyDefaultAdministratorRights holds parameters for the setMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights // See https://core.telegram.org/bots/api#setmydefaultadministratorrights
type SetMyDefaultAdministratorRightsP struct { type SetMyDefaultAdministratorRights struct {
Rights *ChatAdministratorRights `json:"rights"` Rights *ChatAdministratorRights `json:"rights"`
ForChannels bool `json:"for_channels"` ForChannels bool `json:"for_channels"`
} }
@@ -189,24 +295,40 @@ type SetMyDefaultAdministratorRightsP struct {
// SetMyDefaultAdministratorRights changes the default administrator rights for the bot. // SetMyDefaultAdministratorRights changes the default administrator rights for the bot.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setmydefaultadministratorrights // 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) req := NewRequest[bool]("setMyDefaultAdministratorRights", params)
return req.Do(api) return req.Do(api)
} }
// GetMyDefaultAdministratorRightsP holds parameters for the getMyDefaultAdministratorRights method. // 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 SetMyDefaultAdministratorRights) (bool, error) {
req := NewRequest[bool]("setMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
// GetMyDefaultAdministratorRights holds parameters for the getMyDefaultAdministratorRights method.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights // See https://core.telegram.org/bots/api#getmydefaultadministratorrights
type GetMyDefaultAdministratorRightsP struct { type GetMyDefaultAdministratorRights struct {
ForChannels bool `json:"for_channels"` ForChannels bool `json:"for_channels"`
} }
// GetMyDefaultAdministratorRights returns the current default administrator rights for the bot. // GetMyDefaultAdministratorRights returns the current default administrator rights for the bot.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights // 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) req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params)
return req.Do(api) return req.Do(api)
} }
// GetMyDefaultAdministratorRightsWithContext is the context-aware variant of GetMyDefaultAdministratorRights.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmydefaultadministratorrights
func (api *API) GetMyDefaultAdministratorRightsWithContext(ctx context.Context, params GetMyDefaultAdministratorRights) (ChatAdministratorRights, error) {
req := NewRequest[ChatAdministratorRights]("getMyDefaultAdministratorRights", params)
return req.DoWithContext(ctx, api)
}
// GetAvailableGifts returns the list of gifts that can be sent by the bot. // GetAvailableGifts returns the list of gifts that can be sent by the bot.
// See https://core.telegram.org/bots/api#getavailablegifts // See https://core.telegram.org/bots/api#getavailablegifts
func (api *API) GetAvailableGifts() (Gifts, error) { func (api *API) GetAvailableGifts() (Gifts, error) {
@@ -214,9 +336,17 @@ func (api *API) GetAvailableGifts() (Gifts, error) {
return req.Do(api) return req.Do(api)
} }
// SendGiftP holds parameters for the sendGift method. // GetAvailableGiftsWithContext is the context-aware variant of GetAvailableGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getavailablegifts
func (api *API) GetAvailableGiftsWithContext(ctx context.Context) (Gifts, error) {
req := NewRequest[Gifts]("getAvailableGifts", NoParams)
return req.DoWithContext(ctx, api)
}
// SendGift holds parameters for the sendGift method.
// See https://core.telegram.org/bots/api#sendgift // See https://core.telegram.org/bots/api#sendgift
type SendGiftP struct { type SendGift struct {
UserID int64 `json:"user_id,omitempty"` UserID int64 `json:"user_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
GiftID string `json:"gift_id"` GiftID string `json:"gift_id"`
@@ -229,14 +359,22 @@ type SendGiftP struct {
// SendGift sends a gift to the given user or chat. // SendGift sends a gift to the given user or chat.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#sendgift // 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) req := NewRequest[bool]("sendGift", params)
return req.Do(api) return req.Do(api)
} }
// GiftPremiumSubscriptionP holds parameters for the giftPremiumSubscription method. // 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 SendGift) (bool, error) {
req := NewRequest[bool]("sendGift", params)
return req.DoWithContext(ctx, api)
}
// GiftPremiumSubscription holds parameters for the giftPremiumSubscription method.
// See https://core.telegram.org/bots/api#giftpremiumsubscription // See https://core.telegram.org/bots/api#giftpremiumsubscription
type GiftPremiumSubscriptionP struct { type GiftPremiumSubscription struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
MonthCount int `json:"month_count"` MonthCount int `json:"month_count"`
StarCount int `json:"star_count"` StarCount int `json:"star_count"`
@@ -248,7 +386,15 @@ type GiftPremiumSubscriptionP struct {
// GiftPremiumSubscription gifts a Telegram Premium subscription to the user. // GiftPremiumSubscription gifts a Telegram Premium subscription to the user.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#giftpremiumsubscription // 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) req := NewRequest[bool]("giftPremiumSubscription", params)
return req.Do(api) return req.Do(api)
} }
// GiftPremiumSubscriptionWithContext is the context-aware variant of GiftPremiumSubscription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#giftpremiumsubscription
func (api *API) GiftPremiumSubscriptionWithContext(ctx context.Context, params 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 type InputProfilePhotoType string
const ( const (
InputProfilePhotoStaticType InputProfilePhotoType = "static" // InputProfilePhotoStaticType identifies a static profile photo input.
InputProfilePhotoStaticType InputProfilePhotoType = "static"
// InputProfilePhotoAnimatedType identifies an animated profile photo input.
InputProfilePhotoAnimatedType InputProfilePhotoType = "animated" InputProfilePhotoAnimatedType InputProfilePhotoType = "animated"
) )
@@ -75,17 +77,20 @@ type InputProfilePhoto struct {
type MenuButtonType string type MenuButtonType string
const ( const (
// MenuButtonCommandsType identifies a commands menu button.
MenuButtonCommandsType MenuButtonType = "commands" MenuButtonCommandsType MenuButtonType = "commands"
MenuButtonWebAppType MenuButtonType = "web_app" // MenuButtonWebAppType identifies a web app menu button.
MenuButtonDefaultType MenuButtonType = "default" MenuButtonWebAppType MenuButtonType = "web_app"
// MenuButtonDefaultType identifies Telegram's default menu button.
MenuButtonDefaultType MenuButtonType = "default"
) )
// BaseMenuButton represents a menu button. // MenuButton represents a menu button.
// See https://core.telegram.org/bots/api#menubutton // See https://core.telegram.org/bots/api#menubutton
type BaseMenuButton struct { type MenuButton struct {
Type MenuButtonType `json:"type"` Type MenuButtonType `json:"type"`
// WebApp fields (for web_app button) // WebApp fields (for web_app button)
Text string `json:"text"` Text *string `json:"text"`
WebApp WebAppInfo `json:"web_app"` WebApp *WebAppInfo `json:"web_app"`
} }
+249 -70
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// VerifyUserP holds parameters for the verifyUser method. import "context"
// VerifyUser holds parameters for the verifyUser method.
// See https://core.telegram.org/bots/api#verifyuser // See https://core.telegram.org/bots/api#verifyuser
type VerifyUserP struct { type VerifyUser struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
CustomDescription string `json:"custom_description,omitempty"` CustomDescription string `json:"custom_description,omitempty"`
} }
@@ -10,14 +12,22 @@ type VerifyUserP struct {
// VerifyUser verifies a user. // VerifyUser verifies a user.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#verifyuser // 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) req := NewRequest[bool]("verifyUser", params)
return req.Do(api) return req.Do(api)
} }
// VerifyChatP holds parameters for the verifyChat method. // 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 VerifyUser) (bool, error) {
req := NewRequest[bool]("verifyUser", params)
return req.DoWithContext(ctx, api)
}
// VerifyChat holds parameters for the verifyChat method.
// See https://core.telegram.org/bots/api#verifychat // See https://core.telegram.org/bots/api#verifychat
type VerifyChatP struct { type VerifyChat struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
CustomDescription string `json:"custom_description,omitempty"` CustomDescription string `json:"custom_description,omitempty"`
} }
@@ -25,42 +35,66 @@ type VerifyChatP struct {
// VerifyChat verifies a chat. // VerifyChat verifies a chat.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#verifychat // 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) req := NewRequest[bool]("verifyChat", params)
return req.Do(api) return req.Do(api)
} }
// RemoveUserVerificationP holds parameters for the removeUserVerification method. // 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 VerifyChat) (bool, error) {
req := NewRequest[bool]("verifyChat", params)
return req.DoWithContext(ctx, api)
}
// RemoveUserVerification holds parameters for the removeUserVerification method.
// See https://core.telegram.org/bots/api#removeuserverification // See https://core.telegram.org/bots/api#removeuserverification
type RemoveUserVerificationP struct { type RemoveUserVerification struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
// RemoveUserVerification removes a user's verification. // RemoveUserVerification removes a user's verification.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#removeuserverification // 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) req := NewRequest[bool]("removeUserVerification", params)
return req.Do(api) return req.Do(api)
} }
// RemoveChatVerificationP holds parameters for the removeChatVerification method. // 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 RemoveUserVerification) (bool, error) {
req := NewRequest[bool]("removeUserVerification", params)
return req.DoWithContext(ctx, api)
}
// RemoveChatVerification holds parameters for the removeChatVerification method.
// See https://core.telegram.org/bots/api#removechatverification // See https://core.telegram.org/bots/api#removechatverification
type RemoveChatVerificationP struct { type RemoveChatVerification struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// RemoveChatVerification removes a chat's verification. // RemoveChatVerification removes a chat's verification.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#removechatverification // 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) req := NewRequest[bool]("removeChatVerification", params)
return req.Do(api) return req.Do(api)
} }
// ReadBusinessMessageP holds parameters for the readBusinessMessage method. // 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 RemoveChatVerification) (bool, error) {
req := NewRequest[bool]("removeChatVerification", params)
return req.DoWithContext(ctx, api)
}
// ReadBusinessMessage holds parameters for the readBusinessMessage method.
// See https://core.telegram.org/bots/api#readbusinessmessage // See https://core.telegram.org/bots/api#readbusinessmessage
type ReadBusinessMessageP struct { type ReadBusinessMessage struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
@@ -69,27 +103,43 @@ type ReadBusinessMessageP struct {
// ReadBusinessMessage marks a business message as read. // ReadBusinessMessage marks a business message as read.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#readbusinessmessage // 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) req := NewRequest[bool]("readBusinessMessage", params)
return req.Do(api) return req.Do(api)
} }
// GetBusinessConnectionP holds parameters for the getBusinessConnection method. // 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 ReadBusinessMessage) (bool, error) {
req := NewRequest[bool]("readBusinessMessage", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessConnection holds parameters for the getBusinessConnection method.
// See https://core.telegram.org/bots/api#getbusinessconnection // See https://core.telegram.org/bots/api#getbusinessconnection
type GetBusinessConnectionP struct { type GetBusinessConnection struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
} }
// GetBusinessConnection returns information about a business connection. // GetBusinessConnection returns information about a business connection.
// See https://core.telegram.org/bots/api#getbusinessconnection // 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) req := NewRequest[BusinessConnection]("getBusinessConnection", params)
return req.Do(api) return req.Do(api)
} }
// DeleteBusinessMessagesP holds parameters for the deleteBusinessMessages method. // 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 GetBusinessConnection) (BusinessConnection, error) {
req := NewRequest[BusinessConnection]("getBusinessConnection", params)
return req.DoWithContext(ctx, api)
}
// DeleteBusinessMessages holds parameters for the deleteBusinessMessages method.
// See https://core.telegram.org/bots/api#deletebusinessmessages // See https://core.telegram.org/bots/api#deletebusinessmessages
type DeleteBusinessMessagesP struct { type DeleteBusinessMessages struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
MessageIDs []int `json:"message_ids"` MessageIDs []int `json:"message_ids"`
} }
@@ -97,14 +147,22 @@ type DeleteBusinessMessagesP struct {
// DeleteBusinessMessages deletes business messages. // DeleteBusinessMessages deletes business messages.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#deletebusinessmessages // 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) req := NewRequest[bool]("deleteBusinessMessages", params)
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountNameP holds parameters for the setBusinessAccountName method. // 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 DeleteBusinessMessages) (bool, error) {
req := NewRequest[bool]("deleteBusinessMessages", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountName holds parameters for the setBusinessAccountName method.
// See https://core.telegram.org/bots/api#setbusinessaccountname // See https://core.telegram.org/bots/api#setbusinessaccountname
type SetBusinessAccountNameP struct { type SetBusinessAccountName struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LastName string `json:"last_name,omitempty"` LastName string `json:"last_name,omitempty"`
@@ -113,14 +171,22 @@ type SetBusinessAccountNameP struct {
// SetBusinessAccountName sets the first and last name of a business account. // SetBusinessAccountName sets the first and last name of a business account.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountname // 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) req := NewRequest[bool]("setBusinessAccountName", params)
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountUsernameP holds parameters for the setBusinessAccountUsername method. // 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 SetBusinessAccountName) (bool, error) {
req := NewRequest[bool]("setBusinessAccountName", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountUsername holds parameters for the setBusinessAccountUsername method.
// See https://core.telegram.org/bots/api#setbusinessaccountusername // See https://core.telegram.org/bots/api#setbusinessaccountusername
type SetBusinessAccountUsernameP struct { type SetBusinessAccountUsername struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
} }
@@ -128,14 +194,22 @@ type SetBusinessAccountUsernameP struct {
// SetBusinessAccountUsername sets the username of a business account. // SetBusinessAccountUsername sets the username of a business account.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountusername // 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) req := NewRequest[bool]("setBusinessAccountUsername", params)
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountBioP holds parameters for the setBusinessAccountBio method. // 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 SetBusinessAccountUsername) (bool, error) {
req := NewRequest[bool]("setBusinessAccountUsername", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountBio holds parameters for the setBusinessAccountBio method.
// See https://core.telegram.org/bots/api#setbusinessaccountbio // See https://core.telegram.org/bots/api#setbusinessaccountbio
type SetBusinessAccountBioP struct { type SetBusinessAccountBio struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
Bio string `json:"bio,omitempty"` Bio string `json:"bio,omitempty"`
} }
@@ -143,11 +217,19 @@ type SetBusinessAccountBioP struct {
// SetBusinessAccountBio sets the bio of a business account. // SetBusinessAccountBio sets the bio of a business account.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountbio // 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) req := NewRequest[bool]("setBusinessAccountBio", params)
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountBioWithContext is the context-aware variant of SetBusinessAccountBio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountbio
func (api *API) SetBusinessAccountBioWithContext(ctx context.Context, params SetBusinessAccountBio) (bool, error) {
req := NewRequest[bool]("setBusinessAccountBio", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method. // SetBusinessAccountProfilePhoto holds parameters for the setBusinessAccountProfilePhoto method.
// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto // See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto
type SetBusinessAccountProfilePhoto struct { type SetBusinessAccountProfilePhoto struct {
@@ -164,9 +246,17 @@ func (api *API) SetBusinessAccountProfilePhoto(params SetBusinessAccountProfileP
return req.Do(api) return req.Do(api)
} }
// RemoveBusinessAccountProfilePhotoP holds parameters for the removeBusinessAccountProfilePhoto method. // SetBusinessAccountProfilePhotoWithContext is the context-aware variant of SetBusinessAccountProfilePhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setbusinessaccountprofilephoto
func (api *API) SetBusinessAccountProfilePhotoWithContext(ctx context.Context, params SetBusinessAccountProfilePhoto) (bool, error) {
req := NewRequest[bool]("setBusinessAccountProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// RemoveBusinessAccountProfilePhoto holds parameters for the removeBusinessAccountProfilePhoto method.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto // See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto
type RemoveBusinessAccountProfilePhotoP struct { type RemoveBusinessAccountProfilePhoto struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
IsPublic bool `json:"is_public,omitempty"` IsPublic bool `json:"is_public,omitempty"`
} }
@@ -174,14 +264,22 @@ type RemoveBusinessAccountProfilePhotoP struct {
// RemoveBusinessAccountProfilePhoto removes the profile photo of a business account. // RemoveBusinessAccountProfilePhoto removes the profile photo of a business account.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#removebusinessaccountprofilephoto // 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) req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params)
return req.Do(api) return req.Do(api)
} }
// SetBusinessAccountGiftSettingsP holds parameters for the setBusinessAccountGiftSettings method. // 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 RemoveBusinessAccountProfilePhoto) (bool, error) {
req := NewRequest[bool]("removeBusinessAccountProfilePhoto", params)
return req.DoWithContext(ctx, api)
}
// SetBusinessAccountGiftSettings holds parameters for the setBusinessAccountGiftSettings method.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings // See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings
type SetBusinessAccountGiftSettingsP struct { type SetBusinessAccountGiftSettings struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
ShowGiftButton bool `json:"show_gift_button"` ShowGiftButton bool `json:"show_gift_button"`
AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types"` AcceptedGiftTypes AcceptedGiftTypes `json:"accepted_gift_types"`
@@ -190,27 +288,43 @@ type SetBusinessAccountGiftSettingsP struct {
// SetBusinessAccountGiftSettings sets gift settings for a business account. // SetBusinessAccountGiftSettings sets gift settings for a business account.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setbusinessaccountgiftsettings // 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) req := NewRequest[bool]("setBusinessAccountGiftSettings", params)
return req.Do(api) return req.Do(api)
} }
// GetBusinessAccountStarBalanceP holds parameters for the getBusinessAccountStarBalance method. // 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 SetBusinessAccountGiftSettings) (bool, error) {
req := NewRequest[bool]("setBusinessAccountGiftSettings", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountStarBalance holds parameters for the getBusinessAccountStarBalance method.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance // See https://core.telegram.org/bots/api#getbusinessaccountstarbalance
type GetBusinessAccountStarBalanceP struct { type GetBusinessAccountStarBalance struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
} }
// GetBusinessAccountStarBalance returns the star balance of a business account. // GetBusinessAccountStarBalance returns the star balance of a business account.
// See https://core.telegram.org/bots/api#getbusinessaccountstarbalance // 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) req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params)
return req.Do(api) return req.Do(api)
} }
// TransferBusinessAccountStarsP holds parameters for the transferBusinessAccountStars method. // 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 GetBusinessAccountStarBalance) (StarAmount, error) {
req := NewRequest[StarAmount]("getBusinessAccountStarBalance", params)
return req.DoWithContext(ctx, api)
}
// TransferBusinessAccountStars holds parameters for the transferBusinessAccountStars method.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars // See https://core.telegram.org/bots/api#transferbusinessaccountstars
type TransferBusinessAccountStarsP struct { type TransferBusinessAccountStars struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
StarCount int `json:"star_count"` StarCount int `json:"star_count"`
} }
@@ -218,14 +332,22 @@ type TransferBusinessAccountStarsP struct {
// TransferBusinessAccountStars transfers stars from a business account. // TransferBusinessAccountStars transfers stars from a business account.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#transferbusinessaccountstars // 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) req := NewRequest[bool]("transferBusinessAccountStars", params)
return req.Do(api) return req.Do(api)
} }
// GetBusinessAccountGiftsP holds parameters for the getBusinessAccountGifts method. // 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 TransferBusinessAccountStars) (bool, error) {
req := NewRequest[bool]("transferBusinessAccountStars", params)
return req.DoWithContext(ctx, api)
}
// GetBusinessAccountGifts holds parameters for the getBusinessAccountGifts method.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts // See https://core.telegram.org/bots/api#getbusinessaccountgifts
type GetBusinessAccountGiftsP struct { type GetBusinessAccountGifts struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"` ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"`
ExcludeSaved bool `json:"exclude_saved,omitempty"` ExcludeSaved bool `json:"exclude_saved,omitempty"`
@@ -241,14 +363,22 @@ type GetBusinessAccountGiftsP struct {
// GetBusinessAccountGifts returns gifts owned by a business account. // GetBusinessAccountGifts returns gifts owned by a business account.
// See https://core.telegram.org/bots/api#getbusinessaccountgifts // 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) req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params)
return req.Do(api) return req.Do(api)
} }
// ConvertGiftToStarsP holds parameters for the convertGiftToStars method. // 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 GetBusinessAccountGifts) (OwnedGifts, error) {
req := NewRequest[OwnedGifts]("getBusinessAccountGifts", params)
return req.DoWithContext(ctx, api)
}
// ConvertGiftToStars holds parameters for the convertGiftToStars method.
// See https://core.telegram.org/bots/api#convertgifttostars // See https://core.telegram.org/bots/api#convertgifttostars
type ConvertGiftToStarsP struct { type ConvertGiftToStars struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
OwnedGiftID string `json:"owned_gift_id"` OwnedGiftID string `json:"owned_gift_id"`
} }
@@ -256,14 +386,22 @@ type ConvertGiftToStarsP struct {
// ConvertGiftToStars converts a gift to Telegram Stars. // ConvertGiftToStars converts a gift to Telegram Stars.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#convertgifttostars // 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) req := NewRequest[bool]("convertGiftToStars", params)
return req.Do(api) return req.Do(api)
} }
// UpgradeGiftP holds parameters for the upgradeGift method. // 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 ConvertGiftToStars) (bool, error) {
req := NewRequest[bool]("convertGiftToStars", params)
return req.DoWithContext(ctx, api)
}
// UpgradeGift holds parameters for the upgradeGift method.
// See https://core.telegram.org/bots/api#upgradegift // See https://core.telegram.org/bots/api#upgradegift
type UpgradeGiftP struct { type UpgradeGift struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
OwnedGiftID string `json:"owned_gift_id"` OwnedGiftID string `json:"owned_gift_id"`
KeepOriginalDetails bool `json:"keep_original_details,omitempty"` KeepOriginalDetails bool `json:"keep_original_details,omitempty"`
@@ -273,14 +411,22 @@ type UpgradeGiftP struct {
// UpgradeGift upgrades a gift. // UpgradeGift upgrades a gift.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#upgradegift // 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) req := NewRequest[bool]("upgradeGift", params)
return req.Do(api) return req.Do(api)
} }
// TransferGiftP holds parameters for the transferGift method. // 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 UpgradeGift) (bool, error) {
req := NewRequest[bool]("upgradeGift", params)
return req.DoWithContext(ctx, api)
}
// TransferGift holds parameters for the transferGift method.
// See https://core.telegram.org/bots/api#transfergift // See https://core.telegram.org/bots/api#transfergift
type TransferGiftP struct { type TransferGift struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
OwnedGiftID string `json:"owned_gift_id"` OwnedGiftID string `json:"owned_gift_id"`
NewOwnerChatID int64 `json:"new_owner_chat_id"` NewOwnerChatID int64 `json:"new_owner_chat_id"`
@@ -290,14 +436,22 @@ type TransferGiftP struct {
// TransferGift transfers a gift to another chat. // TransferGift transfers a gift to another chat.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#transfergift // 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) req := NewRequest[bool]("transferGift", params)
return req.Do(api) return req.Do(api)
} }
// PostStoryP holds parameters for the postStory method. // 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 TransferGift) (bool, error) {
req := NewRequest[bool]("transferGift", params)
return req.DoWithContext(ctx, api)
}
// PostStory holds parameters for the postStory method.
// See https://core.telegram.org/bots/api#poststory // See https://core.telegram.org/bots/api#poststory
type PostStoryP struct { type PostStory struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
Content InputStoryContent `json:"content"` Content InputStoryContent `json:"content"`
ActivePeriod int `json:"active_period"` ActivePeriod int `json:"active_period"`
@@ -311,23 +465,24 @@ type PostStoryP struct {
ProtectContent bool `json:"protect_content,omitempty"` 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 // 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) req := NewRequest[Story]("postStory", params)
return req.Do(api) return req.Do(api)
} }
// PostStoryVideo posts a story with a video. // 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 // See https://core.telegram.org/bots/api#poststory
func (api *API) PostStoryVideo(params PostStoryP) (Story, error) { func (api *API) PostStoryWithContext(ctx context.Context, params PostStory) (Story, error) {
req := NewRequest[Story]("postStory", params) req := NewRequest[Story]("postStory", params)
return req.Do(api) 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 // See https://core.telegram.org/bots/api#repoststory
type RepostStoryP struct { type RepostStory struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
FromChatID int64 `json:"from_chat_id"` FromChatID int64 `json:"from_chat_id"`
FromStoryID int `json:"from_story_id"` FromStoryID int `json:"from_story_id"`
@@ -339,14 +494,22 @@ type RepostStoryP struct {
// RepostStory reposts a story from another chat. // RepostStory reposts a story from another chat.
// Returns the reposted story. // Returns the reposted story.
// See https://core.telegram.org/bots/api#repoststory // 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) req := NewRequest[Story]("repostStory", params)
return req.Do(api) return req.Do(api)
} }
// EditStoryP holds parameters for the editStory method. // 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 RepostStory) (Story, error) {
req := NewRequest[Story]("repostStory", params)
return req.DoWithContext(ctx, api)
}
// EditStory holds parameters for the editStory method.
// See https://core.telegram.org/bots/api#editstory // See https://core.telegram.org/bots/api#editstory
type EditStoryP struct { type EditStory struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
StoryID int `json:"story_id"` StoryID int `json:"story_id"`
Content InputStoryContent `json:"content"` Content InputStoryContent `json:"content"`
@@ -360,14 +523,22 @@ type EditStoryP struct {
// EditStory edits an existing story. // EditStory edits an existing story.
// Returns the updated story. // Returns the updated story.
// See https://core.telegram.org/bots/api#editstory // 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) req := NewRequest[Story]("editStory", params)
return req.Do(api) return req.Do(api)
} }
// DeleteStoryP holds parameters for the deleteStory method. // 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 EditStory) (Story, error) {
req := NewRequest[Story]("editStory", params)
return req.DoWithContext(ctx, api)
}
// DeleteStory holds parameters for the deleteStory method.
// See https://core.telegram.org/bots/api#deletestory // See https://core.telegram.org/bots/api#deletestory
type DeleteStoryP struct { type DeleteStory struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
StoryID int `json:"story_id"` StoryID int `json:"story_id"`
} }
@@ -375,7 +546,15 @@ type DeleteStoryP struct {
// DeleteStory deletes a story. // DeleteStory deletes a story.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#deletestory // 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) req := NewRequest[bool]("deleteStory", params)
return req.Do(api) return req.Do(api)
} }
// DeleteStoryWithContext is the context-aware variant of DeleteStory.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestory
func (api *API) DeleteStoryWithContext(ctx context.Context, params 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 type InputStoryContentType string
const ( const (
// InputStoryContentPhotoType identifies photo story content.
InputStoryContentPhotoType InputStoryContentType = "photo" InputStoryContentPhotoType InputStoryContentType = "photo"
// InputStoryContentVideoType identifies video story content.
InputStoryContentVideoType InputStoryContentType = "video" InputStoryContentVideoType InputStoryContentType = "video"
) )
@@ -106,10 +108,15 @@ type StoryAreaPosition struct {
type StoryAreaTypeType string type StoryAreaTypeType string
const ( const (
StoryAreaTypeLocationType StoryAreaTypeType = "location" // StoryAreaTypeLocationType identifies a location story area.
StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction" StoryAreaTypeLocationType StoryAreaTypeType = "location"
StoryAreaTypeLinkType StoryAreaTypeType = "link" // StoryAreaTypeReactionType identifies a suggested reaction story area.
StoryAreaTypeWeatherType StoryAreaTypeType = "weather" StoryAreaTypeReactionType StoryAreaTypeType = "suggested_reaction"
// StoryAreaTypeLinkType identifies a link story area.
StoryAreaTypeLinkType StoryAreaTypeType = "link"
// StoryAreaTypeWeatherType identifies a weather story area.
StoryAreaTypeWeatherType StoryAreaTypeType = "weather"
// StoryAreaTypeUniqueGiftType identifies a unique gift story area.
StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift" StoryAreaTypeUniqueGiftType StoryAreaTypeType = "unique_gift"
) )
+354 -96
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// BanChatMemberP holds parameters for the banChatMember method. import "context"
// BanChatMember holds parameters for the banChatMember method.
// See https://core.telegram.org/bots/api#banchatmember // See https://core.telegram.org/bots/api#banchatmember
type BanChatMemberP struct { type BanChatMember struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
UntilDate int `json:"until_date,omitempty"` UntilDate int `json:"until_date,omitempty"`
@@ -12,14 +14,22 @@ type BanChatMemberP struct {
// BanChatMember bans a user in a chat. // BanChatMember bans a user in a chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#banchatmember // 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) req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// UnbanChatMemberP holds parameters for the unbanChatMember method. // 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 BanChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("banChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatMember holds parameters for the unbanChatMember method.
// See https://core.telegram.org/bots/api#unbanchatmember // See https://core.telegram.org/bots/api#unbanchatmember
type UnbanChatMemberP struct { type UnbanChatMember struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
OnlyIfBanned bool `json:"only_if_banned"` OnlyIfBanned bool `json:"only_if_banned"`
@@ -28,14 +38,22 @@ type UnbanChatMemberP struct {
// UnbanChatMember unbans a previously banned user in a chat. // UnbanChatMember unbans a previously banned user in a chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unbanchatmember // 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) req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// RestrictChatMemberP holds parameters for the restrictChatMember method. // 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 UnbanChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RestrictChatMember holds parameters for the restrictChatMember method.
// See https://core.telegram.org/bots/api#restrictchatmember // See https://core.telegram.org/bots/api#restrictchatmember
type RestrictChatMemberP struct { type RestrictChatMember struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Permissions ChatPermissions `json:"permissions"` Permissions ChatPermissions `json:"permissions"`
@@ -46,11 +64,19 @@ type RestrictChatMemberP struct {
// RestrictChatMember restricts a user in a chat. // RestrictChatMember restricts a user in a chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#restrictchatmember // 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) req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// RestrictChatMemberWithContext is the context-aware variant of RestrictChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#restrictchatmember
func (api *API) RestrictChatMemberWithContext(ctx context.Context, params RestrictChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("restrictChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// PromoteChatMember holds parameters for the promoteChatMember method. // PromoteChatMember holds parameters for the promoteChatMember method.
// See https://core.telegram.org/bots/api#promotechatmember // See https://core.telegram.org/bots/api#promotechatmember
type PromoteChatMember struct { type PromoteChatMember struct {
@@ -84,9 +110,17 @@ func (api *API) PromoteChatMember(params PromoteChatMember) (bool, error) {
return req.Do(api) return req.Do(api)
} }
// SetChatAdministratorCustomTitleP holds parameters for the setChatAdministratorCustomTitle method. // PromoteChatMemberWithContext is the context-aware variant of PromoteChatMember.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#promotechatmember
func (api *API) PromoteChatMemberWithContext(ctx context.Context, params PromoteChatMember) (bool, error) {
req := NewRequestWithChatID[bool]("promoteChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatAdministratorCustomTitle holds parameters for the setChatAdministratorCustomTitle method.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle // See https://core.telegram.org/bots/api#setchatadministratorcustomtitle
type SetChatAdministratorCustomTitleP struct { type SetChatAdministratorCustomTitle struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
CustomTitle string `json:"custom_title"` CustomTitle string `json:"custom_title"`
@@ -95,14 +129,22 @@ type SetChatAdministratorCustomTitleP struct {
// SetChatAdministratorCustomTitle sets a custom title for an administrator. // SetChatAdministratorCustomTitle sets a custom title for an administrator.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setchatadministratorcustomtitle // 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) req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetChatMemberTagP holds parameters for the setChatMemberTag method. // 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 SetChatAdministratorCustomTitle) (bool, error) {
req := NewRequestWithChatID[bool]("setChatAdministratorCustomTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatMemberTag holds parameters for the setChatMemberTag method.
// See https://core.telegram.org/bots/api#setchatmembertag // See https://core.telegram.org/bots/api#setchatmembertag
type SetChatMemberTagP struct { type SetChatMemberTag struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Tag string `json:"tag,omitempty"` Tag string `json:"tag,omitempty"`
@@ -111,14 +153,22 @@ type SetChatMemberTagP struct {
// SetChatMemberTag sets a tag for a chat member. // SetChatMemberTag sets a tag for a chat member.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setchatmembertag // 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) req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// BanChatSenderChatP holds parameters for the banChatSenderChat method. // 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 SetChatMemberTag) (bool, error) {
req := NewRequestWithChatID[bool]("setChatMemberTag", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// BanChatSenderChat holds parameters for the banChatSenderChat method.
// See https://core.telegram.org/bots/api#banchatsenderchat // See https://core.telegram.org/bots/api#banchatsenderchat
type BanChatSenderChatP struct { type BanChatSenderChat struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
SenderChatID int64 `json:"sender_chat_id"` SenderChatID int64 `json:"sender_chat_id"`
} }
@@ -126,14 +176,22 @@ type BanChatSenderChatP struct {
// BanChatSenderChat bans a channel chat in a supergroup or channel. // BanChatSenderChat bans a channel chat in a supergroup or channel.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#banchatsenderchat // 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) req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// UnbanChatSenderChatP holds parameters for the unbanChatSenderChat method. // 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 BanChatSenderChat) (bool, error) {
req := NewRequestWithChatID[bool]("banChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnbanChatSenderChat holds parameters for the unbanChatSenderChat method.
// See https://core.telegram.org/bots/api#unbanchatsenderchat // See https://core.telegram.org/bots/api#unbanchatsenderchat
type UnbanChatSenderChatP struct { type UnbanChatSenderChat struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
SenderChatID int64 `json:"sender_chat_id"` SenderChatID int64 `json:"sender_chat_id"`
} }
@@ -141,14 +199,22 @@ type UnbanChatSenderChatP struct {
// UnbanChatSenderChat unbans a previously banned channel chat. // UnbanChatSenderChat unbans a previously banned channel chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unbanchatsenderchat // 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) req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetChatPermissionsP holds parameters for the setChatPermissions method. // 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 UnbanChatSenderChat) (bool, error) {
req := NewRequestWithChatID[bool]("unbanChatSenderChat", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPermissions holds parameters for the setChatPermissions method.
// See https://core.telegram.org/bots/api#setchatpermissions // See https://core.telegram.org/bots/api#setchatpermissions
type SetChatPermissionsP struct { type SetChatPermissions struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Permissions ChatPermissions `json:"permissions"` Permissions ChatPermissions `json:"permissions"`
UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"` UseIndependentChatPermissions bool `json:"use_independent_chat_permissions,omitempty"`
@@ -157,28 +223,44 @@ type SetChatPermissionsP struct {
// SetChatPermissions sets default chat permissions for all members. // SetChatPermissions sets default chat permissions for all members.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setchatpermissions // 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) req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// ExportChatInviteLinkP holds parameters for the exportChatInviteLink method. // 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 SetChatPermissions) (bool, error) {
req := NewRequestWithChatID[bool]("setChatPermissions", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ExportChatInviteLink holds parameters for the exportChatInviteLink method.
// See https://core.telegram.org/bots/api#exportchatinvitelink // See https://core.telegram.org/bots/api#exportchatinvitelink
type ExportChatInviteLinkP struct { type ExportChatInviteLink struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// ExportChatInviteLink generates a new primary invite link for a chat. // ExportChatInviteLink generates a new primary invite link for a chat.
// Returns the new invite link as string. // Returns the new invite link as string.
// See https://core.telegram.org/bots/api#exportchatinvitelink // 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) req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// CreateChatInviteLinkP holds parameters for the createChatInviteLink method. // 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 ExportChatInviteLink) (string, error) {
req := NewRequestWithChatID[string]("exportChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatInviteLink holds parameters for the createChatInviteLink method.
// See https://core.telegram.org/bots/api#createchatinvitelink // See https://core.telegram.org/bots/api#createchatinvitelink
type CreateChatInviteLinkP struct { type CreateChatInviteLink struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Name *string `json:"name,omitempty"` Name *string `json:"name,omitempty"`
ExpireDate int `json:"expire_date,omitempty"` ExpireDate int `json:"expire_date,omitempty"`
@@ -189,14 +271,22 @@ type CreateChatInviteLinkP struct {
// CreateChatInviteLink creates an additional invite link for a chat. // CreateChatInviteLink creates an additional invite link for a chat.
// Returns the created invite link. // Returns the created invite link.
// See https://core.telegram.org/bots/api#createchatinvitelink // 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) req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// EditChatInviteLinkP holds parameters for the editChatInviteLink method. // 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 CreateChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatInviteLink holds parameters for the editChatInviteLink method.
// See https://core.telegram.org/bots/api#editchatinvitelink // See https://core.telegram.org/bots/api#editchatinvitelink
type EditChatInviteLinkP struct { type EditChatInviteLink struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
InviteLink string `json:"invite_link"` InviteLink string `json:"invite_link"`
@@ -209,14 +299,22 @@ type EditChatInviteLinkP struct {
// EditChatInviteLink edits a nonprimary invite link. // EditChatInviteLink edits a nonprimary invite link.
// Returns the edited invite link. // Returns the edited invite link.
// See https://core.telegram.org/bots/api#editchatinvitelink // 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) req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// CreateChatSubscriptionInviteLinkP holds parameters for the createChatSubscriptionInviteLink method. // 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 EditChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateChatSubscriptionInviteLink holds parameters for the createChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink // See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink
type CreateChatSubscriptionInviteLinkP struct { type CreateChatSubscriptionInviteLink struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
SubscriptionPeriod int `json:"subscription_period,omitempty"` SubscriptionPeriod int `json:"subscription_period,omitempty"`
@@ -226,14 +324,22 @@ type CreateChatSubscriptionInviteLinkP struct {
// CreateChatSubscriptionInviteLink creates a subscription invite link for a channel chat. // CreateChatSubscriptionInviteLink creates a subscription invite link for a channel chat.
// Returns the created invite link. // Returns the created invite link.
// See https://core.telegram.org/bots/api#createchatsubscriptioninvitelink // 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) req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// EditChatSubscriptionInviteLinkP holds parameters for the editChatSubscriptionInviteLink method. // 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 CreateChatSubscriptionInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("createChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditChatSubscriptionInviteLink holds parameters for the editChatSubscriptionInviteLink method.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink // See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink
type EditChatSubscriptionInviteLinkP struct { type EditChatSubscriptionInviteLink struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
InviteLink string `json:"invite_link"` InviteLink string `json:"invite_link"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
@@ -242,14 +348,22 @@ type EditChatSubscriptionInviteLinkP struct {
// EditChatSubscriptionInviteLink edits a subscription invite link. // EditChatSubscriptionInviteLink edits a subscription invite link.
// Returns the edited invite link. // Returns the edited invite link.
// See https://core.telegram.org/bots/api#editchatsubscriptioninvitelink // 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) req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// RevokeChatInviteLinkP holds parameters for the revokeChatInviteLink method. // 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 EditChatSubscriptionInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("editChatSubscriptionInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// RevokeChatInviteLink holds parameters for the revokeChatInviteLink method.
// See https://core.telegram.org/bots/api#revokechatinvitelink // See https://core.telegram.org/bots/api#revokechatinvitelink
type RevokeChatInviteLinkP struct { type RevokeChatInviteLink struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
InviteLink string `json:"invite_link"` InviteLink string `json:"invite_link"`
} }
@@ -257,14 +371,22 @@ type RevokeChatInviteLinkP struct {
// RevokeChatInviteLink revokes an invite link. // RevokeChatInviteLink revokes an invite link.
// Returns the revoked invite link object. // Returns the revoked invite link object.
// See https://core.telegram.org/bots/api#revokechatinvitelink // 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) req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// ApproveChatJoinRequestP holds parameters for the approveChatJoinRequest method. // 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 RevokeChatInviteLink) (ChatInviteLink, error) {
req := NewRequestWithChatID[ChatInviteLink]("revokeChatInviteLink", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveChatJoinRequest holds parameters for the approveChatJoinRequest method.
// See https://core.telegram.org/bots/api#approvechatjoinrequest // See https://core.telegram.org/bots/api#approvechatjoinrequest
type ApproveChatJoinRequestP struct { type ApproveChatJoinRequest struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
@@ -272,14 +394,22 @@ type ApproveChatJoinRequestP struct {
// ApproveChatJoinRequest approves a chat join request. // ApproveChatJoinRequest approves a chat join request.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#approvechatjoinrequest // 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) req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// DeclineChatJoinRequestP holds parameters for the declineChatJoinRequest method. // 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 ApproveChatJoinRequest) (bool, error) {
req := NewRequestWithChatID[bool]("approveChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineChatJoinRequest holds parameters for the declineChatJoinRequest method.
// See https://core.telegram.org/bots/api#declinechatjoinrequest // See https://core.telegram.org/bots/api#declinechatjoinrequest
type DeclineChatJoinRequestP struct { type DeclineChatJoinRequest struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
@@ -287,14 +417,22 @@ type DeclineChatJoinRequestP struct {
// DeclineChatJoinRequest declines a chat join request. // DeclineChatJoinRequest declines a chat join request.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#declinechatjoinrequest // 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) req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetChatPhotoP holds parameters for the setChatPhoto method. // 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 DeclineChatJoinRequest) (bool, error) {
req := NewRequestWithChatID[bool]("declineChatJoinRequest", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatPhoto holds parameters for the setChatPhoto method.
// See https://core.telegram.org/bots/api#setchatphoto // See https://core.telegram.org/bots/api#setchatphoto
type SetChatPhotoP struct { type SetChatPhoto struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
@@ -302,7 +440,7 @@ type SetChatPhotoP struct {
// photo is the file to upload as the new photo. // photo is the file to upload as the new photo.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setchatphoto // 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) uploader := NewUploader(api)
defer func() { defer func() {
_ = uploader.Close() _ = uploader.Close()
@@ -311,23 +449,31 @@ func (api *API) SetChatPhoto(params SetChatPhotoP, photo UploaderFile) (bool, er
return req.Do(uploader) 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 // See https://core.telegram.org/bots/api#deletechatphoto
type DeleteChatPhotoP struct { type DeleteChatPhoto struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// DeleteChatPhoto deletes a chat photo. // DeleteChatPhoto deletes a chat photo.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deletechatphoto // 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) req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetChatTitleP holds parameters for the setChatTitle method. // 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 DeleteChatPhoto) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatPhoto", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatTitle holds parameters for the setChatTitle method.
// See https://core.telegram.org/bots/api#setchattitle // See https://core.telegram.org/bots/api#setchattitle
type SetChatTitleP struct { type SetChatTitle struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Title string `json:"title"` Title string `json:"title"`
} }
@@ -335,14 +481,22 @@ type SetChatTitleP struct {
// SetChatTitle changes the chat title. // SetChatTitle changes the chat title.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setchattitle // 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) req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetChatDescriptionP holds parameters for the setChatDescription method. // 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 SetChatTitle) (bool, error) {
req := NewRequestWithChatID[bool]("setChatTitle", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatDescription holds parameters for the setChatDescription method.
// See https://core.telegram.org/bots/api#setchatdescription // See https://core.telegram.org/bots/api#setchatdescription
type SetChatDescriptionP struct { type SetChatDescription struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Description string `json:"description"` Description string `json:"description"`
} }
@@ -350,14 +504,22 @@ type SetChatDescriptionP struct {
// SetChatDescription changes the chat description. // SetChatDescription changes the chat description.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setchatdescription // 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) req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// PinChatMessageP holds parameters for the pinChatMessage method. // 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 SetChatDescription) (bool, error) {
req := NewRequestWithChatID[bool]("setChatDescription", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// PinChatMessage holds parameters for the pinChatMessage method.
// See https://core.telegram.org/bots/api#pinchatmessage // See https://core.telegram.org/bots/api#pinchatmessage
type PinChatMessageP struct { type PinChatMessage struct {
BusinessConnectionID *string `json:"business_connection_id,omitempty"` BusinessConnectionID *string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
@@ -367,14 +529,22 @@ type PinChatMessageP struct {
// PinChatMessage pins a message in a chat. // PinChatMessage pins a message in a chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#pinchatmessage // 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) req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// UnpinChatMessageP holds parameters for the unpinChatMessage method. // 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 PinChatMessage) (bool, error) {
req := NewRequestWithChatID[bool]("pinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinChatMessage holds parameters for the unpinChatMessage method.
// See https://core.telegram.org/bots/api#unpinchatmessage // See https://core.telegram.org/bots/api#unpinchatmessage
type UnpinChatMessageP struct { type UnpinChatMessage struct {
BusinessConnectionID *string `json:"business_connection_id,omitempty"` BusinessConnectionID *string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
@@ -383,95 +553,151 @@ type UnpinChatMessageP struct {
// UnpinChatMessage unpins a message in a chat. // UnpinChatMessage unpins a message in a chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unpinchatmessage // 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) req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// UnpinAllChatMessagesP holds parameters for the unpinAllChatMessages method. // 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 UnpinChatMessage) (bool, error) {
req := NewRequestWithChatID[bool]("unpinChatMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllChatMessages holds parameters for the unpinAllChatMessages method.
// See https://core.telegram.org/bots/api#unpinallchatmessages // See https://core.telegram.org/bots/api#unpinallchatmessages
type UnpinAllChatMessagesP struct { type UnpinAllChatMessages struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// UnpinAllChatMessages unpins all pinned messages in a chat. // UnpinAllChatMessages unpins all pinned messages in a chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unpinallchatmessages // 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) req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// LeaveChatP holds parameters for the leaveChat method. // 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 UnpinAllChatMessages) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllChatMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// LeaveChat holds parameters for the leaveChat method.
// See https://core.telegram.org/bots/api#leavechat // See https://core.telegram.org/bots/api#leavechat
type LeaveChatP struct { type LeaveChat struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// LeaveChat makes the bot leave a chat. // LeaveChat makes the bot leave a chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#leavechat // 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 req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name
return req.Do(api) return req.Do(api)
} }
// GetChatP holds parameters for the getChat method. // 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 LeaveChat) (bool, error) {
req := NewRequestWithChatID[bool]("leaveChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChat holds parameters for the getChat method.
// See https://core.telegram.org/bots/api#getchat // See https://core.telegram.org/bots/api#getchat
type GetChatP struct { type GetChat struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// GetChat gets uptodate information about a chat. // GetChat gets uptodate information about a chat.
// See https://core.telegram.org/bots/api#getchat // 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 req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name
return req.Do(api) return req.Do(api)
} }
// GetChatAdministratorsP holds parameters for the getChatAdministrators method. // 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 GetChat) (ChatFullInfo, error) {
req := NewRequestWithChatID[ChatFullInfo]("getChat", params, params.ChatID) // fixed method name
return req.DoWithContext(ctx, api)
}
// GetChatAdministrators holds parameters for the getChatAdministrators method.
// See https://core.telegram.org/bots/api#getchatadministrators // See https://core.telegram.org/bots/api#getchatadministrators
type GetChatAdministratorsP struct { type GetChatAdministrators struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// GetChatAdministrators returns a list of administrators in a chat. // GetChatAdministrators returns a list of administrators in a chat.
// See https://core.telegram.org/bots/api#getchatadministrators // 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) req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetChatMembersCountP holds parameters for the getChatMemberCount method. // 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 GetChatAdministrators) ([]ChatMember, error) {
req := NewRequestWithChatID[[]ChatMember]("getChatAdministrators", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMembersCount holds parameters for the getChatMemberCount method.
// See https://core.telegram.org/bots/api#getchatmembercount // See https://core.telegram.org/bots/api#getchatmembercount
type GetChatMembersCountP struct { type GetChatMembersCount struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// GetChatMemberCount returns the number of members in a chat. // GetChatMemberCount returns the number of members in a chat.
// See https://core.telegram.org/bots/api#getchatmembercount // 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) req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetChatMemberP holds parameters for the getChatMember method. // 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 GetChatMembersCount) (int, error) {
req := NewRequestWithChatID[int]("getChatMemberCount", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatMember holds parameters for the getChatMember method.
// See https://core.telegram.org/bots/api#getchatmember // See https://core.telegram.org/bots/api#getchatmember
type GetChatMemberP struct { type GetChatMember struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
// GetChatMember returns information about a member of a chat. // GetChatMember returns information about a member of a chat.
// See https://core.telegram.org/bots/api#getchatmember // 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) req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetChatStickerSetP holds parameters for the setChatStickerSet method. // 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 GetChatMember) (ChatMember, error) {
req := NewRequestWithChatID[ChatMember]("getChatMember", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetChatStickerSet holds parameters for the setChatStickerSet method.
// See https://core.telegram.org/bots/api#setchatstickerset // See https://core.telegram.org/bots/api#setchatstickerset
type SetChatStickerSetP struct { type SetChatStickerSet struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
StickerSetName string `json:"sticker_set_name"` StickerSetName string `json:"sticker_set_name"`
} }
@@ -479,42 +705,66 @@ type SetChatStickerSetP struct {
// SetChatStickerSet associates a sticker set with a supergroup. // SetChatStickerSet associates a sticker set with a supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setchatstickerset // 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) req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// DeleteChatStickerSetP holds parameters for the deleteChatStickerSet method. // 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 SetChatStickerSet) (bool, error) {
req := NewRequestWithChatID[bool]("setChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteChatStickerSet holds parameters for the deleteChatStickerSet method.
// See https://core.telegram.org/bots/api#deletechatstickerset // See https://core.telegram.org/bots/api#deletechatstickerset
type DeleteChatStickerSetP struct { type DeleteChatStickerSet struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// DeleteChatStickerSet deletes a sticker set from a supergroup. // DeleteChatStickerSet deletes a sticker set from a supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deletechatstickerset // 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) req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetUserChatBoostsP holds parameters for the getUserChatBoosts method. // 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 DeleteChatStickerSet) (bool, error) {
req := NewRequestWithChatID[bool]("deleteChatStickerSet", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetUserChatBoosts holds parameters for the getUserChatBoosts method.
// See https://core.telegram.org/bots/api#getuserchatboosts // See https://core.telegram.org/bots/api#getuserchatboosts
type GetUserChatBoostsP struct { type GetUserChatBoosts struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
} }
// GetUserChatBoosts returns the list of boosts a user has given to a chat. // GetUserChatBoosts returns the list of boosts a user has given to a chat.
// See https://core.telegram.org/bots/api#getuserchatboosts // 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) req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetChatGiftsP holds parameters for the getChatGifts method. // 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 GetUserChatBoosts) (UserChatBoosts, error) {
req := NewRequestWithChatID[UserChatBoosts]("getUserChatBoosts", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetChatGifts holds parameters for the getChatGifts method.
// See https://core.telegram.org/bots/api#getchatgifts // See https://core.telegram.org/bots/api#getchatgifts
type GetChatGiftsP struct { type GetChatGifts struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"` ExcludeUnsaved bool `json:"exclude_unsaved,omitempty"`
ExcludeSaved bool `json:"exclude_saved,omitempty"` ExcludeSaved bool `json:"exclude_saved,omitempty"`
@@ -530,7 +780,15 @@ type GetChatGiftsP struct {
// GetChatGifts returns gifts owned by a chat. // GetChatGifts returns gifts owned by a chat.
// See https://core.telegram.org/bots/api#getchatgifts // 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) req := NewRequestWithChatID[OwnedGifts]("getChatGifts", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetChatGiftsWithContext is the context-aware variant of GetChatGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getchatgifts
func (api *API) GetChatGiftsWithContext(ctx context.Context, params 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). // Chat represents a chat (private, group, supergroup, channel).
// See https://core.telegram.org/bots/api#chat // See https://core.telegram.org/bots/api#chat
type Chat struct { type Chat struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Type string `json:"type"` Type ChatType `json:"type"`
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Username *string `json:"username,omitempty"` Username *string `json:"username,omitempty"`
FirstName *string `json:"first_name,omitempty"` FirstName *string `json:"first_name,omitempty"`
LastName *string `json:"last_name,omitempty"` LastName *string `json:"last_name,omitempty"`
IsForum *bool `json:"is_forum,omitempty"` IsForum *bool `json:"is_forum,omitempty"`
IsDirectMessages *bool `json:"is_direct_messages,omitempty"` IsDirectMessages *bool `json:"is_direct_messages,omitempty"`
} }
// ChatType represents the type of a chat. // ChatType represents the type of a chat.
type ChatType string type ChatType string
const ( const (
ChatTypePrivate ChatType = "private" // ChatTypePrivate identifies a private chat.
ChatTypeGroup ChatType = "group" ChatTypePrivate ChatType = "private"
// ChatTypeGroup identifies a basic group chat.
ChatTypeGroup ChatType = "group"
// ChatTypeSupergroup identifies a supergroup chat.
ChatTypeSupergroup ChatType = "supergroup" ChatTypeSupergroup ChatType = "supergroup"
ChatTypeChannel ChatType = "channel" // ChatTypeChannel identifies a channel chat.
ChatTypeChannel ChatType = "channel"
) )
// ChatFullInfo contains full information about a chat. // ChatFullInfo contains full information about a chat.
@@ -103,11 +107,12 @@ type ChatPermissions struct {
CanSendAudios bool `json:"can_send_audios"` CanSendAudios bool `json:"can_send_audios"`
CanSendDocuments bool `json:"can_send_documents"` CanSendDocuments bool `json:"can_send_documents"`
CanSendPhotos bool `json:"can_send_photos"` CanSendPhotos bool `json:"can_send_photos"`
CanSendVideos bool `json:"can_send_videos"`
CanSendVideoNotes bool `json:"can_send_video_notes"` CanSendVideoNotes bool `json:"can_send_video_notes"`
CanSendVoiceNotes bool `json:"can_send_voice_notes"` CanSendVoiceNotes bool `json:"can_send_voice_notes"`
CanSendPolls bool `json:"can_send_polls"` CanSendPolls bool `json:"can_send_polls"`
CanSendOtherMessages bool `json:"can_send_other_messages"` 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"` CanEditTag bool `json:"can_edit_tag"`
CanChangeInfo bool `json:"can_change_info"` CanChangeInfo bool `json:"can_change_info"`
CanInviteUsers bool `json:"can_invite_users"` CanInviteUsers bool `json:"can_invite_users"`
@@ -143,12 +148,18 @@ type ChatInviteLink struct {
type ChatMemberStatusType string type ChatMemberStatusType string
const ( const (
ChatMemberStatusOwner ChatMemberStatusType = "owner" // ChatMemberStatusOwner identifies a chat owner.
ChatMemberStatusOwner ChatMemberStatusType = "owner"
// ChatMemberStatusAdministrator identifies a chat administrator.
ChatMemberStatusAdministrator ChatMemberStatusType = "administrator" ChatMemberStatusAdministrator ChatMemberStatusType = "administrator"
ChatMemberStatusMember ChatMemberStatusType = "member" // ChatMemberStatusMember identifies a regular member.
ChatMemberStatusRestricted ChatMemberStatusType = "restricted" ChatMemberStatusMember ChatMemberStatusType = "member"
ChatMemberStatusLeft ChatMemberStatusType = "left" // ChatMemberStatusRestricted identifies a restricted member.
ChatMemberStatusBanned ChatMemberStatusType = "kicked" ChatMemberStatusRestricted ChatMemberStatusType = "restricted"
// ChatMemberStatusLeft identifies a user who left the chat.
ChatMemberStatusLeft ChatMemberStatusType = "left"
// ChatMemberStatusBanned identifies a banned user.
ChatMemberStatusBanned ChatMemberStatusType = "kicked"
) )
// ChatMember contains information about one member of a chat. // ChatMember contains information about one member of a chat.
@@ -190,12 +201,13 @@ type ChatMember struct {
CanSendMessages *bool `json:"can_send_messages,omitempty"` CanSendMessages *bool `json:"can_send_messages,omitempty"`
CanSendAudios *bool `json:"can_send_audios,omitempty"` CanSendAudios *bool `json:"can_send_audios,omitempty"`
CanSendDocuments *bool `json:"can_send_documents,omitempty"` CanSendDocuments *bool `json:"can_send_documents,omitempty"`
CanSendPhotos *bool `json:"can_send_photos,omitempty"`
CanSendVideos *bool `json:"can_send_videos,omitempty"` CanSendVideos *bool `json:"can_send_videos,omitempty"`
CanSendVideoNotes *bool `json:"can_send_video_notes,omitempty"` CanSendVideoNotes *bool `json:"can_send_video_notes,omitempty"`
CanSendVoiceNotes *bool `json:"can_send_voice_notes,omitempty"` CanSendVoiceNotes *bool `json:"can_send_voice_notes,omitempty"`
CanSendPolls *bool `json:"can_send_polls,omitempty"` CanSendPolls *bool `json:"can_send_polls,omitempty"`
CanSendOtherMessages *bool `json:"can_send_other_messages,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"` CanEditTag *bool `json:"can_edit_tag,omitempty"`
} }
@@ -214,7 +226,7 @@ type ChatBoostSource struct {
// ChatBoost represents a boost added to a chat. // ChatBoost represents a boost added to a chat.
// See https://core.telegram.org/bots/api#chatboost // See https://core.telegram.org/bots/api#chatboost
type ChatBoost struct { type ChatBoost struct {
BoostID int `json:"boost_id"` BoostID string `json:"boost_id"`
AddDate int `json:"add_date"` AddDate int `json:"add_date"`
ExpirationDate int `json:"expiration_date"` ExpirationDate int `json:"expiration_date"`
Source ChatBoostSource `json:"source"` Source ChatBoostSource `json:"source"`
@@ -225,6 +237,25 @@ type ChatBoost struct {
type UserChatBoosts struct { type UserChatBoosts struct {
Boosts []ChatBoost `json:"boosts"` 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. // ChatAdministratorRights represents the rights of an administrator in a chat.
// See https://core.telegram.org/bots/api#chatadministratorrights // See https://core.telegram.org/bots/api#chatadministratorrights
+7
View File
@@ -2,7 +2,14 @@ package tgapi
import "errors" import "errors"
// ErrRateLimit reports that a request exceeded the configured rate limiter.
var ErrRateLimit = errors.New("rate limit exceeded") var ErrRateLimit = errors.New("rate limit exceeded")
// ErrPoolUnexpected reports an unexpected result type returned from the worker pool.
var ErrPoolUnexpected = errors.New("unexpected response from pool") var ErrPoolUnexpected = errors.New("unexpected response from pool")
// ErrPoolQueueFull reports that the internal request queue is full.
var ErrPoolQueueFull = errors.New("worker pool queue full") var ErrPoolQueueFull = errors.New("worker pool queue full")
// ErrPoolStopped reports that a request was submitted after the worker pool stopped.
var ErrPoolStopped = errors.New("worker pool stopped") var ErrPoolStopped = errors.New("worker pool stopped")
+129 -23
View File
@@ -1,7 +1,9 @@
package tgapi package tgapi
// BaseForumTopicP contains common fields for forum topic operations that require a chat ID and a message thread ID. import "context"
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"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id"` MessageThreadID int `json:"message_thread_id"`
} }
@@ -13,9 +15,17 @@ func (api *API) GetForumTopicIconStickers() ([]Sticker, error) {
return req.Do(api) return req.Do(api)
} }
// CreateForumTopicP holds parameters for the createForumTopic method. // GetForumTopicIconStickersWithContext is the context-aware variant of GetForumTopicIconStickers.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getforumtopiciconstickers
func (api *API) GetForumTopicIconStickersWithContext(ctx context.Context) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getForumTopicIconStickers", NoParams)
return req.DoWithContext(ctx, api)
}
// CreateForumTopic holds parameters for the createForumTopic method.
// See https://core.telegram.org/bots/api#createforumtopic // See https://core.telegram.org/bots/api#createforumtopic
type CreateForumTopicP struct { type CreateForumTopic struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Name string `json:"name"` Name string `json:"name"`
IconColor ForumTopicIconColor `json:"icon_color"` IconColor ForumTopicIconColor `json:"icon_color"`
@@ -25,15 +35,23 @@ type CreateForumTopicP struct {
// CreateForumTopic creates a topic in a forum supergroup. // CreateForumTopic creates a topic in a forum supergroup.
// Returns the created ForumTopic on success. // Returns the created ForumTopic on success.
// See https://core.telegram.org/bots/api#createforumtopic // 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) req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// EditForumTopicP holds parameters for the editForumTopic method. // 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 CreateForumTopic) (ForumTopic, error) {
req := NewRequestWithChatID[ForumTopic]("createForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditForumTopic holds parameters for the editForumTopic method.
// See https://core.telegram.org/bots/api#editforumtopic // See https://core.telegram.org/bots/api#editforumtopic
type EditForumTopicP struct { type EditForumTopic struct {
BaseForumTopicP BaseForumTopic
Name string `json:"name"` Name string `json:"name"`
IconCustomEmojiID string `json:"icon_custom_emoji_id"` IconCustomEmojiID string `json:"icon_custom_emoji_id"`
} }
@@ -41,51 +59,91 @@ type EditForumTopicP struct {
// EditForumTopic edits name and icon of a forum topic. // EditForumTopic edits name and icon of a forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#editforumtopic // 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) req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// EditForumTopicWithContext is the context-aware variant of EditForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editforumtopic
func (api *API) EditForumTopicWithContext(ctx context.Context, params EditForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("editForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CloseForumTopic closes an open forum topic. // CloseForumTopic closes an open forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#closeforumtopic // See https://core.telegram.org/bots/api#closeforumtopic
func (api *API) CloseForumTopic(params BaseForumTopicP) (bool, error) { func (api *API) CloseForumTopic(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID) req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// CloseForumTopicWithContext is the context-aware variant of CloseForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#closeforumtopic
func (api *API) CloseForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ReopenForumTopic reopens a closed forum topic. // ReopenForumTopic reopens a closed forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#reopenforumtopic // See https://core.telegram.org/bots/api#reopenforumtopic
func (api *API) ReopenForumTopic(params BaseForumTopicP) (bool, error) { func (api *API) ReopenForumTopic(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID) req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// ReopenForumTopicWithContext is the context-aware variant of ReopenForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#reopenforumtopic
func (api *API) ReopenForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteForumTopic deletes a forum topic. // DeleteForumTopic deletes a forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deleteforumtopic // See https://core.telegram.org/bots/api#deleteforumtopic
func (api *API) DeleteForumTopic(params BaseForumTopicP) (bool, error) { func (api *API) DeleteForumTopic(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID) req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// DeleteForumTopicWithContext is the context-aware variant of DeleteForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deleteforumtopic
func (api *API) DeleteForumTopicWithContext(ctx context.Context, params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("deleteForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic. // UnpinAllForumTopicMessages clears the list of pinned messages in a forum topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unpinallforumtopicmessages // See https://core.telegram.org/bots/api#unpinallforumtopicmessages
func (api *API) UnpinAllForumTopicMessages(params BaseForumTopicP) (bool, error) { func (api *API) UnpinAllForumTopicMessages(params BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID) req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// BaseGeneralForumTopicP contains common fields for general forum topic operations that require a chat ID. // UnpinAllForumTopicMessagesWithContext is the context-aware variant of UnpinAllForumTopicMessages.
type BaseGeneralForumTopicP struct { // 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 BaseForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllForumTopicMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// BaseGeneralForumTopic contains common fields for general forum topic operations that require a chat ID.
type BaseGeneralForumTopic struct {
ChatID int64 `json:"chat_id"` 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 // See https://core.telegram.org/bots/api#editgeneralforumtopic
type EditGeneralForumTopicP struct { type EditGeneralForumTopic struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Name string `json:"name"` Name string `json:"name"`
} }
@@ -93,47 +151,95 @@ type EditGeneralForumTopicP struct {
// EditGeneralForumTopic edits the name of the 'General' topic in a forum supergroup. // EditGeneralForumTopic edits the name of the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#editgeneralforumtopic // 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) req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// EditGeneralForumTopicWithContext is the context-aware variant of EditGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#editgeneralforumtopic
func (api *API) EditGeneralForumTopicWithContext(ctx context.Context, params EditGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("editGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CloseGeneralForumTopic closes the 'General' topic in a forum supergroup. // CloseGeneralForumTopic closes the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#closegeneralforumtopic // See https://core.telegram.org/bots/api#closegeneralforumtopic
func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { func (api *API) CloseGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID) req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// CloseGeneralForumTopicWithContext is the context-aware variant of CloseGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#closegeneralforumtopic
func (api *API) CloseGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("closeGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup. // ReopenGeneralForumTopic reopens the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic // See https://core.telegram.org/bots/api#reopengeneralforumtopic
func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { func (api *API) ReopenGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID) req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// ReopenGeneralForumTopicWithContext is the context-aware variant of ReopenGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#reopengeneralforumtopic
func (api *API) ReopenGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("reopenGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// HideGeneralForumTopic hides the 'General' topic in a forum supergroup. // HideGeneralForumTopic hides the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic // See https://core.telegram.org/bots/api#hidegeneralforumtopic
func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { func (api *API) HideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID) req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// HideGeneralForumTopicWithContext is the context-aware variant of HideGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#hidegeneralforumtopic
func (api *API) HideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("hideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup. // UnhideGeneralForumTopic unhides the 'General' topic in a forum supergroup.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic // See https://core.telegram.org/bots/api#unhidegeneralforumtopic
func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopicP) (bool, error) { func (api *API) UnhideGeneralForumTopic(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID) req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// UnhideGeneralForumTopicWithContext is the context-aware variant of UnhideGeneralForumTopic.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unhidegeneralforumtopic
func (api *API) UnhideGeneralForumTopicWithContext(ctx context.Context, params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unhideGeneralForumTopic", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic. // UnpinAllGeneralForumTopicMessages clears the list of pinned messages in the 'General' topic.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages // See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages
func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopicP) (bool, error) { func (api *API) UnpinAllGeneralForumTopicMessages(params BaseGeneralForumTopic) (bool, error) {
req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID) req := NewRequestWithChatID[bool]("unpinAllGeneralForumTopicMessages", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// UnpinAllGeneralForumTopicMessagesWithContext is the context-aware variant of UnpinAllGeneralForumTopicMessages.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#unpinallgeneralforumtopicmessages
func (api *API) UnpinAllGeneralForumTopicMessagesWithContext(ctx context.Context, params 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 is the blue color for forum topic icons (value 7322096).
ForumTopicIconColorBlue ForumTopicIconColor = 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 {
}
+42 -9
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// SendGameP holds parameters for the sendGame method. import "context"
// SendGame holds parameters for the sendGame method.
// See https://core.telegram.org/bots/api#sendgame // See https://core.telegram.org/bots/api#sendgame
type SendGameP struct { type SendGame struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -19,14 +21,22 @@ type SendGameP struct {
// SendGame sends a game message. // SendGame sends a game message.
// See https://core.telegram.org/bots/api#sendgame // 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) req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetGameScoreP holds parameters for the setGameScore method. // 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 SendGame) (Message, error) {
req := NewRequestWithChatID[Message]("sendGame", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetGameScore holds parameters for the setGameScore method.
// See https://core.telegram.org/bots/api#setgamescore // See https://core.telegram.org/bots/api#setgamescore
type SetGameScoreP struct { type SetGameScore struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Score int `json:"score"` Score int `json:"score"`
Force bool `json:"force,omitempty"` Force bool `json:"force,omitempty"`
@@ -40,7 +50,7 @@ type SetGameScoreP struct {
// If inline_message_id is provided, returns a boolean success flag. // If inline_message_id is provided, returns a boolean success flag.
// Otherwise returns the edited Message. // Otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#setgamescore // 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 var zero Message
if params.InlineMessageID != "" { if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID) req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID)
@@ -52,9 +62,24 @@ func (api *API) SetGameScore(params SetGameScoreP) (Message, bool, error) {
return res, false, err return res, false, err
} }
// GetGameHighScoresP holds parameters for the getGameHighScores method. // 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 SetGameScore) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("setGameScore", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("setGameScore", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// GetGameHighScores holds parameters for the getGameHighScores method.
// See https://core.telegram.org/bots/api#getgamehighscores // See https://core.telegram.org/bots/api#getgamehighscores
type GetGameHighScoresP struct { type GetGameHighScores struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
@@ -63,7 +88,15 @@ type GetGameHighScoresP struct {
// GetGameHighScores returns game high score data for a user. // GetGameHighScores returns game high score data for a user.
// See https://core.telegram.org/bots/api#getgamehighscores // 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) req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetGameHighScoresWithContext is the context-aware variant of GetGameHighScores.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getgamehighscores
func (api *API) GetGameHighScoresWithContext(ctx context.Context, params GetGameHighScores) ([]GameHighScore, error) {
req := NewRequestWithChatID[[]GameHighScore]("getGameHighScores", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
+10
View File
@@ -1,5 +1,15 @@
package tgapi 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. // GameHighScore represents one row in a game high score table.
// See https://core.telegram.org/bots/api#gamehighscore // See https://core.telegram.org/bots/api#gamehighscore
type GameHighScore struct { type GameHighScore struct {
+57 -9
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// AnswerInlineQueryP holds parameters for the answerInlineQuery method. import "context"
// AnswerInlineQuery holds parameters for the answerInlineQuery method.
// See https://core.telegram.org/bots/api#answerinlinequery // See https://core.telegram.org/bots/api#answerinlinequery
type AnswerInlineQueryP struct { type AnswerInlineQuery struct {
InlineQueryID string `json:"inline_query_id"` InlineQueryID string `json:"inline_query_id"`
Results []InlineQueryResult `json:"results"` Results []InlineQueryResult `json:"results"`
CacheTime int `json:"cache_time,omitempty"` CacheTime int `json:"cache_time,omitempty"`
@@ -14,28 +16,44 @@ type AnswerInlineQueryP struct {
// AnswerInlineQuery sends answers to an inline query. // AnswerInlineQuery sends answers to an inline query.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#answerinlinequery // 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) req := NewRequest[bool]("answerInlineQuery", params)
return req.Do(api) return req.Do(api)
} }
// AnswerWebAppQueryP holds parameters for the answerWebAppQuery method. // 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 AnswerInlineQuery) (bool, error) {
req := NewRequest[bool]("answerInlineQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerWebAppQuery holds parameters for the answerWebAppQuery method.
// See https://core.telegram.org/bots/api#answerwebappquery // See https://core.telegram.org/bots/api#answerwebappquery
type AnswerWebAppQueryP struct { type AnswerWebAppQuery struct {
WebAppQueryID string `json:"web_app_query_id"` WebAppQueryID string `json:"web_app_query_id"`
Result InlineQueryResult `json:"result"` Result InlineQueryResult `json:"result"`
} }
// AnswerWebAppQuery sets the result of a Web App interaction. // AnswerWebAppQuery sets the result of a Web App interaction.
// See https://core.telegram.org/bots/api#answerwebappquery // 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) req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params)
return req.Do(api) return req.Do(api)
} }
// SavePreparedInlineMessageP holds parameters for the savePreparedInlineMessage method. // 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 AnswerWebAppQuery) (SentWebAppMessage, error) {
req := NewRequest[SentWebAppMessage]("answerWebAppQuery", params)
return req.DoWithContext(ctx, api)
}
// SavePreparedInlineMessage holds parameters for the savePreparedInlineMessage method.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage // See https://core.telegram.org/bots/api#savepreparedinlinemessage
type SavePreparedInlineMessageP struct { type SavePreparedInlineMessage struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Result InlineQueryResult `json:"result"` Result InlineQueryResult `json:"result"`
AllowUserChats bool `json:"allow_user_chats,omitempty"` AllowUserChats bool `json:"allow_user_chats,omitempty"`
@@ -46,7 +64,37 @@ type SavePreparedInlineMessageP struct {
// SavePreparedInlineMessage stores a prepared message for Mini App users. // SavePreparedInlineMessage stores a prepared message for Mini App users.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage // 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) req := NewRequest[PreparedInlineMessage]("savePreparedInlineMessage", params)
return req.Do(api) return req.Do(api)
} }
// SavePreparedInlineMessageWithContext is the context-aware variant of SavePreparedInlineMessage.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#savepreparedinlinemessage
func (api *API) SavePreparedInlineMessageWithContext(ctx context.Context, params 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"` ID string `json:"id"`
ExpirationDate int `json:"expiration_date"` 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"`
}
+389 -114
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// SendMessageP holds parameters for the sendMessage method. import "context"
// SendMessage holds parameters for the sendMessage method.
// See https://core.telegram.org/bots/api#sendmessage // See https://core.telegram.org/bots/api#sendmessage
type SendMessageP struct { type SendMessage struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -24,14 +26,22 @@ type SendMessageP struct {
// SendMessage sends a text message. // SendMessage sends a text message.
// See https://core.telegram.org/bots/api#sendmessage // See https://core.telegram.org/bots/api#sendmessage
func (api *API) SendMessage(params SendMessageP) (Message, error) { func (api *API) SendMessage(params SendMessage) (Message, error) {
req := NewRequestWithChatID[Message, SendMessageP]("sendMessage", params, params.ChatID) req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// ForwardMessageP holds parameters for the forwardMessage method. // 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 SendMessage) (Message, error) {
req := NewRequestWithChatID[Message, SendMessage]("sendMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessage holds parameters for the forwardMessage method.
// See https://core.telegram.org/bots/api#forwardmessage // See https://core.telegram.org/bots/api#forwardmessage
type ForwardMessageP struct { type ForwardMessage struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -48,14 +58,22 @@ type ForwardMessageP struct {
// ForwardMessage forwards a message. // ForwardMessage forwards a message.
// See https://core.telegram.org/bots/api#forwardmessage // 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) req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// ForwardMessagesP holds parameters for the forwardMessages method. // 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 ForwardMessage) (Message, error) {
req := NewRequestWithChatID[Message]("forwardMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ForwardMessages holds parameters for the forwardMessages method.
// See https://core.telegram.org/bots/api#forwardmessages // See https://core.telegram.org/bots/api#forwardmessages
type ForwardMessagesP struct { type ForwardMessages struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -69,14 +87,22 @@ type ForwardMessagesP struct {
// ForwardMessages forwards multiple messages. // ForwardMessages forwards multiple messages.
// Returns an array of message IDs of the sent messages. // Returns an array of message IDs of the sent messages.
// See https://core.telegram.org/bots/api#forwardmessages // 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) req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// CopyMessageP holds parameters for the copyMessage method. // 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 ForwardMessages) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("forwardMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CopyMessage holds parameters for the copyMessage method.
// See https://core.telegram.org/bots/api#copymessage // See https://core.telegram.org/bots/api#copymessage
type CopyMessageP struct { type CopyMessage struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -102,7 +128,7 @@ type CopyMessageP struct {
// CopyMessage copies a message. // CopyMessage copies a message.
// Returns the MessageID of the sent copy. // Returns the MessageID of the sent copy.
// See https://core.telegram.org/bots/api#copymessage // 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) msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).Do(api)
if err != nil { if err != nil {
return 0, err return 0, err
@@ -110,9 +136,20 @@ func (api *API) CopyMessage(params CopyMessageP) (int, error) {
return msgID.MessageID, nil return msgID.MessageID, nil
} }
// CopyMessagesP holds parameters for the copyMessages method. // 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 CopyMessage) (int, error) {
msgID, err := NewRequestWithChatID[MessageID]("copyMessage", params, params.ChatID).DoWithContext(ctx, api)
if err != nil {
return 0, err
}
return msgID.MessageID, nil
}
// CopyMessages holds parameters for the copyMessages method.
// See https://core.telegram.org/bots/api#copymessages // See https://core.telegram.org/bots/api#copymessages
type CopyMessagesP struct { type CopyMessages struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
@@ -127,14 +164,22 @@ type CopyMessagesP struct {
// CopyMessages copies multiple messages. // CopyMessages copies multiple messages.
// Returns an array of message IDs of the sent copies. // Returns an array of message IDs of the sent copies.
// See https://core.telegram.org/bots/api#copymessages // 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) req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendLocationP holds parameters for the sendLocation method. // 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 CopyMessages) ([]MessageID, error) {
req := NewRequestWithChatID[[]MessageID]("copyMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendLocation holds parameters for the sendLocation method.
// See https://core.telegram.org/bots/api#sendlocation // See https://core.telegram.org/bots/api#sendlocation
type SendLocationP struct { type SendLocation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -159,14 +204,22 @@ type SendLocationP struct {
// SendLocation sends a point on the map. // SendLocation sends a point on the map.
// See https://core.telegram.org/bots/api#sendlocation // 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) req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendVenueP holds parameters for the sendVenue method. // 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 SendLocation) (Message, error) {
req := NewRequestWithChatID[Message]("sendLocation", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendVenue holds parameters for the sendVenue method.
// See https://core.telegram.org/bots/api#sendvenue // See https://core.telegram.org/bots/api#sendvenue
type SendVenueP struct { type SendVenue struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -193,14 +246,22 @@ type SendVenueP struct {
// SendVenue sends information about a venue. // SendVenue sends information about a venue.
// See https://core.telegram.org/bots/api#sendvenue // 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) req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendContactP holds parameters for the sendContact method. // 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 SendVenue) (Message, error) {
req := NewRequestWithChatID[Message]("sendVenue", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendContact holds parameters for the sendContact method.
// See https://core.telegram.org/bots/api#sendcontact // See https://core.telegram.org/bots/api#sendcontact
type SendContactP struct { type SendContact struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -223,32 +284,48 @@ type SendContactP struct {
// SendContact sends a phone contact. // SendContact sends a phone contact.
// See https://core.telegram.org/bots/api#sendcontact // 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) req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendPollP holds parameters for the sendPoll method. // 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 SendContact) (Message, error) {
req := NewRequestWithChatID[Message]("sendContact", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendPoll holds parameters for the sendPoll method.
// See https://core.telegram.org/bots/api#sendpoll // See https://core.telegram.org/bots/api#sendpoll
type SendPollP struct { type SendPoll struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
Question string `json:"question"` Question string `json:"question"`
QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"` QuestionParseMode ParseMode `json:"question_parse_mode,omitempty"`
QuestionEntities []MessageEntity `json:"question_entities,omitempty"` QuestionEntities []MessageEntity `json:"question_entities,omitempty"`
Options []InputPollOption `json:"options"` Options []InputPollOption `json:"options"`
IsAnonymous bool `json:"is_anonymous,omitempty"` IsAnonymous bool `json:"is_anonymous,omitempty"`
Type PollType `json:"type"` Type PollType `json:"type"`
AllowsMultipleAnswers bool `json:"allows_multiple_answers,omitempty"` AllowsMultipleAnswers bool `json:"allows_multiple_answers,omitempty"`
CorrectOptionID int `json:"correct_option_id,omitempty"` AllowsRevoting bool `json:"allows_revoting,omitempty"`
Explanation string `json:"explanation,omitempty"` ShuffleOptions bool `json:"shuffle_options,omitempty"`
ExplanationParseMode ParseMode `json:"explanation_parse_mode,omitempty"` AllowAddingOptions bool `json:"allow_adding_options,omitempty"`
ExplanationEntities []MessageEntity `json:"explanation_entities,omitempty"` HideResultsUntilCloses bool `json:"hide_results_until_closes,omitempty"`
OpenPeriod int `json:"open_period,omitempty"` CorrectOptionIDs []int `json:"correct_option_ids,omitempty"`
CloseDate int `json:"close_date"` Explanation string `json:"explanation,omitempty"`
IsClosed bool `json:"is_closed,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"` DisableNotification bool `json:"disable_notification,omitempty"`
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
@@ -261,14 +338,22 @@ type SendPollP struct {
// SendPoll sends a native poll. // SendPoll sends a native poll.
// See https://core.telegram.org/bots/api#sendpoll // 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) req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendChecklistP holds parameters for the sendChecklist method. // 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 SendPoll) (Message, error) {
req := NewRequestWithChatID[Message]("sendPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChecklist holds parameters for the sendChecklist method.
// See https://core.telegram.org/bots/api#sendchecklist // See https://core.telegram.org/bots/api#sendchecklist
type SendChecklistP struct { type SendChecklist struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
Checklist InputChecklist `json:"checklist"` Checklist InputChecklist `json:"checklist"`
@@ -283,14 +368,22 @@ type SendChecklistP struct {
// SendChecklist sends a checklist. // SendChecklist sends a checklist.
// See https://core.telegram.org/bots/api#sendchecklist // 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) req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendDiceP holds parameters for the sendDice method. // 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 SendChecklist) (Message, error) {
req := NewRequestWithChatID[Message]("sendChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendDice holds parameters for the sendDice method.
// See https://core.telegram.org/bots/api#senddice // See https://core.telegram.org/bots/api#senddice
type SendDiceP struct { type SendDice struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -310,14 +403,22 @@ type SendDiceP struct {
// SendDice sends a dice, which will have a random value. // SendDice sends a dice, which will have a random value.
// See https://core.telegram.org/bots/api#senddice // 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) req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendMessageDraftP holds parameters for the sendMessageDraft method. // 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 SendDice) (Message, error) {
req := NewRequestWithChatID[Message]("sendDice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendMessageDraft holds parameters for the sendMessageDraft method.
// See https://core.telegram.org/bots/api#sendmessagedraft // See https://core.telegram.org/bots/api#sendmessagedraft
type SendMessageDraftP struct { type SendMessageDraft struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DraftID uint64 `json:"draft_id"` DraftID uint64 `json:"draft_id"`
@@ -329,14 +430,22 @@ type SendMessageDraftP struct {
// SendMessageDraft sends or updates a draft message in the target chat. // SendMessageDraft sends or updates a draft message in the target chat.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#sendmessagedraft // 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) req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SendChatActionP holds parameters for the sendChatAction method. // 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 SendMessageDraft) (bool, error) {
req := NewRequestWithChatID[bool]("sendMessageDraft", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SendChatAction holds parameters for the sendChatAction method.
// See https://core.telegram.org/bots/api#sendchataction // See https://core.telegram.org/bots/api#sendchataction
type SendChatActionP struct { type SendChatAction struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -346,14 +455,22 @@ type SendChatActionP struct {
// SendChatAction sends a chat action (typing, uploading photo, etc.). // SendChatAction sends a chat action (typing, uploading photo, etc.).
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#sendchataction // 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) req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// SetMessageReactionP holds parameters for the setMessageReaction method. // 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 SendChatAction) (bool, error) {
req := NewRequestWithChatID[bool]("sendChatAction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// SetMessageReaction holds parameters for the setMessageReaction method.
// See https://core.telegram.org/bots/api#setmessagereaction // See https://core.telegram.org/bots/api#setmessagereaction
type SetMessageReactionP struct { type SetMessageReaction struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageId int `json:"message_id"` MessageId int `json:"message_id"`
Reaction []ReactionType `json:"reaction"` Reaction []ReactionType `json:"reaction"`
@@ -363,28 +480,38 @@ type SetMessageReactionP struct {
// SetMessageReaction changes the chosen reaction on a message. // SetMessageReaction changes the chosen reaction on a message.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setmessagereaction // 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) req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// EditMessageTextP holds parameters for the editMessageText method. // 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 SetMessageReaction) (bool, error) {
req := NewRequestWithChatID[bool]("setMessageReaction", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageText holds parameters for the editMessageText method.
// See https://core.telegram.org/bots/api#editmessagetext // See https://core.telegram.org/bots/api#editmessagetext
type EditMessageTextP struct { type EditMessageText struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"`
Text string `json:"text"` Text string `json:"text"`
ParseMode ParseMode `json:"parse_mode,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` Entities []MessageEntity `json:"entities,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
} }
// EditMessageText edits text messages. // EditMessageText edits text messages.
// If inline_message_id is provided, returns a boolean success flag; // If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message. // otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagetext // 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 var zero Message
if params.InlineMessageID != "" { if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID) req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID)
@@ -396,23 +523,40 @@ func (api *API) EditMessageText(params EditMessageTextP) (Message, bool, error)
return res, false, err return res, false, err
} }
// EditMessageCaptionP holds parameters for the editMessageCaption method. // 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 EditMessageText) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageText", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageText", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageCaption holds parameters for the editMessageCaption method.
// See https://core.telegram.org/bots/api#editmessagecaption // See https://core.telegram.org/bots/api#editmessagecaption
type EditMessageCaptionP struct { type EditMessageCaption struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
InlineMessageID string `json:"inline_message_id,omitempty"` InlineMessageID string `json:"inline_message_id,omitempty"`
Caption string `json:"caption"` Caption string `json:"caption"`
ParseMode ParseMode `json:"parse_mode,omitempty"` ParseMode ParseMode `json:"parse_mode,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"` CaptionEntities []MessageEntity `json:"caption_entities,omitempty"`
ShowCaptionAboveMedia bool `json:"show_caption_above_media,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
} }
// EditMessageCaption edits captions of messages. // EditMessageCaption edits captions of messages.
// If inline_message_id is provided, returns a boolean success flag; // If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message. // otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagecaption // 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 var zero Message
if params.InlineMessageID != "" { if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID) req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID)
@@ -424,9 +568,24 @@ func (api *API) EditMessageCaption(params EditMessageCaptionP) (Message, bool, e
return res, false, err return res, false, err
} }
// EditMessageMediaP holds parameters for the editMessageMedia method. // 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 EditMessageCaption) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageCaption", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageCaption", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageMedia holds parameters for the editMessageMedia method.
// See https://core.telegram.org/bots/api#editmessagemedia // See https://core.telegram.org/bots/api#editmessagemedia
type EditMessageMediaP struct { type EditMessageMedia struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
@@ -439,7 +598,7 @@ type EditMessageMediaP struct {
// If inline_message_id is provided, returns a boolean success flag; // If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message. // otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagemedia // 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 var zero Message
if params.InlineMessageID != "" { if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID) req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID)
@@ -451,9 +610,24 @@ func (api *API) EditMessageMedia(params EditMessageMediaP) (Message, bool, error
return res, false, err return res, false, err
} }
// EditMessageLiveLocationP holds parameters for the editMessageLiveLocation method. // 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 EditMessageMedia) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageMedia", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageMedia", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageLiveLocation holds parameters for the editMessageLiveLocation method.
// See https://core.telegram.org/bots/api#editmessagelivelocation // See https://core.telegram.org/bots/api#editmessagelivelocation
type EditMessageLiveLocationP struct { type EditMessageLiveLocation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
@@ -472,7 +646,7 @@ type EditMessageLiveLocationP struct {
// If inline_message_id is provided, returns a boolean success flag; // If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message. // otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagelivelocation // 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 var zero Message
if params.InlineMessageID != "" { if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID) req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID)
@@ -484,9 +658,24 @@ func (api *API) EditMessageLiveLocation(params EditMessageLiveLocationP) (Messag
return res, false, err return res, false, err
} }
// StopMessageLiveLocationP holds parameters for the stopMessageLiveLocation method. // 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 EditMessageLiveLocation) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// StopMessageLiveLocation holds parameters for the stopMessageLiveLocation method.
// See https://core.telegram.org/bots/api#stopmessagelivelocation // See https://core.telegram.org/bots/api#stopmessagelivelocation
type StopMessageLiveLocationP struct { type StopMessageLiveLocation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
@@ -498,7 +687,7 @@ type StopMessageLiveLocationP struct {
// If inline_message_id is provided, returns a boolean success flag; // If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message. // otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#stopmessagelivelocation // 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 var zero Message
if params.InlineMessageID != "" { if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID) req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID)
@@ -510,8 +699,23 @@ func (api *API) StopMessageLiveLocation(params StopMessageLiveLocationP) (Messag
return res, false, err return res, false, err
} }
// EditMessageChecklistP holds parameters for the editMessageChecklist method. // StopMessageLiveLocationWithContext is the context-aware variant of StopMessageLiveLocation.
type EditMessageChecklistP struct { // 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 StopMessageLiveLocation) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("stopMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("stopMessageLiveLocation", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// EditMessageChecklist holds parameters for the editMessageChecklist method.
type EditMessageChecklist struct {
BusinessConnectionID string `json:"business_connection_id"` BusinessConnectionID string `json:"business_connection_id"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
@@ -521,14 +725,22 @@ type EditMessageChecklistP struct {
// EditMessageChecklist edits a checklist message. // EditMessageChecklist edits a checklist message.
// See https://core.telegram.org/bots/api#editmessagechecklist // 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) req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// EditMessageReplyMarkupP holds parameters for the editMessageReplyMarkup method. // 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 EditMessageChecklist) (Message, error) {
req := NewRequestWithChatID[Message]("editMessageChecklist", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// EditMessageReplyMarkup holds parameters for the editMessageReplyMarkup method.
// See https://core.telegram.org/bots/api#editmessagereplymarkup // See https://core.telegram.org/bots/api#editmessagereplymarkup
type EditMessageReplyMarkupP struct { type EditMessageReplyMarkup struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
MessageID int `json:"message_id,omitempty"` MessageID int `json:"message_id,omitempty"`
@@ -540,7 +752,7 @@ type EditMessageReplyMarkupP struct {
// If inline_message_id is provided, returns a boolean success flag; // If inline_message_id is provided, returns a boolean success flag;
// otherwise returns the edited Message. // otherwise returns the edited Message.
// See https://core.telegram.org/bots/api#editmessagereplymarkup // 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 var zero Message
if params.InlineMessageID != "" { if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID) req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID)
@@ -552,26 +764,49 @@ func (api *API) EditMessageReplyMarkup(params EditMessageReplyMarkupP) (Message,
return res, false, err return res, false, err
} }
// StopPollP holds parameters for the stopPoll method. // 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 EditMessageReplyMarkup) (Message, bool, error) {
var zero Message
if params.InlineMessageID != "" {
req := NewRequestWithChatID[bool]("editMessageReplyMarkup", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return zero, res, err
}
req := NewRequestWithChatID[Message]("editMessageReplyMarkup", params, params.ChatID)
res, err := req.DoWithContext(ctx, api)
return res, false, err
}
// StopPoll holds parameters for the stopPoll method.
// See https://core.telegram.org/bots/api#stoppoll // See https://core.telegram.org/bots/api#stoppoll
type StopPollP struct { type StopPoll struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
InlineMessageID string `json:"inline_message_id,omitempty"` ReplyMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"`
} }
// StopPoll stops a poll that was sent by the bot. // StopPoll stops a poll that was sent by the bot.
// Returns the stopped Poll. // Returns the stopped Poll.
// See https://core.telegram.org/bots/api#stoppoll // 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) req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// ApproveSuggestedPostP holds parameters for the approveSuggestedPost method. // 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 StopPoll) (Poll, error) {
req := NewRequestWithChatID[Poll]("stopPoll", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// ApproveSuggestedPost holds parameters for the approveSuggestedPost method.
// See https://core.telegram.org/bots/api#approvesuggestedpost // See https://core.telegram.org/bots/api#approvesuggestedpost
type ApproveSuggestedPostP struct { type ApproveSuggestedPost struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
SendDate int `json:"send_date,omitempty"` SendDate int `json:"send_date,omitempty"`
@@ -580,14 +815,22 @@ type ApproveSuggestedPostP struct {
// ApproveSuggestedPost approves a suggested channel post. // ApproveSuggestedPost approves a suggested channel post.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#approvesuggestedpost // 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) req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// DeclineSuggestedPostP holds parameters for the declineSuggestedPost method. // 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 ApproveSuggestedPost) (bool, error) {
req := NewRequestWithChatID[bool]("approveSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeclineSuggestedPost holds parameters for the declineSuggestedPost method.
// See https://core.telegram.org/bots/api#declinesuggestedpost // See https://core.telegram.org/bots/api#declinesuggestedpost
type DeclineSuggestedPostP struct { type DeclineSuggestedPost struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`
@@ -596,14 +839,22 @@ type DeclineSuggestedPostP struct {
// DeclineSuggestedPost declines a suggested channel post. // DeclineSuggestedPost declines a suggested channel post.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#declinesuggestedpost // 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) req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// DeleteMessageP holds parameters for the deleteMessage method. // 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 DeclineSuggestedPost) (bool, error) {
req := NewRequestWithChatID[bool]("declineSuggestedPost", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessage holds parameters for the deleteMessage method.
// See https://core.telegram.org/bots/api#deletemessage // See https://core.telegram.org/bots/api#deletemessage
type DeleteMessageP struct { type DeleteMessage struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
} }
@@ -611,14 +862,22 @@ type DeleteMessageP struct {
// DeleteMessage deletes a message. // DeleteMessage deletes a message.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deletemessage // 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) req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// DeleteMessagesP holds parameters for the deleteMessages method. // 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 DeleteMessage) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessage", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// DeleteMessages holds parameters for the deleteMessages method.
// See https://core.telegram.org/bots/api#deletemessages // See https://core.telegram.org/bots/api#deletemessages
type DeleteMessagesP struct { type DeleteMessages struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageIDs []int `json:"message_ids"` MessageIDs []int `json:"message_ids"`
} }
@@ -626,14 +885,22 @@ type DeleteMessagesP struct {
// DeleteMessages deletes multiple messages at once. // DeleteMessages deletes multiple messages at once.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deletemessages // 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) req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// AnswerCallbackQueryP holds parameters for the answerCallbackQuery method. // 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 DeleteMessages) (bool, error) {
req := NewRequestWithChatID[bool]("deleteMessages", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// AnswerCallbackQuery holds parameters for the answerCallbackQuery method.
// See https://core.telegram.org/bots/api#answercallbackquery // See https://core.telegram.org/bots/api#answercallbackquery
type AnswerCallbackQueryP struct { type AnswerCallbackQuery struct {
CallbackQueryID string `json:"callback_query_id"` CallbackQueryID string `json:"callback_query_id"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
ShowAlert bool `json:"show_alert,omitempty"` ShowAlert bool `json:"show_alert,omitempty"`
@@ -644,7 +911,15 @@ type AnswerCallbackQueryP struct {
// AnswerCallbackQuery sends answers to callback queries sent from inline keyboards. // AnswerCallbackQuery sends answers to callback queries sent from inline keyboards.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#answercallbackquery // 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) req := NewRequest[bool]("answerCallbackQuery", params)
return req.Do(api) return req.Do(api)
} }
// AnswerCallbackQueryWithContext is the context-aware variant of AnswerCallbackQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answercallbackquery
func (api *API) AnswerCallbackQueryWithContext(ctx context.Context, params AnswerCallbackQuery) (bool, error) {
req := NewRequest[bool]("answerCallbackQuery", params)
return req.DoWithContext(ctx, api)
}
+419 -113
View File
@@ -1,64 +1,222 @@
package tgapi 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. // MessageID represents a message identifier wrapper returned by some API methods.
type MessageID struct { type MessageID struct {
MessageID int `json:"message_id"` 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. // DirectMessageTopic represents a forum topic in a direct message.
type DirectMessageTopic struct { type DirectMessageTopic struct {
TopicID int64 `json:"topic_id"` TopicID int64 `json:"topic_id"`
User *User `json:"user,omitempty"` 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. // Message represents a Telegram message.
// See https://core.telegram.org/bots/api#message // See https://core.telegram.org/bots/api#message
type Message struct { type Message struct {
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"` DirectMessageTopic *DirectMessageTopic `json:"direct_message_topic,omitempty"`
BusinessConnectionId string `json:"business_connection_id,omitempty"` From *User `json:"from,omitempty"`
From *User `json:"from,omitempty"`
SenderChat *Chat `json:"sender_chat,omitempty"` SenderChat *Chat `json:"sender_chat,omitempty"`
SenderBoostCount int `json:"sender_boost_count,omitempty"` SenderBoostCount int `json:"sender_boost_count,omitempty"`
SenderBusinessBot *User `json:"sender_business_bot,omitempty"` SenderBusinessBot *User `json:"sender_business_bot,omitempty"`
SenderTag string `json:"sender_tag,omitempty"` SenderTag string `json:"sender_tag,omitempty"`
Chat *Chat `json:"chat,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"` IsTopicMessage bool `json:"is_topic_message,omitempty"`
IsAutomaticForward bool `json:"is_automatic_forward,omitempty"` IsAutomaticForward bool `json:"is_automatic_forward,omitempty"`
IsFromOffline bool `json:"is_from_offline,omitempty"` ReplyToMessage *Message `json:"reply_to_message,omitempty"`
IsPaidPost bool `json:"is_paid_post,omitempty"` ExternalReply *ExternalReplyInfo `json:"external_reply,omitempty"`
MediaGroupId string `json:"media_group_id,omitempty"` Quote *TextQuote `json:"quote,omitempty"`
AuthorSignature string `json:"author_signature,omitempty"`
PaidStarCount int `json:"paid_star_count,omitempty"`
ReplyToMessage *Message `json:"reply_to_message,omitempty"`
Text string `json:"text"` ReplyToStory *Story `json:"reply_to_story,omitempty"`
ReplyToChecklistTaskID int `json:"reply_to_checklist_task_id,omitempty"`
Photo extypes.Slice[*PhotoSize] `json:"photo,omitempty"` ReplyToPollOptionID string `json:"reply_to_poll_option_id,omitempty"`
Caption string `json:"caption,omitempty"` ViaBot *User `json:"via_bot,omitempty"`
CaptionEntities []MessageEntity `json:"caption_entities,omitempty"` EditDate int `json:"edit_date,omitempty"`
HasProtectedContent bool `json:"has_protected_content,omitempty"`
Date int `json:"date"` IsFromOffline bool `json:"is_from_offline,omitempty"`
EditDate int `json:"edit_date"` IsPaidPost bool `json:"is_paid_post,omitempty"`
MediaGroupId string `json:"media_group_id,omitempty"`
ReplyMarkup *MessageReplyMarkup `json:"reply_markup,omitempty"` AuthorSignature string `json:"author_signature,omitempty"`
PaidStarCount int `json:"paid_star_count,omitempty"`
Text string `json:"text"`
Entities []MessageEntity `json:"entities,omitempty"` Entities []MessageEntity `json:"entities,omitempty"`
LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"`
SuggestedPostInfo *SuggestedPostInfo `json:"suggested_post_info,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. // 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. // MaybeInaccessibleMessage is a union type that can be either Message or InaccessibleMessage.
// See https://core.telegram.org/bots/api#maybeinaccessiblemessage // 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. // MessageEntityType represents the type of a message entity.
type MessageEntityType string type MessageEntityType string
const ( const (
MessageEntityMention MessageEntityType = "mention" // MessageEntityMention identifies an @mention entity.
MessageEntityHashtag MessageEntityType = "hashtag" MessageEntityMention MessageEntityType = "mention"
MessageEntityCashtag MessageEntityType = "cashtag" // MessageEntityHashtag identifies a hashtag entity.
MessageEntityBotCommand MessageEntityType = "bot_command" MessageEntityHashtag MessageEntityType = "hashtag"
MessageEntityUrl MessageEntityType = "url" // MessageEntityCashtag identifies a cashtag entity.
MessageEntityEmail MessageEntityType = "email" MessageEntityCashtag MessageEntityType = "cashtag"
MessageEntityPhoneNumber MessageEntityType = "phone_number" // MessageEntityBotCommand identifies a bot command entity.
MessageEntityBold MessageEntityType = "bold" MessageEntityBotCommand MessageEntityType = "bot_command"
MessageEntityItalic MessageEntityType = "italic" // MessageEntityUrl identifies a URL entity.
MessageEntityUnderline MessageEntityType = "underline" MessageEntityUrl MessageEntityType = "url"
MessageEntityStrike MessageEntityType = "strikethrough" // MessageEntityEmail identifies an email entity.
MessageEntitySpoiler MessageEntityType = "spoiler" MessageEntityEmail MessageEntityType = "email"
MessageEntityBlockquote MessageEntityType = "blockquote" // MessageEntityPhoneNumber identifies a phone number entity.
MessageEntityPhoneNumber MessageEntityType = "phone_number"
// MessageEntityBold identifies bold text.
MessageEntityBold MessageEntityType = "bold"
// MessageEntityItalic identifies italic text.
MessageEntityItalic MessageEntityType = "italic"
// MessageEntityUnderline identifies underlined text.
MessageEntityUnderline MessageEntityType = "underline"
// MessageEntityStrike identifies strikethrough text.
MessageEntityStrike MessageEntityType = "strikethrough"
// MessageEntitySpoiler identifies spoiler text.
MessageEntitySpoiler MessageEntityType = "spoiler"
// MessageEntityBlockquote identifies a blockquote entity.
MessageEntityBlockquote MessageEntityType = "blockquote"
// MessageEntityExpandableBlockquote identifies an expandable blockquote entity.
MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote" MessageEntityExpandableBlockquote MessageEntityType = "expandable_blockquote"
MessageEntityCode MessageEntityType = "code" // MessageEntityCode identifies inline code.
MessageEntityPre MessageEntityType = "pre" MessageEntityCode MessageEntityType = "code"
MessageEntityTextLink MessageEntityType = "text_link" // MessageEntityPre identifies a preformatted block.
MessageEntityTextMention MessageEntityType = "text_mention" MessageEntityPre MessageEntityType = "pre"
MessageEntityCustomEmoji MessageEntityType = "custom_emoji" // MessageEntityTextLink identifies linked text.
MessageEntityDateTime MessageEntityType = "date_time" MessageEntityTextLink MessageEntityType = "text_link"
// MessageEntityTextMention identifies a text mention.
MessageEntityTextMention MessageEntityType = "text_mention"
// MessageEntityCustomEmoji identifies a custom emoji entity.
MessageEntityCustomEmoji MessageEntityType = "custom_emoji"
// MessageEntityDateTime identifies a date-time entity.
MessageEntityDateTime MessageEntityType = "date_time"
) )
// MessageEntity represents one special entity in a text message. // MessageEntity represents one special entity in a text message.
@@ -121,12 +372,13 @@ type ReplyParameters struct {
MessageID int `json:"message_id"` MessageID int `json:"message_id"`
ChatID int64 `json:"chat_id,omitempty"` ChatID int64 `json:"chat_id,omitempty"`
AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"` AllowSendingWithoutReply bool `json:"allow_sending_without_reply,omitempty"`
Quote string `json:"quote,omitempty"` Quote string `json:"quote,omitempty"`
QuoteParsingMode string `json:"quote_parsing_mode,omitempty"` QuoteParsingMode string `json:"quote_parsing_mode,omitempty"`
QuoteEntities []*MessageEntity `json:"quote_entities,omitempty"` QuoteEntities []MessageEntity `json:"quote_entities,omitempty"`
QuotePosition int `json:"quote_position,omitempty"` QuotePosition int `json:"quote_position,omitempty"`
ChecklistTaskID int `json:"checklist_task_id,omitempty"` ChecklistTaskID int `json:"checklist_task_id,omitempty"`
PollOptionID string `json:"poll_option_id,omitempty"`
} }
// LinkPreviewOptions describes the options used for link preview generation. // LinkPreviewOptions describes the options used for link preview generation.
@@ -166,23 +418,27 @@ type InlineKeyboardMarkup struct {
type KeyboardButtonStyle string type KeyboardButtonStyle string
const ( const (
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger" // KeyboardButtonStyleDanger marks a destructive keyboard button.
KeyboardButtonStyleDanger KeyboardButtonStyle = "danger"
// KeyboardButtonStyleSuccess marks a confirmatory keyboard button.
KeyboardButtonStyleSuccess KeyboardButtonStyle = "success" KeyboardButtonStyleSuccess KeyboardButtonStyle = "success"
// KeyboardButtonStylePrimary marks a primary keyboard button.
KeyboardButtonStylePrimary KeyboardButtonStyle = "primary" KeyboardButtonStylePrimary KeyboardButtonStyle = "primary"
) )
// KeyboardButton represents one button of the reply keyboard. // KeyboardButton represents one button of the reply keyboard.
// See https://core.telegram.org/bots/api#keyboardbutton // See https://core.telegram.org/bots/api#keyboardbutton
type KeyboardButton struct { type KeyboardButton struct {
Text string `json:"text"` Text string `json:"text"`
IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"` IconCustomEmojiID string `json:"icon_custom_emoji_id,omitempty"`
Style KeyboardButtonStyle `json:"style,omitempty"` Style KeyboardButtonStyle `json:"style,omitempty"`
RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"` RequestUsers *KeyboardButtonRequestUsers `json:"request_users,omitempty"`
RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"` RequestChat *KeyboardButtonRequestChat `json:"request_chat,omitempty"`
RequestContact bool `json:"request_contact,omitempty"` RequestManagedBot *KeyboardButtonRequestManagedBot `json:"request_managed_bot,omitempty"`
RequestLocation bool `json:"request_location,omitempty"` RequestContact bool `json:"request_contact,omitempty"`
RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"` RequestLocation bool `json:"request_location,omitempty"`
WebApp *WebAppInfo `json:"web_app,omitempty"` RequestPoll *KeyboardButtonPollType `json:"request_poll,omitempty"`
WebApp *WebAppInfo `json:"web_app,omitempty"`
} }
// KeyboardButtonRequestUsers defines criteria used to request suitable users. // KeyboardButtonRequestUsers defines criteria used to request suitable users.
@@ -213,6 +469,14 @@ type KeyboardButtonRequestChat struct {
RequestPhoto bool `json:"request_photo,omitempty"` 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. // KeyboardButtonPollType represents the type of a poll that may be created from a keyboard button.
// See https://core.telegram.org/bots/api#keyboardbuttonpolltype // See https://core.telegram.org/bots/api#keyboardbuttonpolltype
type KeyboardButtonPollType struct { type KeyboardButtonPollType struct {
@@ -252,52 +516,27 @@ type CallbackQuery struct {
GameShortName string `json:"game_short_name,omitempty"` 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. // ChatActionType represents the type of chat action.
type ChatActionType string type ChatActionType string
const ( const (
ChatActionTyping ChatActionType = "typing" // ChatActionTyping tells Telegram the bot is typing.
ChatActionUploadPhoto ChatActionType = "upload_photo" ChatActionTyping ChatActionType = "typing"
ChatActionUploadVideo ChatActionType = "upload_video" // ChatActionUploadPhoto tells Telegram the bot is uploading a photo.
ChatActionUploadVoice ChatActionType = "upload_voice" ChatActionUploadPhoto ChatActionType = "upload_photo"
ChatActionUploadDocument ChatActionType = "upload_document" // ChatActionUploadVideo tells Telegram the bot is uploading a video.
ChatActionChooseSticker ChatActionType = "choose_sticker" ChatActionUploadVideo ChatActionType = "upload_video"
ChatActionFindLocation ChatActionType = "find_location" // ChatActionUploadVoice tells Telegram the bot is uploading a voice message.
ChatActionUploadVoice ChatActionType = "upload_voice"
// ChatActionUploadDocument tells Telegram the bot is uploading a document.
ChatActionUploadDocument ChatActionType = "upload_document"
// ChatActionChooseSticker tells Telegram the bot is choosing a sticker.
ChatActionChooseSticker ChatActionType = "choose_sticker"
// ChatActionFindLocation tells Telegram the bot is finding a location.
ChatActionFindLocation ChatActionType = "find_location"
// ChatActionUploadVideoNote tells Telegram the bot is uploading a video note.
ChatActionUploadVideoNote ChatActionType = "upload_video_note" ChatActionUploadVideoNote ChatActionType = "upload_video_note"
// ChatActionUploadVideoNone is a deprecated alias for ChatActionUploadVideoNote.
ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote ChatActionUploadVideoNone ChatActionType = ChatActionUploadVideoNote
) )
@@ -358,3 +597,70 @@ type SuggestedPostParameters struct {
Price SuggestedPostPrice `json:"price"` Price SuggestedPostPrice `json:"price"`
SendDate int `json:"send_date"` 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"`
}
+162 -21
View File
@@ -6,7 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// UpdateParams holds parameters for the getUpdates method. // UpdateParams holds parameters for the getUpdates method.
@@ -21,10 +21,60 @@ type UpdateParams struct {
// GetMe returns basic information about the bot. // GetMe returns basic information about the bot.
// See https://core.telegram.org/bots/api#getme // See https://core.telegram.org/bots/api#getme
func (api *API) GetMe() (User, error) { func (api *API) GetMe() (User, error) {
req := NewRequest[User, EmptyParams]("getMe", NoParams) req := NewRequest[User]("getMe", NoParams)
return req.Do(api) return req.Do(api)
} }
// GetMeWithContext is the context-aware variant of GetMe.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getme
func (api *API) GetMeWithContext(ctx context.Context) (User, error) {
req := NewRequest[User]("getMe", NoParams)
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. // LogOut logs the bot out from the cloud Bot API server.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#logout // See https://core.telegram.org/bots/api#logout
@@ -33,14 +83,30 @@ func (api *API) LogOut() (bool, error) {
return req.Do(api) return req.Do(api)
} }
// Close closes the bot instance on the local server. // LogOutWithContext is the context-aware variant of LogOut.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#logout
func (api *API) LogOutWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool, EmptyParams]("logOut", NoParams)
return req.DoWithContext(ctx, api)
}
// CloseRemote closes the bot instance on the local server.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#close // See https://core.telegram.org/bots/api#close
func (api *API) Close() (bool, error) { func (api *API) CloseRemote() (bool, error) {
req := NewRequest[bool, EmptyParams]("close", NoParams) req := NewRequest[bool, EmptyParams]("close", NoParams)
return req.Do(api) return req.Do(api)
} }
// CloseRemoteWithContext is the context-aware variant of CloseRemote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#close
func (api *API) CloseRemoteWithContext(ctx context.Context) (bool, error) {
req := NewRequest[bool, EmptyParams]("close", NoParams)
return req.DoWithContext(ctx, api)
}
// GetUpdates receives incoming updates using long polling. // GetUpdates receives incoming updates using long polling.
// See https://core.telegram.org/bots/api#getupdates // See https://core.telegram.org/bots/api#getupdates
func (api *API) GetUpdates(params UpdateParams) ([]Update, error) { func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
@@ -48,45 +114,66 @@ func (api *API) GetUpdates(params UpdateParams) ([]Update, error) {
return req.Do(api) return req.Do(api)
} }
// GetUpdatesWithContext is the context-aware variant of GetUpdates.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getupdates
func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) ([]Update, error) { func (api *API) GetUpdatesWithContext(ctx context.Context, params UpdateParams) ([]Update, error) {
req := NewRequest[[]Update]("getUpdates", params) req := NewRequest[[]Update]("getUpdates", params)
return req.DoWithContext(ctx, api) return req.DoWithContext(ctx, api)
} }
// SetWebhookP holds parameters for the setWebhook method. // SetWebhook holds parameters for the setWebhook method.
// To upload a self-signed certificate, use Uploader.SetWebhook.
// See https://core.telegram.org/bots/api#setwebhook // See https://core.telegram.org/bots/api#setwebhook
type SetWebhookP struct { type SetWebhook struct {
URL string `json:"url"` URL string `json:"url"`
Certificate string `json:"certificate,omitempty"`
IPAddress string `json:"ip_address,omitempty"` IPAddress string `json:"ip_address,omitempty"`
MaxConnections int `json:"max_connections,omitempty"` MaxConnections int8 `json:"max_connections,omitempty"`
AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"` AllowedUpdates []UpdateType `json:"allowed_updates,omitempty"`
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
SecretToken string `json:"secret_token,omitempty"` SecretToken string `json:"secret_token,omitempty"`
} }
// SetWebhook sets a webhook URL for incoming updates. // SetWebhook sets a webhook URL for incoming updates.
// For certificate upload, use Uploader.SetWebhook.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setwebhook // See https://core.telegram.org/bots/api#setwebhook
func (api *API) SetWebhook(params SetWebhookP) (bool, error) { func (api *API) SetWebhook(params SetWebhook) (bool, error) {
req := NewRequest[bool]("setWebhook", params) req := NewRequest[bool]("setWebhook", params)
return req.Do(api) return req.Do(api)
} }
// DeleteWebhookP holds parameters for the deleteWebhook method. // SetWebhookWithContext is the context-aware variant of SetWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// For certificate upload, use Uploader.SetWebhook.
// See https://core.telegram.org/bots/api#setwebhook
func (api *API) SetWebhookWithContext(ctx context.Context, params SetWebhook) (bool, error) {
req := NewRequest[bool]("setWebhook", params)
return req.DoWithContext(ctx, api)
}
// DeleteWebhook holds parameters for the deleteWebhook method.
// See https://core.telegram.org/bots/api#deletewebhook // See https://core.telegram.org/bots/api#deletewebhook
type DeleteWebhookP struct { type DeleteWebhook struct {
DropPendingUpdates bool `json:"drop_pending_updates,omitempty"` DropPendingUpdates bool `json:"drop_pending_updates,omitempty"`
} }
// DeleteWebhook removes the current webhook integration. // DeleteWebhook removes the current webhook integration.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#deletewebhook // 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) req := NewRequest[bool]("deleteWebhook", params)
return req.Do(api) return req.Do(api)
} }
// DeleteWebhookWithContext is the context-aware variant of DeleteWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletewebhook
func (api *API) DeleteWebhookWithContext(ctx context.Context, params DeleteWebhook) (bool, error) {
req := NewRequest[bool]("deleteWebhook", params)
return req.DoWithContext(ctx, api)
}
// GetWebhookInfo returns the current webhook status. // GetWebhookInfo returns the current webhook status.
// See https://core.telegram.org/bots/api#getwebhookinfo // See https://core.telegram.org/bots/api#getwebhookinfo
func (api *API) GetWebhookInfo() (WebhookInfo, error) { func (api *API) GetWebhookInfo() (WebhookInfo, error) {
@@ -94,30 +181,84 @@ func (api *API) GetWebhookInfo() (WebhookInfo, error) {
return req.Do(api) return req.Do(api)
} }
// GetFileP holds parameters for the getFile method. // GetWebhookInfoWithContext is the context-aware variant of GetWebhookInfo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getwebhookinfo
func (api *API) GetWebhookInfoWithContext(ctx context.Context) (WebhookInfo, error) {
req := NewRequest[WebhookInfo]("getWebhookInfo", NoParams)
return req.DoWithContext(ctx, api)
}
// GetFile holds parameters for the getFile method.
// See https://core.telegram.org/bots/api#getfile // See https://core.telegram.org/bots/api#getfile
type GetFileP struct { type GetFile struct {
FileId string `json:"file_id"` FileID string `json:"file_id"`
} }
// GetFile returns basic information about a file and prepares it for downloading. // GetFile returns basic information about a file and prepares it for downloading.
// See https://core.telegram.org/bots/api#getfile // 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) req := NewRequest[File]("getFile", params)
return req.Do(api) return req.Do(api)
} }
// GetFileWithContext is the context-aware variant of GetFile.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getfile
func (api *API) GetFileWithContext(ctx context.Context, params 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. // GetFileByLink downloads a file from Telegram's file server using the provided file link.
// The link is usually obtained from File.FilePath. // The link is usually obtained from File.FilePath.
// For large files, prefer OpenFileByLink or OpenFileByLinkWithContext to stream the response body.
// See https://core.telegram.org/bots/api#file // See https://core.telegram.org/bots/api#file
func (api *API) GetFileByLink(link string) ([]byte, error) { func (api *API) GetFileByLink(link string) ([]byte, error) {
return api.getFileByLink(context.Background(), link)
}
// GetFileByLinkWithContext is the context-aware variant of GetFileByLink.
// It executes the same request but uses ctx for cancellation and deadlines.
// For large files, prefer OpenFileByLinkWithContext to stream the response body.
// See https://core.telegram.org/bots/api#file
func (api *API) GetFileByLinkWithContext(ctx context.Context, link string) ([]byte, error) {
return api.getFileByLink(ctx, link)
}
// OpenFileByLink opens a streaming response body for a file hosted on Telegram's file server.
// The caller must close the returned ReadCloser.
// See https://core.telegram.org/bots/api#file
func (api *API) OpenFileByLink(link string) (io.ReadCloser, error) {
return api.openFileByLink(context.Background(), link)
}
// OpenFileByLinkWithContext is the context-aware variant of OpenFileByLink.
// The caller must close the returned ReadCloser.
// See https://core.telegram.org/bots/api#file
func (api *API) OpenFileByLinkWithContext(ctx context.Context, link string) (io.ReadCloser, error) {
return api.openFileByLink(ctx, link)
}
func (api *API) getFileByLink(ctx context.Context, link string) ([]byte, error) {
body, err := api.openFileByLink(ctx, link)
if err != nil {
return nil, err
}
defer func() {
_ = body.Close()
}()
return io.ReadAll(body)
}
func (api *API) openFileByLink(ctx context.Context, link string) (io.ReadCloser, error) {
methodPrefix := "" methodPrefix := ""
if api.useTestServer { if api.useTestServer {
methodPrefix = "/test" methodPrefix = "/test"
} }
u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link) u := fmt.Sprintf("%s/file/bot%s%s/%s", api.apiUrl, api.token, methodPrefix, link)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, u, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -127,15 +268,15 @@ func (api *API) GetFileByLink(link string) ([]byte, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer func() {
_ = res.Body.Close()
}()
if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices { if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusMultipleChoices {
defer func() {
_ = res.Body.Close()
}()
body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10)) body, readErr := io.ReadAll(io.LimitReader(res.Body, 4<<10))
if readErr != nil { if readErr != nil {
return nil, fmt.Errorf("unexpected status %d", res.StatusCode) return nil, fmt.Errorf("unexpected status %d", res.StatusCode)
} }
return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body)) return nil, fmt.Errorf("unexpected status %d: %s", res.StatusCode, string(body))
} }
return io.ReadAll(res.Body) return res.Body, nil
} }
+101 -6
View File
@@ -27,8 +27,8 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -44,6 +44,44 @@ func TestGetFileByLinkUsesConfiguredAPIURL(t *testing.T) {
} }
} }
func TestOpenFileByLinkStreamsResponseBody(t *testing.T) {
api := NewAPI(
NewAPIOpts("token").
SetAPIUrl("https://example.test").
SetHTTPClient(&http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("streamed payload")),
}, nil
}),
}),
)
defer func() {
if err := api.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
body, err := api.OpenFileByLink("files/report.txt")
if err != nil {
t.Fatalf("OpenFileByLink returned error: %v", err)
}
defer func() {
if err := body.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
}()
data, err := io.ReadAll(body)
if err != nil {
t.Fatalf("failed to read body: %v", err)
}
if string(data) != "streamed payload" {
t.Fatalf("unexpected payload: %q", string(data))
}
}
func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) { func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
client := &http.Client{ client := &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -60,8 +98,8 @@ func TestGetFileByLinkReturnsHTTPStatusError(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -97,8 +135,8 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -113,3 +151,60 @@ func TestGetUpdatesOmitsAllowedUpdatesWhenEmpty(t *testing.T) {
t.Fatalf("expected allowed_updates to be omitted, got %v", gotBody["allowed_updates"]) 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" ParseHTML ParseMode = "HTML"
// ParseMD enables legacy Markdown style parsing. // ParseMD enables legacy Markdown style parsing.
ParseMD ParseMode = "Markdown" ParseMD ParseMode = "Markdown"
// ParseNone disables any parsing. // ParseNone disables parse_mode and leaves plain-text requests unannotated.
ParseNone ParseMode = "None" ParseNone ParseMode = ""
) )
// EmptyParams is a placeholder for methods that take no parameters. // EmptyParams is a placeholder for methods that take no parameters.
@@ -19,17 +19,3 @@ type EmptyParams struct{}
// NoParams is a convenient instance of EmptyParams. // NoParams is a convenient instance of EmptyParams.
var NoParams = 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))
}
}
+13 -3
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// SetPassportDataErrorsP holds parameters for the setPassportDataErrors method. import "context"
// SetPassportDataErrors holds parameters for the setPassportDataErrors method.
// See https://core.telegram.org/bots/api#setpassportdataerrors // See https://core.telegram.org/bots/api#setpassportdataerrors
type SetPassportDataErrorsP struct { type SetPassportDataErrors struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Errors []PassportElementError `json:"errors"` Errors []PassportElementError `json:"errors"`
} }
@@ -10,7 +12,15 @@ type SetPassportDataErrorsP struct {
// SetPassportDataErrors informs a user about Telegram Passport data errors. // SetPassportDataErrors informs a user about Telegram Passport data errors.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setpassportdataerrors // 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) req := NewRequest[bool]("setPassportDataErrors", params)
return req.Do(api) return req.Do(api)
} }
// SetPassportDataErrorsWithContext is the context-aware variant of SetPassportDataErrors.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setpassportdataerrors
func (api *API) SetPassportDataErrorsWithContext(ctx context.Context, params SetPassportDataErrors) (bool, error) {
req := NewRequest[bool]("setPassportDataErrors", params)
return req.DoWithContext(ctx, api)
}
+60 -1
View File
@@ -1,5 +1,64 @@
package tgapi 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. // PassportElementError is a JSON-serializable passport element error object.
// See https://core.telegram.org/bots/api#passportelementerror // 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"`
}
+49 -16
View File
@@ -1,12 +1,13 @@
package tgapi package tgapi
// SendInvoiceP holds parameters for the sendInvoice method. import "context"
// SendInvoice holds parameters for the sendInvoice method.
// See https://core.telegram.org/bots/api#sendinvoice // See https://core.telegram.org/bots/api#sendinvoice
type SendInvoiceP struct { type SendInvoice struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` ChatID int64 `json:"chat_id"`
ChatID int64 `json:"chat_id"` MessageThreadID int `json:"message_thread_id,omitempty"`
MessageThreadID int `json:"message_thread_id,omitempty"` DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
DirectMessagesTopicID int `json:"direct_messages_topic_id,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
@@ -42,14 +43,22 @@ type SendInvoiceP struct {
// SendInvoice sends an invoice. // SendInvoice sends an invoice.
// See https://core.telegram.org/bots/api#sendinvoice // 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) req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// CreateInvoiceLinkP holds parameters for the createInvoiceLink method. // 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 SendInvoice) (Message, error) {
req := NewRequestWithChatID[Message]("sendInvoice", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// CreateInvoiceLink holds parameters for the createInvoiceLink method.
// See https://core.telegram.org/bots/api#createinvoicelink // See https://core.telegram.org/bots/api#createinvoicelink
type CreateInvoiceLinkP struct { type CreateInvoiceLink struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
Title string `json:"title"` Title string `json:"title"`
@@ -78,14 +87,22 @@ type CreateInvoiceLinkP struct {
// CreateInvoiceLink creates an invoice link. // CreateInvoiceLink creates an invoice link.
// See https://core.telegram.org/bots/api#createinvoicelink // 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) req := NewRequest[string]("createInvoiceLink", params)
return req.Do(api) return req.Do(api)
} }
// AnswerShippingQueryP holds parameters for the answerShippingQuery method. // 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 CreateInvoiceLink) (string, error) {
req := NewRequest[string]("createInvoiceLink", params)
return req.DoWithContext(ctx, api)
}
// AnswerShippingQuery holds parameters for the answerShippingQuery method.
// See https://core.telegram.org/bots/api#answershippingquery // See https://core.telegram.org/bots/api#answershippingquery
type AnswerShippingQueryP struct { type AnswerShippingQuery struct {
ShippingQueryID string `json:"shipping_query_id"` ShippingQueryID string `json:"shipping_query_id"`
OK bool `json:"ok"` OK bool `json:"ok"`
ShippingOptions []ShippingOption `json:"shipping_options,omitempty"` ShippingOptions []ShippingOption `json:"shipping_options,omitempty"`
@@ -95,14 +112,22 @@ type AnswerShippingQueryP struct {
// AnswerShippingQuery answers a shipping query. // AnswerShippingQuery answers a shipping query.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#answershippingquery // 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) req := NewRequest[bool]("answerShippingQuery", params)
return req.Do(api) return req.Do(api)
} }
// AnswerPreCheckoutQueryP holds parameters for the answerPreCheckoutQuery method. // 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 AnswerShippingQuery) (bool, error) {
req := NewRequest[bool]("answerShippingQuery", params)
return req.DoWithContext(ctx, api)
}
// AnswerPreCheckoutQuery holds parameters for the answerPreCheckoutQuery method.
// See https://core.telegram.org/bots/api#answerprecheckoutquery // See https://core.telegram.org/bots/api#answerprecheckoutquery
type AnswerPreCheckoutQueryP struct { type AnswerPreCheckoutQuery struct {
PreCheckoutQueryID string `json:"pre_checkout_query_id"` PreCheckoutQueryID string `json:"pre_checkout_query_id"`
OK bool `json:"ok"` OK bool `json:"ok"`
ErrorMessage string `json:"error_message,omitempty"` ErrorMessage string `json:"error_message,omitempty"`
@@ -111,7 +136,15 @@ type AnswerPreCheckoutQueryP struct {
// AnswerPreCheckoutQuery answers a pre-checkout query. // AnswerPreCheckoutQuery answers a pre-checkout query.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#answerprecheckoutquery // 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) req := NewRequest[bool]("answerPreCheckoutQuery", params)
return req.Do(api) return req.Do(api)
} }
// AnswerPreCheckoutQueryWithContext is the context-aware variant of AnswerPreCheckoutQuery.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#answerprecheckoutquery
func (api *API) AnswerPreCheckoutQueryWithContext(ctx context.Context, params 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"` 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. // ShippingOption represents one shipping option.
// See https://core.telegram.org/bots/api#shippingoption // See https://core.telegram.org/bots/api#shippingoption
type ShippingOption struct { type ShippingOption struct {
@@ -14,3 +70,27 @@ type ShippingOption struct {
Title string `json:"title"` Title string `json:"title"`
Prices []LabeledPrice `json:"prices"` 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" "sync"
) )
// workerPool — приватная структура, управляющая пулом воркеров.
// Внешний код не может создавать или напрямую взаимодействовать с этой структурой.
// Используется только через экспортируемые методы newWorkerPool, start, stop, submit.
type workerPool struct { type workerPool struct {
taskCh chan requestEnvelope // канал для принятия задач (буферизованный) taskCh chan requestEnvelope
queueSize int // максимальный размер очереди queueSize int
workers int // количество воркеров (горутин) workers int
wg sync.WaitGroup // синхронизирует завершение всех воркеров при остановке wg sync.WaitGroup
quit chan struct{} // канал для сигнала остановки quit chan struct{}
stopOnce sync.Once // гарантирует идемпотентную остановку пула stopOnce sync.Once
started bool // флаг, указывающий, запущен ли пул started bool
stopped bool // флаг, указывающий, что пул остановлен stopped bool
startedMu sync.Mutex // мьютекс для безопасного доступа к started startedMu sync.Mutex
} }
// requestEnvelope — приватная структура, инкапсулирующая задачу и канал для результата.
// Используется только внутри пакета для передачи задач воркерам.
type requestEnvelope struct { type requestEnvelope struct {
ctx context.Context // контекст конкретной задачи ctx context.Context
doFunc func(context.Context) (any, error) // функция, выполняющая запрос doFunc func(context.Context) (any, error)
resultCh chan requestResult // канал, через который воркер вернёт результат resultCh chan requestResult
} }
// requestResult — приватная структура, представляющая результат выполнения задачи.
// Внешний код получает его через канал, но не знает структуры — только через <-chan requestResult.
type requestResult struct { type requestResult struct {
value any // значение, возвращённое задачей value any
err error // ошибка, если возникла err error
} }
// newWorkerPool создаёт новый пул воркеров с заданным количеством горутин и размером очереди.
// Это единственный способ создать workerPool — внешний код не может создать его напрямую.
func newWorkerPool(workers int, queueSize int) *workerPool { func newWorkerPool(workers int, queueSize int) *workerPool {
if workers <= 0 { if workers <= 0 {
workers = 1 // защита от некорректных значений workers = 1
} }
if queueSize <= 0 { if queueSize <= 0 {
queueSize = 100 // разумный дефолт queueSize = 100
} }
return &workerPool{ return &workerPool{
@@ -53,43 +44,32 @@ func newWorkerPool(workers int, queueSize int) *workerPool {
} }
} }
// start запускает воркеры (горутины), которые будут обрабатывать задачи из очереди.
// Метод идемпотентен: если пул уже запущен — ничего не делает.
// Должен вызываться перед первым вызовом submit.
func (p *workerPool) start() { func (p *workerPool) start() {
p.startedMu.Lock() p.startedMu.Lock()
defer p.startedMu.Unlock() defer p.startedMu.Unlock()
if p.started { if p.started {
return // уже запущен — ничего не делаем return
} }
p.started = true p.started = true
// Запускаем воркеры — каждый будет обрабатывать задачи в бесконечном цикле
for i := 0; i < p.workers; i++ { for i := 0; i < p.workers; i++ {
p.wg.Add(1) p.wg.Add(1)
go p.worker() // запускаем горутину go p.worker()
} }
} }
// stop останавливает пул воркеров.
// Отправляет сигнал остановки через quit-канал и ждёт завершения всех активных задач.
// Безопасно вызывать многократно — после остановки повторные вызовы не имеют эффекта.
func (p *workerPool) stop() { func (p *workerPool) stop() {
p.stopOnce.Do(func() { p.stopOnce.Do(func() {
p.startedMu.Lock() p.startedMu.Lock()
p.stopped = true p.stopped = true
p.started = false p.started = false
close(p.quit) // сигнал для всех воркеров — выйти из цикла close(p.quit)
p.startedMu.Unlock() p.startedMu.Unlock()
p.wg.Wait() // ждём, пока все воркеры завершатся p.wg.Wait()
}) })
} }
// submit отправляет задачу в очередь и возвращает канал, через который будет получен результат.
// Если очередь переполнена — возвращает ErrPoolQueueFull.
// Канал результата имеет буфер 1, чтобы не блокировать воркера при записи.
// Контекст используется для отмены задачи, если клиент отменил запрос до отправки.
func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) { func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any, error)) (<-chan requestResult, error) {
p.startedMu.Lock() p.startedMu.Lock()
if p.stopped || !p.started { if p.stopped || !p.started {
@@ -97,55 +77,39 @@ func (p *workerPool) submit(ctx context.Context, do func(context.Context) (any,
return nil, ErrPoolStopped return nil, ErrPoolStopped
} }
// Проверяем, не превышена ли очередь
if len(p.taskCh) >= p.queueSize { if len(p.taskCh) >= p.queueSize {
p.startedMu.Unlock() p.startedMu.Unlock()
return nil, ErrPoolQueueFull return nil, ErrPoolQueueFull
} }
// Создаём канал для результата — буферизованный, чтобы не блокировать воркера
resultCh := make(chan requestResult, 1) resultCh := make(chan requestResult, 1)
// Создаём обёртку задачи
envelope := requestEnvelope{ envelope := requestEnvelope{
ctx: ctx, ctx: ctx,
doFunc: do, doFunc: do,
resultCh: resultCh, resultCh: resultCh,
} }
// Пытаемся отправить задачу в очередь
select { select {
case <-ctx.Done(): case <-ctx.Done():
p.startedMu.Unlock() p.startedMu.Unlock()
// Клиент отменил операцию до отправки — возвращаем ошибку отмены
return nil, ctx.Err() return nil, ctx.Err()
case p.taskCh <- envelope: case p.taskCh <- envelope:
p.startedMu.Unlock() p.startedMu.Unlock()
// Успешно отправлено — возвращаем канал для чтения результата
return resultCh, nil return resultCh, nil
default: default:
p.startedMu.Unlock() p.startedMu.Unlock()
// Очередь переполнена — не должно происходить при проверке len(p.taskCh), но на всякий случай
return nil, ErrPoolQueueFull return nil, ErrPoolQueueFull
} }
} }
// worker — приватная горутина, выполняющая задачи из очереди.
// Каждый воркер работает в бесконечном цикле, пока не получит сигнал остановки.
// При получении задачи:
// - вызывает doFunc с контекстом
// - записывает результат в resultCh
// - закрывает канал, чтобы клиент мог прочитать и завершить
//
// После закрытия quit-канала — воркер завершает работу.
func (p *workerPool) worker() { func (p *workerPool) worker() {
defer p.wg.Done() // уменьшаем WaitGroup при завершении горутины defer p.wg.Done()
for { for {
select { select {
case <-p.quit: case <-p.quit:
// Получен сигнал остановки — дренируем очередь и выходим. // Drain queued work after stop. No new tasks are accepted.
// После stop() новые задачи не принимаются.
for { for {
select { select {
case envelope := <-p.taskCh: case envelope := <-p.taskCh:
@@ -162,14 +126,10 @@ func (p *workerPool) worker() {
} }
func (p *workerPool) executeEnvelope(envelope requestEnvelope) { func (p *workerPool) executeEnvelope(envelope requestEnvelope) {
// Выполняем задачу с переданным контекстом (клиентский или общий)
value, err := envelope.doFunc(envelope.ctx) value, err := envelope.doFunc(envelope.ctx)
// Записываем результат в канал — не блокируем, т.к. буфер 1
envelope.resultCh <- requestResult{ envelope.resultCh <- requestResult{
value: value, value: value,
err: err, err: err,
} }
// Закрываем канал — клиент знает, что результат пришёл и больше не будет
close(envelope.resultCh) close(envelope.resultCh)
} }
+62
View File
@@ -0,0 +1,62 @@
package tgapi
import (
"context"
"errors"
"testing"
)
func TestWorkerPoolSubmitAfterStop(t *testing.T) {
pool := newWorkerPool(1, 1)
pool.start()
pool.stop()
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return nil, nil
}); !errors.Is(err, ErrPoolStopped) {
t.Fatalf("expected ErrPoolStopped, got %v", err)
}
}
func TestWorkerPoolQueueFull(t *testing.T) {
pool := newWorkerPool(1, 1)
pool.start()
defer pool.stop()
started := make(chan struct{})
release := make(chan struct{})
firstResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
close(started)
<-release
return "first", nil
})
if err != nil {
t.Fatalf("first submit returned error: %v", err)
}
<-started
secondResult, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return "second", nil
})
if err != nil {
t.Fatalf("second submit returned error: %v", err)
}
if _, err := pool.submit(context.Background(), func(context.Context) (any, error) {
return "third", nil
}); !errors.Is(err, ErrPoolQueueFull) {
t.Fatalf("expected ErrPoolQueueFull, got %v", err)
}
close(release)
first := <-firstResult
if first.err != nil || first.value != "first" {
t.Fatalf("unexpected first result: %+v", first)
}
second := <-secondResult
if second.err != nil || second.value != "second" {
t.Fatalf("unexpected second result: %+v", second)
}
}
+43 -9
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// GetStarTransactionsP holds parameters for the getStarTransactions method. import "context"
// GetStarTransactions holds parameters for the getStarTransactions method.
// See https://core.telegram.org/bots/api#getstartransactions // See https://core.telegram.org/bots/api#getstartransactions
type GetStarTransactionsP struct { type GetStarTransactions struct {
Offset int `json:"offset,omitempty"` Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
} }
@@ -14,16 +16,32 @@ func (api *API) GetMyStarBalance() (StarAmount, error) {
return req.Do(api) return req.Do(api)
} }
// GetMyStarBalanceWithContext is the context-aware variant of GetMyStarBalance.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getmystarbalance
func (api *API) GetMyStarBalanceWithContext(ctx context.Context) (StarAmount, error) {
req := NewRequest[StarAmount]("getMyStarBalance", NoParams)
return req.DoWithContext(ctx, api)
}
// GetStarTransactions returns Telegram Star transactions for the bot. // GetStarTransactions returns Telegram Star transactions for the bot.
// See https://core.telegram.org/bots/api#getstartransactions // See https://core.telegram.org/bots/api#getstartransactions
func (api *API) GetStarTransactions(params GetStarTransactionsP) (StarTransactions, error) { func (api *API) GetStarTransactions(params GetStarTransactions) (StarTransactions, error) {
req := NewRequest[StarTransactions]("getStarTransactions", params) req := NewRequest[StarTransactions]("getStarTransactions", params)
return req.Do(api) return req.Do(api)
} }
// RefundStarPaymentP holds parameters for the refundStarPayment method. // 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 GetStarTransactions) (StarTransactions, error) {
req := NewRequest[StarTransactions]("getStarTransactions", params)
return req.DoWithContext(ctx, api)
}
// RefundStarPayment holds parameters for the refundStarPayment method.
// See https://core.telegram.org/bots/api#refundstarpayment // See https://core.telegram.org/bots/api#refundstarpayment
type RefundStarPaymentP struct { type RefundStarPayment struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
} }
@@ -31,14 +49,22 @@ type RefundStarPaymentP struct {
// RefundStarPayment refunds a successful Telegram Stars payment. // RefundStarPayment refunds a successful Telegram Stars payment.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#refundstarpayment // 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) req := NewRequest[bool]("refundStarPayment", params)
return req.Do(api) return req.Do(api)
} }
// EditUserStarSubscriptionP holds parameters for the editUserStarSubscription method. // 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 RefundStarPayment) (bool, error) {
req := NewRequest[bool]("refundStarPayment", params)
return req.DoWithContext(ctx, api)
}
// EditUserStarSubscription holds parameters for the editUserStarSubscription method.
// See https://core.telegram.org/bots/api#edituserstarsubscription // See https://core.telegram.org/bots/api#edituserstarsubscription
type EditUserStarSubscriptionP struct { type EditUserStarSubscription struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"` TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
IsCanceled bool `json:"is_canceled"` IsCanceled bool `json:"is_canceled"`
@@ -47,7 +73,15 @@ type EditUserStarSubscriptionP struct {
// EditUserStarSubscription cancels or re-enables a user star subscription extension. // EditUserStarSubscription cancels or re-enables a user star subscription extension.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#edituserstarsubscription // 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) req := NewRequest[bool]("editUserStarSubscription", params)
return req.Do(api) return req.Do(api)
} }
// EditUserStarSubscriptionWithContext is the context-aware variant of EditUserStarSubscription.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#edituserstarsubscription
func (api *API) EditUserStarSubscriptionWithContext(ctx context.Context, params EditUserStarSubscription) (bool, error) {
req := NewRequest[bool]("editUserStarSubscription", params)
return req.DoWithContext(ctx, api)
}
+186 -48
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// SendStickerP holds parameters for the sendSticker method. import "context"
// SendSticker holds parameters for the sendSticker method.
// See https://core.telegram.org/bots/api#sendsticker // See https://core.telegram.org/bots/api#sendsticker
type SendStickerP struct { type SendSticker struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -14,44 +16,72 @@ type SendStickerP struct {
ProtectContent bool `json:"protect_content,omitempty"` ProtectContent bool `json:"protect_content,omitempty"`
AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"`
MessageEffectID string `json:"message_effect_id,omitempty"` MessageEffectID string `json:"message_effect_id,omitempty"`
SuggestedPostParameters *SuggestedPostParameters `json:"suggested_post_parameters,omitempty"`
ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"`
ReplyMarkup *ReplyMarkup `json:"reply_markup,omitempty"`
} }
// SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker. // SendSticker sends a static .WEBP, animated .TGS, or video .WEBM sticker.
// See https://core.telegram.org/bots/api#sendsticker // 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) req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID)
return req.Do(api) return req.Do(api)
} }
// GetStickerSetP holds parameters for the getStickerSet method. // 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 SendSticker) (Message, error) {
req := NewRequestWithChatID[Message]("sendSticker", params, params.ChatID)
return req.DoWithContext(ctx, api)
}
// GetStickerSet holds parameters for the getStickerSet method.
// See https://core.telegram.org/bots/api#getstickerset // See https://core.telegram.org/bots/api#getstickerset
type GetStickerSetP struct { type GetStickerSet struct {
Name string `json:"name"` Name string `json:"name"`
} }
// GetStickerSet returns a sticker set by its name. // GetStickerSet returns a sticker set by its name.
// See https://core.telegram.org/bots/api#getstickerset // 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) req := NewRequest[StickerSet]("getStickerSet", params)
return req.Do(api) return req.Do(api)
} }
// GetCustomEmojiStickersP holds parameters for the getCustomEmojiStickers method. // 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 GetStickerSet) (StickerSet, error) {
req := NewRequest[StickerSet]("getStickerSet", params)
return req.DoWithContext(ctx, api)
}
// GetCustomEmojiStickers holds parameters for the getCustomEmojiStickers method.
// See https://core.telegram.org/bots/api#getcustomemojistickers // See https://core.telegram.org/bots/api#getcustomemojistickers
type GetCustomEmojiStickersP struct { type GetCustomEmojiStickers struct {
CustomEmojiIDs []string `json:"custom_emoji_ids"` CustomEmojiIDs []string `json:"custom_emoji_ids"`
} }
// GetCustomEmojiStickers returns information about custom emoji stickers by their IDs. // GetCustomEmojiStickers returns information about custom emoji stickers by their IDs.
// See https://core.telegram.org/bots/api#getcustomemojistickers // 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) req := NewRequest[[]Sticker]("getCustomEmojiStickers", params)
return req.Do(api) return req.Do(api)
} }
// UploadStickerFileP holds parameters for the uploadStickerFile method. // 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 GetCustomEmojiStickers) ([]Sticker, error) {
req := NewRequest[[]Sticker]("getCustomEmojiStickers", params)
return req.DoWithContext(ctx, api)
}
// UploadStickerFile holds parameters for the uploadStickerFile method.
// See https://core.telegram.org/bots/api#uploadstickerfile // See https://core.telegram.org/bots/api#uploadstickerfile
type UploadStickerFileP struct { type UploadStickerFile struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
StickerFormat InputStickerFormat `json:"sticker_format"` StickerFormat InputStickerFormat `json:"sticker_format"`
} }
@@ -59,7 +89,7 @@ type UploadStickerFileP struct {
// UploadStickerFile uploads a sticker file for later use in sticker set methods. // UploadStickerFile uploads a sticker file for later use in sticker set methods.
// sticker is the file to upload. // sticker is the file to upload.
// See https://core.telegram.org/bots/api#uploadstickerfile // 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) uploader := NewUploader(api)
defer func() { defer func() {
_ = uploader.Close() _ = uploader.Close()
@@ -68,9 +98,21 @@ func (api *API) UploadStickerFile(params UploadStickerFileP, sticker UploaderFil
return req.Do(uploader) return req.Do(uploader)
} }
// CreateNewStickerSetP holds parameters for the createNewStickerSet method. // 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 UploadStickerFile, sticker UploaderFile) (File, error) {
uploader := NewUploader(api)
defer func() {
_ = uploader.Close()
}()
req := NewUploaderRequest[File]("uploadStickerFile", params, sticker.SetType(UploaderStickerType))
return req.DoWithContext(ctx, uploader)
}
// CreateNewStickerSet holds parameters for the createNewStickerSet method.
// See https://core.telegram.org/bots/api#createnewstickerset // See https://core.telegram.org/bots/api#createnewstickerset
type CreateNewStickerSetP struct { type CreateNewStickerSet struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Title string `json:"title"` Title string `json:"title"`
@@ -83,14 +125,22 @@ type CreateNewStickerSetP struct {
// CreateNewStickerSet creates a new sticker set owned by a user. // CreateNewStickerSet creates a new sticker set owned by a user.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#createnewstickerset // 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) req := NewRequest[bool]("createNewStickerSet", params)
return req.Do(api) return req.Do(api)
} }
// AddStickerToSetP holds parameters for the addStickerToSet method. // 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 CreateNewStickerSet) (bool, error) {
req := NewRequest[bool]("createNewStickerSet", params)
return req.DoWithContext(ctx, api)
}
// AddStickerToSet holds parameters for the addStickerToSet method.
// See https://core.telegram.org/bots/api#addstickertoset // See https://core.telegram.org/bots/api#addstickertoset
type AddStickerToSetP struct { type AddStickerToSet struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
Sticker InputSticker `json:"sticker"` Sticker InputSticker `json:"sticker"`
@@ -99,14 +149,22 @@ type AddStickerToSetP struct {
// AddStickerToSet adds a new sticker to a set created by the bot. // AddStickerToSet adds a new sticker to a set created by the bot.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#addstickertoset // 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) req := NewRequest[bool]("addStickerToSet", params)
return req.Do(api) return req.Do(api)
} }
// SetStickerPositionInSetP holds parameters for the setStickerPositionInSet method. // 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 AddStickerToSet) (bool, error) {
req := NewRequest[bool]("addStickerToSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerPositionInSet holds parameters for the setStickerPositionInSet method.
// See https://core.telegram.org/bots/api#setstickerpositioninset // See https://core.telegram.org/bots/api#setstickerpositioninset
type SetStickerPositionInSetP struct { type SetStickerPositionInSet struct {
Sticker string `json:"sticker"` Sticker string `json:"sticker"`
Position int `json:"position"` Position int `json:"position"`
} }
@@ -114,28 +172,44 @@ type SetStickerPositionInSetP struct {
// SetStickerPositionInSet moves a sticker in a set to a specific position. // SetStickerPositionInSet moves a sticker in a set to a specific position.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setstickerpositioninset // 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) req := NewRequest[bool]("setStickerPositionInSet", params)
return req.Do(api) return req.Do(api)
} }
// DeleteStickerFromSetP holds parameters for the deleteStickerFromSet method. // 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 SetStickerPositionInSet) (bool, error) {
req := NewRequest[bool]("setStickerPositionInSet", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerFromSet holds parameters for the deleteStickerFromSet method.
// See https://core.telegram.org/bots/api#deletestickerfromset // See https://core.telegram.org/bots/api#deletestickerfromset
type DeleteStickerFromSetP struct { type DeleteStickerFromSet struct {
Sticker string `json:"sticker"` Sticker string `json:"sticker"`
} }
// DeleteStickerFromSet deletes a sticker from a set created by the bot. // DeleteStickerFromSet deletes a sticker from a set created by the bot.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deletestickerfromset // 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) req := NewRequest[bool]("deleteStickerFromSet", params)
return req.Do(api) return req.Do(api)
} }
// ReplaceStickerInSetP holds parameters for the replaceStickerInSet method. // 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 DeleteStickerFromSet) (bool, error) {
req := NewRequest[bool]("deleteStickerFromSet", params)
return req.DoWithContext(ctx, api)
}
// ReplaceStickerInSet holds parameters for the replaceStickerInSet method.
// See https://core.telegram.org/bots/api#replacestickerinset // See https://core.telegram.org/bots/api#replacestickerinset
type ReplaceStickerInSetP struct { type ReplaceStickerInSet struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Name string `json:"name"` Name string `json:"name"`
OldSticker string `json:"old_sticker"` OldSticker string `json:"old_sticker"`
@@ -145,14 +219,22 @@ type ReplaceStickerInSetP struct {
// ReplaceStickerInSet replaces an existing sticker in a set with a new one. // ReplaceStickerInSet replaces an existing sticker in a set with a new one.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#replacestickerinset // 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) req := NewRequest[bool]("replaceStickerInSet", params)
return req.Do(api) return req.Do(api)
} }
// SetStickerEmojiListP holds parameters for the setStickerEmojiList method. // 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 ReplaceStickerInSet) (bool, error) {
req := NewRequest[bool]("replaceStickerInSet", params)
return req.DoWithContext(ctx, api)
}
// SetStickerEmojiList holds parameters for the setStickerEmojiList method.
// See https://core.telegram.org/bots/api#setstickeremojilist // See https://core.telegram.org/bots/api#setstickeremojilist
type SetStickerEmojiListP struct { type SetStickerEmojiList struct {
Sticker string `json:"sticker"` Sticker string `json:"sticker"`
EmojiList []string `json:"emoji_list"` EmojiList []string `json:"emoji_list"`
} }
@@ -160,14 +242,22 @@ type SetStickerEmojiListP struct {
// SetStickerEmojiList changes the list of emoji associated with a sticker. // SetStickerEmojiList changes the list of emoji associated with a sticker.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setstickeremojilist // 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) req := NewRequest[bool]("setStickerEmojiList", params)
return req.Do(api) return req.Do(api)
} }
// SetStickerKeywordsP holds parameters for the setStickerKeywords method. // 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 SetStickerEmojiList) (bool, error) {
req := NewRequest[bool]("setStickerEmojiList", params)
return req.DoWithContext(ctx, api)
}
// SetStickerKeywords holds parameters for the setStickerKeywords method.
// See https://core.telegram.org/bots/api#setstickerkeywords // See https://core.telegram.org/bots/api#setstickerkeywords
type SetStickerKeywordsP struct { type SetStickerKeywords struct {
Sticker string `json:"sticker"` Sticker string `json:"sticker"`
Keywords []string `json:"keywords"` Keywords []string `json:"keywords"`
} }
@@ -175,14 +265,22 @@ type SetStickerKeywordsP struct {
// SetStickerKeywords changes the keywords of a sticker. // SetStickerKeywords changes the keywords of a sticker.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setstickerkeywords // 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) req := NewRequest[bool]("setStickerKeywords", params)
return req.Do(api) return req.Do(api)
} }
// SetStickerMaskPositionP holds parameters for the setStickerMaskPosition method. // 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 SetStickerKeywords) (bool, error) {
req := NewRequest[bool]("setStickerKeywords", params)
return req.DoWithContext(ctx, api)
}
// SetStickerMaskPosition holds parameters for the setStickerMaskPosition method.
// See https://core.telegram.org/bots/api#setstickermaskposition // See https://core.telegram.org/bots/api#setstickermaskposition
type SetStickerMaskPositionP struct { type SetStickerMaskPosition struct {
Sticker string `json:"sticker"` Sticker string `json:"sticker"`
MaskPosition *MaskPosition `json:"mask_position,omitempty"` MaskPosition *MaskPosition `json:"mask_position,omitempty"`
} }
@@ -190,14 +288,22 @@ type SetStickerMaskPositionP struct {
// SetStickerMaskPosition changes the mask position of a mask sticker. // SetStickerMaskPosition changes the mask position of a mask sticker.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setstickermaskposition // 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) req := NewRequest[bool]("setStickerMaskPosition", params)
return req.Do(api) return req.Do(api)
} }
// SetStickerSetTitleP holds parameters for the setStickerSetTitle method. // 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 SetStickerMaskPosition) (bool, error) {
req := NewRequest[bool]("setStickerMaskPosition", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetTitle holds parameters for the setStickerSetTitle method.
// See https://core.telegram.org/bots/api#setstickersettitle // See https://core.telegram.org/bots/api#setstickersettitle
type SetStickerSetTitleP struct { type SetStickerSetTitle struct {
Name string `json:"name"` Name string `json:"name"`
Title string `json:"title"` Title string `json:"title"`
} }
@@ -205,14 +311,22 @@ type SetStickerSetTitleP struct {
// SetStickerSetTitle sets the title of a sticker set created by the bot. // SetStickerSetTitle sets the title of a sticker set created by the bot.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setstickersettitle // 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) req := NewRequest[bool]("setStickerSetTitle", params)
return req.Do(api) return req.Do(api)
} }
// SetStickerSetThumbnailP holds parameters for the setStickerSetThumbnail method. // 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 SetStickerSetTitle) (bool, error) {
req := NewRequest[bool]("setStickerSetTitle", params)
return req.DoWithContext(ctx, api)
}
// SetStickerSetThumbnail holds parameters for the setStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setstickersetthumbnail // See https://core.telegram.org/bots/api#setstickersetthumbnail
type SetStickerSetThumbnailP struct { type SetStickerSetThumbnail struct {
Name string `json:"name"` Name string `json:"name"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Thumbnail string `json:"thumbnail"` Thumbnail string `json:"thumbnail"`
@@ -222,14 +336,22 @@ type SetStickerSetThumbnailP struct {
// SetStickerSetThumbnail sets the thumbnail of a sticker set. // SetStickerSetThumbnail sets the thumbnail of a sticker set.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setstickersetthumbnail // 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) req := NewRequest[bool]("setStickerSetThumbnail", params)
return req.Do(api) return req.Do(api)
} }
// SetCustomEmojiStickerSetThumbnailP holds parameters for the setCustomEmojiStickerSetThumbnail method. // 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 SetStickerSetThumbnail) (bool, error) {
req := NewRequest[bool]("setStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// SetCustomEmojiStickerSetThumbnail holds parameters for the setCustomEmojiStickerSetThumbnail method.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail // See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail
type SetCustomEmojiStickerSetThumbnailP struct { type SetCustomEmojiStickerSetThumbnail struct {
Name string `json:"name"` Name string `json:"name"`
CustomEmojiID string `json:"custom_emoji_id,omitempty"` CustomEmojiID string `json:"custom_emoji_id,omitempty"`
} }
@@ -237,21 +359,37 @@ type SetCustomEmojiStickerSetThumbnailP struct {
// SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set. // SetCustomEmojiStickerSetThumbnail sets the thumbnail of a custom emoji sticker set.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#setcustomemojistickersetthumbnail // 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) req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params)
return req.Do(api) return req.Do(api)
} }
// DeleteStickerSetP holds parameters for the deleteStickerSet method. // 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 SetCustomEmojiStickerSetThumbnail) (bool, error) {
req := NewRequest[bool]("setCustomEmojiStickerSetThumbnail", params)
return req.DoWithContext(ctx, api)
}
// DeleteStickerSet holds parameters for the deleteStickerSet method.
// See https://core.telegram.org/bots/api#deletestickerset // See https://core.telegram.org/bots/api#deletestickerset
type DeleteStickerSetP struct { type DeleteStickerSet struct {
Name string `json:"name"` Name string `json:"name"`
} }
// DeleteStickerSet deletes a sticker set created by the bot. // DeleteStickerSet deletes a sticker set created by the bot.
// Returns True on success. // Returns True on success.
// See https://core.telegram.org/bots/api#deletestickerset // 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) req := NewRequest[bool]("deleteStickerSet", params)
return req.Do(api) return req.Do(api)
} }
// DeleteStickerSetWithContext is the context-aware variant of DeleteStickerSet.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#deletestickerset
func (api *API) DeleteStickerSetWithContext(ctx context.Context, params 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. // Sticker represents a sticker.
// See https://core.telegram.org/bots/api#sticker // See https://core.telegram.org/bots/api#sticker
type Sticker struct { type Sticker struct {
FileId string `json:"file_id"` FileID string `json:"file_id"`
FileUniqueId string `json:"file_unique_id"` FileUniqueID string `json:"file_unique_id"`
Type StickerType `json:"type"` Type StickerType `json:"type"`
Width int `json:"width"` Width int `json:"width"`
Height int `json:"height"` Height int `json:"height"`
+289 -168
View File
@@ -6,6 +6,9 @@ import "encoding/json"
type UpdateType string type UpdateType string
const ( const (
// UpdateTypeUnknown marks an update whose payload does not match a known Telegram update kind.
UpdateTypeUnknown UpdateType = "unknown"
// UpdateTypeMessage is a regular message update. // UpdateTypeMessage is a regular message update.
UpdateTypeMessage UpdateType = "message" UpdateTypeMessage UpdateType = "message"
// UpdateTypeEditedMessage is an edited message update. // UpdateTypeEditedMessage is an edited message update.
@@ -27,8 +30,6 @@ const (
UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message" UpdateTypeEditedBusinessMessage UpdateType = "edited_business_message"
// UpdateTypeDeletedBusinessMessages is a deleted business messages update. // UpdateTypeDeletedBusinessMessages is a deleted business messages update.
UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages" UpdateTypeDeletedBusinessMessages UpdateType = "deleted_business_messages"
// UpdateTypeDeletedBusinessMessage is kept as a backward-compatible alias.
UpdateTypeDeletedBusinessMessage UpdateType = UpdateTypeDeletedBusinessMessages
// UpdateTypeInlineQuery is an inline query update. // UpdateTypeInlineQuery is an inline query update.
UpdateTypeInlineQuery UpdateType = "inline_query" UpdateTypeInlineQuery UpdateType = "inline_query"
@@ -56,11 +57,15 @@ const (
UpdateTypeChatBoost UpdateType = "chat_boost" UpdateTypeChatBoost UpdateType = "chat_boost"
// UpdateTypeRemovedChatBoost is a removed chat boost update. // UpdateTypeRemovedChatBoost is a removed chat boost update.
UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost" UpdateTypeRemovedChatBoost UpdateType = "removed_chat_boost"
UpdateTypeManagedBot UpdateType = "managed_bot"
) )
// Update represents an incoming update from Telegram. // Update represents an incoming update from Telegram.
// See https://core.telegram.org/bots/api#update // See https://core.telegram.org/bots/api#update
type Update struct { type Update struct {
Type UpdateType `json:"-"`
UpdateID int `json:"update_id"` UpdateID int `json:"update_id"`
Message *Message `json:"message,omitempty"` Message *Message `json:"message,omitempty"`
EditedMessage *Message `json:"edited_message,omitempty"` EditedMessage *Message `json:"edited_message,omitempty"`
@@ -71,7 +76,6 @@ type Update struct {
BusinessMessage *Message `json:"business_message,omitempty"` BusinessMessage *Message `json:"business_message,omitempty"`
EditedBusinessMessage *Message `json:"edited_business_message,omitempty"` EditedBusinessMessage *Message `json:"edited_business_message,omitempty"`
DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"` DeletedBusinessMessages *BusinessMessagesDeleted `json:"deleted_business_messages,omitempty"`
DeletedBusinessMessage *BusinessMessagesDeleted `json:"-"`
MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"`
MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"`
@@ -89,35 +93,98 @@ type Update struct {
ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"` ChatJoinRequest *ChatJoinRequest `json:"chat_join_request,omitempty"`
ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"` ChatBoost *ChatBoostUpdated `json:"chat_boost,omitempty"`
RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"` RemovedChatBoost *ChatBoostRemoved `json:"removed_chat_boost,omitempty"`
ManagedBot *ManagedBotUpdated `json:"managed_bot,omitempty"`
} }
func (u *Update) syncDeletedBusinessMessages() { // UnmarshalJSON decodes an update and derives its Type from the populated payload field.
if u.DeletedBusinessMessages != nil {
u.DeletedBusinessMessage = u.DeletedBusinessMessages
return
}
if u.DeletedBusinessMessage != nil {
u.DeletedBusinessMessages = u.DeletedBusinessMessage
}
}
// UnmarshalJSON keeps the deprecated DeletedBusinessMessage alias in sync.
func (u *Update) UnmarshalJSON(data []byte) error { func (u *Update) UnmarshalJSON(data []byte) error {
type alias Update type Alias Update
var aux alias
var aux Alias
if err := json.Unmarshal(data, &aux); err != nil { if err := json.Unmarshal(data, &aux); err != nil {
return err return err
} }
*u = Update(aux) *u = 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 return nil
} }
// MarshalJSON emits the canonical deleted_business_messages field. // WebhookInfo describes the current webhook status.
func (u Update) MarshalJSON() ([]byte, error) { // See https://core.telegram.org/bots/api#webhookinfo
u.syncDeletedBusinessMessages() type WebhookInfo struct {
type alias Update URL string `json:"url"`
return json.Marshal(alias(u)) 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. // InlineQuery represents an incoming inline query.
@@ -141,115 +208,15 @@ type ChosenInlineResult struct {
Query string `json:"query"` 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. // File represents a file ready to be downloaded.
// See https://core.telegram.org/bots/api#file // See https://core.telegram.org/bots/api#file
type File struct { type File struct {
FileId string `json:"file_id"` FileID string `json:"file_id"`
FileUniqueID string `json:"file_unique_id"` FileUniqueID string `json:"file_unique_id"`
FileSize int64 `json:"file_size,omitempty"` FileSize int64 `json:"file_size,omitempty"`
FilePath string `json:"file_path,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. // ChatMemberUpdated represents changes in the status of a chat member.
// See https://core.telegram.org/bots/api#chatmemberupdated // See https://core.telegram.org/bots/api#chatmemberupdated
type ChatMemberUpdated struct { type ChatMemberUpdated struct {
@@ -311,18 +278,17 @@ type WebAppInfo struct {
URL string `json:"url"` URL string `json:"url"`
} }
type WebAppData struct {
Data string `json:"data"`
ButtonText string `json:"button_text"`
}
// StarAmount represents an amount of Telegram Stars. // StarAmount represents an amount of Telegram Stars.
type StarAmount struct { type StarAmount struct {
Amount int `json:"amount"` Amount int `json:"amount"`
NanostarAmount int `json:"nanostar_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. // AcceptedGiftTypes represents the types of gifts accepted by a user or chat.
type AcceptedGiftTypes struct { type AcceptedGiftTypes struct {
UnlimitedGifts bool `json:"unlimited_gifts"` UnlimitedGifts bool `json:"unlimited_gifts"`
@@ -332,6 +298,58 @@ type AcceptedGiftTypes struct {
GiftsFromChannels bool `json:"gifts_from_channels"` 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. // UniqueGiftColors represents color information for a unique gift.
type UniqueGiftColors struct { type UniqueGiftColors struct {
ModelCustomEmojiID string `json:"model_custom_emoji_id"` ModelCustomEmojiID string `json:"model_custom_emoji_id"`
@@ -342,67 +360,79 @@ type UniqueGiftColors struct {
DarkThemeOtherColors []int `json:"dark_theme_other_colors"` DarkThemeOtherColors []int `json:"dark_theme_other_colors"`
} }
// GiftBackground represents the background of a gift. type UniqueGift struct {
type GiftBackground struct { GiftID string `json:"gift_id"`
CenterColor int `json:"center_color"` BaseName string `json:"base_name"`
EdgeColor int `json:"edge_color"` Name string `json:"name"`
TextColor int `json:"text_color"` 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 GiftInfo struct {
type Gift struct { Gift Gift `json:"gift"`
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. OwnedGiftID string `json:"owned_gift_id,omitempty"`
type Gifts struct { ConvertStarCount int `json:"convert_star_count,omitempty"`
Gifts []Gift `json:"gifts"` 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. // OwnedGiftType represents the type of an owned gift.
type OwnedGiftType string type OwnedGiftType string
const ( const (
// OwnedGiftRegularType identifies a regular owned gift.
OwnedGiftRegularType OwnedGiftType = "regular" OwnedGiftRegularType OwnedGiftType = "regular"
OwnedGiftUniqueType OwnedGiftType = "unique" // OwnedGiftUniqueType identifies a unique owned gift.
OwnedGiftUniqueType OwnedGiftType = "unique"
) )
// OwnedGift represents a gift owned by a user or chat. // OwnedGift represents a gift owned by a user or chat.
type OwnedGift struct { type OwnedGift struct {
Type OwnedGiftType `json:"type"` Type OwnedGiftType `json:"type"`
OwnerGiftID *string `json:"owner_gift_id,omitempty"` OwnedGiftID string `json:"ownen_gift_id,omitempty"`
SendDate *int `json:"send_date,omitempty"` SendDate int `json:"send_date,omitempty"`
IsSaved *bool `json:"is_saved,omitempty"` IsSaved bool `json:"is_saved,omitempty"`
// Fields specific to "regular" type // Fields specific to "regular" type
Gift Gift `json:"gift"` Gift Gift `json:"gift"`
SenderUser User `json:"sender_user,omitempty"` SenderUser *User `json:"sender_user,omitempty"`
Text string `json:"text,omitempty"` Text string `json:"text,omitempty"`
Entities []MessageEntity `json:"entities,omitempty"` Entities []MessageEntity `json:"entities,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"` IsPrivate bool `json:"is_private,omitempty"`
CanBeUpgraded *bool `json:"can_be_upgraded,omitempty"` CanBeUpgraded bool `json:"can_be_upgraded,omitempty"`
WasRefunded *bool `json:"was_refunded,omitempty"` WasRefunded bool `json:"was_refunded,omitempty"`
ConvertStarCount *int `json:"convert_star_count,omitempty"` ConvertStarCount int `json:"convert_star_count,omitempty"`
PrepaidUpgradeStarCount *int `json:"prepaid_upgrade_star_count,omitempty"` PrepaidUpgradeStarCount int `json:"prepaid_upgrade_star_count,omitempty"`
IsUpgradeSeparate *bool `json:"is_upgrade_separate,omitempty"` IsUpgradeSeparate bool `json:"is_upgrade_separate,omitempty"`
UniqueGiftNumber *int `json:"unique_gift_number,omitempty"` UniqueGiftNumber int `json:"unique_gift_number,omitempty"`
// Fields specific to "unique" type // Fields specific to "unique" type
CanBeTransferred *bool `json:"can_be_transferred,omitempty"` CanBeTransferred bool `json:"can_be_transferred,omitempty"`
TransferStarCount *int `json:"transfer_star_count,omitempty"` TransferStarCount int `json:"transfer_star_count,omitempty"`
NextTransferDate *int `json:"next_transfer_date,omitempty"` NextTransferDate int `json:"next_transfer_date,omitempty"`
} }
// OwnedGifts represents a list of owned gifts with pagination. // OwnedGifts represents a list of owned gifts with pagination.
@@ -411,3 +441,94 @@ type OwnedGifts struct {
Gifts []OwnedGift `json:"gifts"` Gifts []OwnedGift `json:"gifts"`
NextOffset string `json:"next_offset"` 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" "testing"
) )
func TestUpdateDeletedBusinessMessagesUnmarshalSetsAlias(t *testing.T) { func TestUpdateUnmarshalSetsType(t *testing.T) {
var update Update tests := []struct {
err := json.Unmarshal([]byte(`{ name string
"update_id": 1, body string
"deleted_business_messages": { want UpdateType
"business_connection_id": "conn", }{
"chat": {"id": 42, "type": "private"}, {
"message_ids": [3, 5] name: "deleted business messages",
} body: `{
}`), &update) "update_id": 1,
if err != nil { "deleted_business_messages": {
t.Fatalf("Unmarshal returned error: %v", err) "business_connection_id": "conn",
"chat": {"id": 42, "type": "private"},
"message_ids": [3, 5]
}
}`,
want: UpdateTypeDeletedBusinessMessages,
},
{
name: "callback query",
body: `{
"update_id": 2,
"callback_query": {
"id": "cb",
"from": {"id": 1, "is_bot": false, "first_name": "Test"},
"chat_instance": "instance",
"data": "payload"
}
}`,
want: UpdateTypeCallbackQuery,
},
{
name: "chat boost",
body: `{
"update_id": 3,
"chat_boost": {
"chat": {"id": -1001, "type": "supergroup", "title": "Boosted"},
"boost": {
"boost_id": "boost-1",
"add_date": 1735689600,
"expiration_date": 1738291600,
"source": {
"source": "premium",
"user": {"id": 1, "is_bot": false, "first_name": "Test"}
}
}
}
}`,
want: UpdateTypeChatBoost,
},
{
name: "unknown",
body: `{"update_id":4}`,
want: UpdateTypeUnknown,
},
{
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 { for _, tt := range tests {
t.Fatal("expected DeletedBusinessMessages to be populated") t.Run(tt.name, func(t *testing.T) {
} var update Update
if update.DeletedBusinessMessage == nil { if err := json.Unmarshal([]byte(tt.body), &update); err != nil {
t.Fatal("expected deprecated DeletedBusinessMessage alias to be populated") t.Fatalf("Unmarshal returned error: %v", err)
} }
if update.DeletedBusinessMessages != update.DeletedBusinessMessage { if update.Type != tt.want {
t.Fatal("expected deleted business message fields to share the same payload") t.Fatalf("unexpected update type: got %q want %q", update.Type, tt.want)
} }
if got := update.DeletedBusinessMessages.MessageIDs; len(got) != 2 || got[0] != 3 || got[1] != 5 { if tt.want == UpdateTypeChatBoost && update.ChatBoost.Boost.BoostID != "boost-1" {
t.Fatalf("unexpected message ids: %v", got) t.Fatalf("unexpected boost id: got %q want %q", update.ChatBoost.Boost.BoostID, "boost-1")
}
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{ update := Update{
UpdateID: 1, UpdateID: 1,
DeletedBusinessMessage: &BusinessMessagesDeleted{ Type: UpdateTypeCallbackQuery,
BusinessConnectionID: "conn", CallbackQuery: &CallbackQuery{
Chat: Chat{ID: 42, Type: string(ChatTypePrivate)}, ID: "cb",
MessageIDs: []int{7}, From: User{ID: 1, FirstName: "Test"},
ChatInstance: "instance",
Data: "payload",
}, },
} }
@@ -50,11 +139,8 @@ func TestUpdateMarshalUsesCanonicalDeletedBusinessMessagesField(t *testing.T) {
} }
got := string(data) got := string(data)
if !strings.Contains(got, `"deleted_business_messages"`) { if strings.Contains(got, `"type"`) {
t.Fatalf("expected canonical deleted_business_messages field, got %s", got) t.Fatalf("unexpected synthetic type field, got %s", got)
}
if strings.Contains(got, `"deleted_business_message"`) {
t.Fatalf("unexpected singular deleted_business_message field, got %s", got)
} }
} }
@@ -66,4 +152,66 @@ func TestUpdateShippingQueryIsNilWhenAbsent(t *testing.T) {
if update.ShippingQuery != nil { if update.ShippingQuery != nil {
t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery) t.Fatalf("expected ShippingQuery to be nil, got %+v", update.ShippingQuery)
} }
if update.Type != UpdateTypeUnknown {
t.Fatalf("expected UpdateTypeUnknown, got %q", update.Type)
}
}
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())
}
} }
+23 -13
View File
@@ -7,10 +7,11 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"time" "time"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
"git.nix13.pw/scuroneko/slog" "git.scuroneko.dev/scuroneko/slog"
) )
const ( const (
@@ -30,6 +31,8 @@ const (
UploaderThumbnailType UploaderFileType = "thumbnail" UploaderThumbnailType UploaderFileType = "thumbnail"
// UploaderStickerType is the multipart field name for sticker uploads. // UploaderStickerType is the multipart field name for sticker uploads.
UploaderStickerType UploaderFileType = "sticker" UploaderStickerType UploaderFileType = "sticker"
// UploaderCertificateType is the multipart field name for webhook certificate uploads.
UploaderCertificateType UploaderFileType = "certificate"
) )
// UploaderFileType represents the Telegram form field name for a file upload. // UploaderFileType represents the Telegram form field name for a file upload.
@@ -67,8 +70,12 @@ type Uploader struct {
// NewUploader creates a multipart uploader bound to an API client. // NewUploader creates a multipart uploader bound to an API client.
func NewUploader(api *API) *Uploader { func NewUploader(api *API) *Uploader {
logger := slog.CreateLogger().Level(utils.GetLoggerLevel()).Prefix("UPLOADER") logger := utils.CreateLogger("UPLOADER", utils.GetLoggerLevel())
logger.AddWriter(logger.CreateJsonStdoutWriter()) if api == nil {
logger.Errorln("api is nil")
_ = logger.Close()
return nil
}
return &Uploader{api, logger} return &Uploader{api, logger}
} }
@@ -80,8 +87,12 @@ func (u *Uploader) Close() error { return u.logger.Close() }
// See https://core.telegram.org/bots/api // See https://core.telegram.org/bots/api
func (u *Uploader) GetLogger() *slog.Logger { return u.logger } func (u *Uploader) GetLogger() *slog.Logger { return u.logger }
// UploaderRequest is a multipart file upload request to the Telegram API. // UploaderRequest is a low-level multipart upload request wrapper.
// Use NewUploaderRequest or NewUploaderRequestWithChatID to construct one. //
// Prefer method-specific helpers such as SendPhoto or SetWebhook. UploaderRequest
// is intended for advanced use cases where callers manage the method name, files,
// and request/response types themselves. In that sense it is an unsafe escape
// hatch compared with the typed uploader API.
type UploaderRequest[R, P any] struct { type UploaderRequest[R, P any] struct {
method string method string
files []UploaderFile files []UploaderFile
@@ -89,16 +100,17 @@ type UploaderRequest[R, P any] struct {
chatId int64 chatId int64
} }
// NewUploaderRequest creates a new multipart upload request with no associated chat ID. // NewUploaderRequest creates a low-level multipart upload request with no associated chat ID.
func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] { func NewUploaderRequest[R, P any](method string, params P, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0} return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: 0}
} }
// NewUploaderRequestWithChatID creates a new multipart upload request with an associated chat ID. // NewUploaderRequestWithChatID creates a low-level multipart upload request with an associated chat ID.
// The chat ID is used for per-chat rate limiting. // The chat ID is used for per-chat rate limiting.
func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] { func NewUploaderRequestWithChatID[R, P any](method string, params P, chatId int64, files ...UploaderFile) UploaderRequest[R, P] {
return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId} return UploaderRequest[R, P]{method: method, files: files, params: params, chatId: chatId}
} }
func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) { func (r UploaderRequest[R, P]) doRequest(ctx context.Context, up *Uploader) (R, error) {
var zero R var zero R
@@ -203,8 +215,7 @@ func (r UploaderRequest[R, P]) Do(up *Uploader) (R, error) {
return r.DoWithContext(context.Background(), up) return r.DoWithContext(context.Background(), up)
} }
// prepareMultipart builds a multipart form body from the given files and params. // Internal helper that builds a finalized multipart body from files and params.
// Params are encoded via utils.Encode. The writer boundary is finalized before returning.
func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) { func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, string, error) {
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
w := multipart.NewWriter(buf) w := multipart.NewWriter(buf)
@@ -237,10 +248,9 @@ func prepareMultipart[P any](files []UploaderFile, params P) (*bytes.Buffer, str
return buf, w.FormDataContentType(), nil return buf, w.FormDataContentType(), nil
} }
// uploaderTypeByExt infers the Telegram upload field name from a file extension. // Internal helper that infers an upload field name from a file extension.
// Falls back to UploaderDocumentType for unrecognized extensions.
func uploaderTypeByExt(filename string) UploaderFileType { func uploaderTypeByExt(filename string) UploaderFileType {
ext := filepath.Ext(filename) ext := strings.ToLower(filepath.Ext(filename))
switch ext { switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".bmp": case ".jpg", ".jpeg", ".png", ".webp", ".bmp":
return UploaderPhotoType return UploaderPhotoType
+24 -3
View File
@@ -44,8 +44,8 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
SetHTTPClient(client), SetHTTPClient(client),
) )
defer func() { defer func() {
if err := api.CloseApi(); err != nil { if err := api.Close(); err != nil {
t.Fatalf("CloseApi returned error: %v", err) t.Fatalf("Close returned error: %v", err)
} }
}() }()
@@ -57,7 +57,7 @@ func TestUploaderEncodesJSONFieldsAndLeavesAcceptEncodingToHTTPTransport(t *test
}() }()
msg, err := uploader.SendPhoto( msg, err := uploader.SendPhoto(
UploadPhotoP{ UploadPhoto{
ChatID: 42, ChatID: 42,
CaptionEntities: []MessageEntity{{ CaptionEntities: []MessageEntity{{
Type: MessageEntityBold, 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) { func readMultipartRequest(req *http.Request) (map[string]string, string, []byte, error) {
_, params, err := mime.ParseMediaType(req.Header.Get("Content-Type")) _, params, err := mime.ParseMediaType(req.Header.Get("Content-Type"))
if err != nil { if err != nil {
+134 -24
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// UploadPhotoP holds parameters for uploading a photo using the Uploader. import "context"
// UploadPhoto holds parameters for uploading a photo using the Uploader.
// See https://core.telegram.org/bots/api#sendphoto // See https://core.telegram.org/bots/api#sendphoto
type UploadPhotoP struct { type UploadPhoto struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -27,14 +29,24 @@ type UploadPhotoP struct {
// SendPhoto uploads a photo via multipart and sends it as a message. // SendPhoto uploads a photo via multipart and sends it as a message.
// file is the photo file to upload. // file is the photo file to upload.
// See https://core.telegram.org/bots/api#sendphoto // 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) req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file)
return req.Do(u) return req.Do(u)
} }
// UploadAudioP holds parameters for uploading an audio file using the Uploader. // SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendPhotoWithContext is the context-aware variant of SendPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendphoto
func (u *Uploader) SendPhotoWithContext(ctx context.Context, params UploadPhoto, file UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendPhoto", params, params.ChatID, file)
return req.DoWithContext(ctx, u)
}
// UploadAudio holds parameters for uploading an audio file using the Uploader.
// See https://core.telegram.org/bots/api#sendaudio // See https://core.telegram.org/bots/api#sendaudio
type UploadAudioP struct { type UploadAudio struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -61,14 +73,24 @@ type UploadAudioP struct {
// SendAudio uploads an audio file via multipart and sends it as a message. // SendAudio uploads an audio file via multipart and sends it as a message.
// files are the audio file(s) to upload (typically one file). // files are the audio file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendaudio // 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...) req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...)
return req.Do(u) return req.Do(u)
} }
// UploadDocumentP holds parameters for uploading a document using the Uploader. // SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendAudioWithContext is the context-aware variant of SendAudio.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendaudio
func (u *Uploader) SendAudioWithContext(ctx context.Context, params UploadAudio, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAudio", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadDocument holds parameters for uploading a document using the Uploader.
// See https://core.telegram.org/bots/api#senddocument // See https://core.telegram.org/bots/api#senddocument
type UploadDocumentP struct { type UploadDocument struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -92,14 +114,24 @@ type UploadDocumentP struct {
// SendDocument uploads a document via multipart and sends it as a message. // SendDocument uploads a document via multipart and sends it as a message.
// files are the document file(s) to upload (typically one file). // files are the document file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#senddocument // 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...) req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...)
return req.Do(u) return req.Do(u)
} }
// UploadVideoP holds parameters for uploading a video using the Uploader. // SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendDocumentWithContext is the context-aware variant of SendDocument.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#senddocument
func (u *Uploader) SendDocumentWithContext(ctx context.Context, params UploadDocument, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendDocument", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVideo holds parameters for uploading a video using the Uploader.
// See https://core.telegram.org/bots/api#sendvideo // See https://core.telegram.org/bots/api#sendvideo
type UploadVideoP struct { type UploadVideo struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -130,14 +162,24 @@ type UploadVideoP struct {
// SendVideo uploads a video via multipart and sends it as a message. // SendVideo uploads a video via multipart and sends it as a message.
// files are the video file(s) to upload (typically one file). // files are the video file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendvideo // 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...) req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...)
return req.Do(u) return req.Do(u)
} }
// UploadAnimationP holds parameters for uploading an animation using the Uploader. // SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVideoWithContext is the context-aware variant of SendVideo.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideo
func (u *Uploader) SendVideoWithContext(ctx context.Context, params UploadVideo, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideo", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadAnimation holds parameters for uploading an animation using the Uploader.
// See https://core.telegram.org/bots/api#sendanimation // See https://core.telegram.org/bots/api#sendanimation
type UploadAnimationP struct { type UploadAnimation struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -166,14 +208,24 @@ type UploadAnimationP struct {
// SendAnimation uploads an animation via multipart and sends it as a message. // SendAnimation uploads an animation via multipart and sends it as a message.
// files are the animation file(s) to upload (typically one file). // files are the animation file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendanimation // 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...) req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...)
return req.Do(u) return req.Do(u)
} }
// UploadVoiceP holds parameters for uploading a voice note using the Uploader. // SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendAnimationWithContext is the context-aware variant of SendAnimation.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendanimation
func (u *Uploader) SendAnimationWithContext(ctx context.Context, params UploadAnimation, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendAnimation", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVoice holds parameters for uploading a voice note using the Uploader.
// See https://core.telegram.org/bots/api#sendvoice // See https://core.telegram.org/bots/api#sendvoice
type UploadVoiceP struct { type UploadVoice struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -197,14 +249,24 @@ type UploadVoiceP struct {
// SendVoice uploads a voice note via multipart and sends it as a message. // SendVoice uploads a voice note via multipart and sends it as a message.
// files are the voice file(s) to upload (typically one file). // files are the voice file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendvoice // 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...) req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...)
return req.Do(u) return req.Do(u)
} }
// UploadVideoNoteP holds parameters for uploading a video note (rounded video) using the Uploader. // SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVoiceWithContext is the context-aware variant of SendVoice.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvoice
func (u *Uploader) SendVoiceWithContext(ctx context.Context, params UploadVoice, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVoice", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadVideoNote holds parameters for uploading a video note (rounded video) using the Uploader.
// See https://core.telegram.org/bots/api#sendvideonote // See https://core.telegram.org/bots/api#sendvideonote
type UploadVideoNoteP struct { type UploadVideoNote struct {
BusinessConnectionID string `json:"business_connection_id,omitempty"` BusinessConnectionID string `json:"business_connection_id,omitempty"`
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
MessageThreadID int `json:"message_thread_id,omitempty"` MessageThreadID int `json:"message_thread_id,omitempty"`
@@ -226,21 +288,69 @@ type UploadVideoNoteP struct {
// SendVideoNote uploads a video note via multipart and sends it as a message. // 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). // files are the video note file(s) to upload (typically one file).
// See https://core.telegram.org/bots/api#sendvideonote // 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...) req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...)
return req.Do(u) return req.Do(u)
} }
// UploadChatPhotoP holds parameters for uploading a chat photo using the Uploader. // SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// SendVideoNoteWithContext is the context-aware variant of SendVideoNote.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#sendvideonote
func (u *Uploader) SendVideoNoteWithContext(ctx context.Context, params UploadVideoNote, files ...UploaderFile) (Message, error) {
req := NewUploaderRequestWithChatID[Message]("sendVideoNote", params, params.ChatID, files...)
return req.DoWithContext(ctx, u)
}
// UploadChatPhoto holds parameters for uploading a chat photo using the Uploader.
// See https://core.telegram.org/bots/api#setchatphoto // See https://core.telegram.org/bots/api#setchatphoto
type UploadChatPhotoP struct { type UploadChatPhoto struct {
ChatID int64 `json:"chat_id"` ChatID int64 `json:"chat_id"`
} }
// SetChatPhoto uploads a new chat photo. // SetChatPhoto uploads a new chat photo.
// photo is the photo file to upload. // photo is the photo file to upload.
// See https://core.telegram.org/bots/api#setchatphoto // 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) req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
return req.Do(u) return req.Do(u)
} }
// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// SetChatPhotoWithContext is the context-aware variant of SetChatPhoto.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setchatphoto
func (u *Uploader) SetChatPhotoWithContext(ctx context.Context, params UploadChatPhoto, photo UploaderFile) (bool, error) {
req := NewUploaderRequestWithChatID[bool]("setChatPhoto", params, params.ChatID, photo)
return req.DoWithContext(ctx, u)
}
// 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 UploadSetWebhook struct {
URL string `json:"url"`
IPAddress string `json:"ip_address,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"`
}
// 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 UploadSetWebhook, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.Do(u)
}
// SetWebhookWithContext is the context-aware variant of SetWebhook.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#setwebhook
func (u *Uploader) SetWebhookWithContext(ctx context.Context, params UploadSetWebhook, certificate UploaderFile) (bool, error) {
req := NewUploaderRequest[bool]("setWebhook", params, certificate.SetType(UploaderCertificateType))
return req.DoWithContext(ctx, u)
}
+46 -12
View File
@@ -1,8 +1,10 @@
package tgapi package tgapi
// GetUserProfilePhotosP holds parameters for the GetUserProfilePhotos method. import "context"
// GetUserProfilePhotos holds parameters for the GetUserProfilePhotos method.
// See https://core.telegram.org/bots/api#getuserprofilephotos // See https://core.telegram.org/bots/api#getuserprofilephotos
type GetUserProfilePhotosP struct { type GetUserProfilePhotos struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Offset int `json:"offset,omitempty"` Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
@@ -10,14 +12,22 @@ type GetUserProfilePhotosP struct {
// GetUserProfilePhotos returns a list of profile pictures for a user. // GetUserProfilePhotos returns a list of profile pictures for a user.
// See https://core.telegram.org/bots/api#getuserprofilephotos // 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) req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params)
return req.Do(api) return req.Do(api)
} }
// GetUserProfileAudiosP holds parameters for the GetUserProfileAudios method. // 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 GetUserProfilePhotos) (UserProfilePhotos, error) {
req := NewRequest[UserProfilePhotos]("getUserProfilePhotos", params)
return req.DoWithContext(ctx, api)
}
// GetUserProfileAudios holds parameters for the GetUserProfileAudios method.
// See https://core.telegram.org/bots/api#getuserprofileaudios // See https://core.telegram.org/bots/api#getuserprofileaudios
type GetUserProfileAudiosP struct { type GetUserProfileAudios struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
Offset int `json:"offset,omitempty"` Offset int `json:"offset,omitempty"`
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
@@ -25,14 +35,22 @@ type GetUserProfileAudiosP struct {
// GetUserProfileAudios returns a list of profile audios for a user. // GetUserProfileAudios returns a list of profile audios for a user.
// See https://core.telegram.org/bots/api#getuserprofileaudios // 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) req := NewRequest[UserProfileAudios]("getUserProfileAudios", params)
return req.Do(api) return req.Do(api)
} }
// SetUserEmojiStatusP holds parameters for the SetUserEmojiStatus method. // 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 GetUserProfileAudios) (UserProfileAudios, error) {
req := NewRequest[UserProfileAudios]("getUserProfileAudios", params)
return req.DoWithContext(ctx, api)
}
// SetUserEmojiStatus holds parameters for the SetUserEmojiStatus method.
// See https://core.telegram.org/bots/api#setuseremojistatus // See https://core.telegram.org/bots/api#setuseremojistatus
type SetUserEmojiStatusP struct { type SetUserEmojiStatus struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
EmojiID string `json:"emoji_status_custom_emoji_id,omitempty"` EmojiID string `json:"emoji_status_custom_emoji_id,omitempty"`
ExpirationDate int `json:"emoji_status_expiration_date,omitempty"` ExpirationDate int `json:"emoji_status_expiration_date,omitempty"`
@@ -41,14 +59,22 @@ type SetUserEmojiStatusP struct {
// SetUserEmojiStatus sets a custom emoji status for a user. // SetUserEmojiStatus sets a custom emoji status for a user.
// Returns true on success. // Returns true on success.
// See https://core.telegram.org/bots/api#setuseremojistatus // 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) req := NewRequest[bool]("setUserEmojiStatus", params)
return req.Do(api) return req.Do(api)
} }
// GetUserGiftsP holds parameters for the GetUserGifts method. // 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 SetUserEmojiStatus) (bool, error) {
req := NewRequest[bool]("setUserEmojiStatus", params)
return req.DoWithContext(ctx, api)
}
// GetUserGifts holds parameters for the GetUserGifts method.
// See https://core.telegram.org/bots/api#getusergifts // See https://core.telegram.org/bots/api#getusergifts
type GetUserGiftsP struct { type GetUserGifts struct {
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"` ExcludeUnlimited bool `json:"exclude_unlimited,omitempty"`
ExcludeLimitedUpgradable bool `json:"exclude_limited_upgradable,omitempty"` ExcludeLimitedUpgradable bool `json:"exclude_limited_upgradable,omitempty"`
@@ -62,7 +88,15 @@ type GetUserGiftsP struct {
// GetUserGifts returns gifts owned by a user. // GetUserGifts returns gifts owned by a user.
// See https://core.telegram.org/bots/api#getusergifts // 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) req := NewRequest[OwnedGifts]("getUserGifts", params)
return req.Do(api) return req.Do(api)
} }
// GetUserGiftsWithContext is the context-aware variant of GetUserGifts.
// It executes the same request but uses ctx for cancellation and deadlines.
// See https://core.telegram.org/bots/api#getusergifts
func (api *API) GetUserGiftsWithContext(ctx context.Context, params 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"` AddedToAttachmentMenu *bool `json:"added_to_attachment_menu,omitempty"`
CanJoinGroups *bool `json:"can_join_groups,omitempty"` CanJoinGroups *bool `json:"can_join_groups,omitempty"`
CanReadAllGroupMessages *bool `json:"can_read_all_group_messages,omitempty"` CanReadAllGroupMessages *bool `json:"can_read_all_group_messages,omitempty"`
CanManageBots *bool `json:"can_manage_bots,omitempty"`
SupportsInlineQueries *bool `json:"supports_inline_queries,omitempty"` SupportsInlineQueries *bool `json:"supports_inline_queries,omitempty"`
CanConnectToBusiness *bool `json:"can_connect_to_business,omitempty"` CanConnectToBusiness *bool `json:"can_connect_to_business,omitempty"`
HasMainWebApp *bool `json:"has_main_web_app,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 ( import (
"strings" "strings"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
// Ptr returns a pointer to v. // Ptr returns a pointer to v.
@@ -53,11 +53,15 @@ func EscapePunctuation(s string) string {
return s return s
} }
// Version constants mirror values from the internal utils/version package.
const ( const (
// VersionString re-exports the module version string.
VersionString = utils.VersionString VersionString = utils.VersionString
VersionMajor = utils.VersionMajor // VersionMajor re-exports the module major version.
VersionMinor = utils.VersionMinor VersionMajor = utils.VersionMajor
VersionPatch = utils.VersionPatch // VersionMinor re-exports the module minor version.
VersionBeta = utils.VersionBeta VersionMinor = utils.VersionMinor
// VersionPatch re-exports the module patch version.
VersionPatch = utils.VersionPatch
// VersionBeta re-exports the module prerelease counter.
VersionBeta = utils.VersionBeta
) )
+5 -7
View File
@@ -9,6 +9,7 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
// ErrDropOverflow is returned when drop mode rejects a rate-limited request.
var ErrDropOverflow = errors.New("drop overflow limit") var ErrDropOverflow = errors.New("drop overflow limit")
// RateLimiter implements per-chat and global rate limiting with optional blocking. // RateLimiter implements per-chat and global rate limiting with optional blocking.
@@ -102,7 +103,7 @@ func (rl *RateLimiter) Wait(ctx context.Context, chatID int64) error {
return chatLimiter.Wait(ctx) return chatLimiter.Wait(ctx)
} }
// getGlobalLimiter returns the global limiter safely under read lock. // Internal helper that returns the global limiter under read lock.
func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter { func (rl *RateLimiter) getGlobalLimiter() *rate.Limiter {
rl.globalMu.RLock() rl.globalMu.RLock()
defer rl.globalMu.RUnlock() defer rl.globalMu.RUnlock()
@@ -190,8 +191,7 @@ func (rl *RateLimiter) Check(ctx context.Context, dropOverflow bool, chatID int6
return nil return nil
} }
// waitForGlobalUnlock blocks until global cooldown expires or context is done. // Internal helper that waits for the global cooldown to expire.
// Does not check token bucket — only cooldown.
func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error { func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
rl.globalMu.RLock() rl.globalMu.RLock()
until := rl.globalLockUntil until := rl.globalLockUntil
@@ -209,8 +209,7 @@ func (rl *RateLimiter) waitForGlobalUnlock(ctx context.Context) error {
} }
} }
// waitForChatUnlock blocks until the specified chat's cooldown expires or context is done. // Internal helper that waits for a chat-specific cooldown to expire.
// Does not check token bucket — only cooldown.
func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error { func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) error {
rl.chatMu.RLock() rl.chatMu.RLock()
until, ok := rl.chatLocks[chatID] until, ok := rl.chatLocks[chatID]
@@ -228,8 +227,7 @@ func (rl *RateLimiter) waitForChatUnlock(ctx context.Context, chatID int64) erro
} }
} }
// getChatLimiter returns the rate limiter for the given chat, creating it if needed. // Internal helper that returns or creates a per-chat limiter.
// Uses 1 request per second with burst of 1 — conservative for per-user limits.
func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter { func (rl *RateLimiter) getChatLimiter(chatID int64) *rate.Limiter {
rl.chatMu.Lock() rl.chatMu.Lock()
defer rl.chatMu.Unlock() defer rl.chatMu.Unlock()
+41
View File
@@ -0,0 +1,41 @@
package utils
import (
"context"
"errors"
"testing"
"time"
)
func TestRateLimiterCheckDropOverflowHonorsGlobalLock(t *testing.T) {
rl := NewRateLimiter()
rl.SetGlobalLock(1)
if err := rl.Check(context.Background(), true, 0); !errors.Is(err, ErrDropOverflow) {
t.Fatalf("expected ErrDropOverflow, got %v", err)
}
}
func TestRateLimiterChatLocksAreScopedPerChat(t *testing.T) {
rl := NewRateLimiter()
rl.SetChatLock(42, 1)
if rl.Allow(42) {
t.Fatal("expected locked chat to be rejected")
}
if !rl.Allow(7) {
t.Fatal("expected unrelated chat to remain allowed")
}
}
func TestRateLimiterGlobalWaitRespectsContextCancellation(t *testing.T) {
rl := NewRateLimiter()
rl.SetGlobalLock(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer cancel()
if err := rl.GlobalWait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected DeadlineExceeded, got %v", err)
}
}
+1 -2
View File
@@ -3,7 +3,6 @@ package utils
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"mime/multipart" "mime/multipart"
"reflect" "reflect"
"slices" "slices"
@@ -110,6 +109,6 @@ func writeMultipartValue(w *multipart.Writer, fieldName string, value []byte) er
if err != nil { if err != nil {
return err return err
} }
_, err = io.Copy(fw, strings.NewReader(string(value))) _, err = fw.Write(value)
return err return err
} }
+3 -4
View File
@@ -6,8 +6,8 @@ import (
"mime/multipart" "mime/multipart"
"testing" "testing"
"git.nix13.pw/scuroneko/laniakea/tgapi" "git.scuroneko.dev/scuroneko/laniakea/tgapi"
"git.nix13.pw/scuroneko/laniakea/utils" "git.scuroneko.dev/scuroneko/laniakea/utils"
) )
type multipartEncodeParams struct { type multipartEncodeParams struct {
@@ -19,10 +19,9 @@ type multipartEncodeParams struct {
} }
func TestEncodeMultipartJSONFields(t *testing.T) { func TestEncodeMultipartJSONFields(t *testing.T) {
threadID := 7
params := multipartEncodeParams{ params := multipartEncodeParams{
ChatID: 42, ChatID: 42,
MessageThreadID: &threadID, MessageThreadID: new(7),
ReplyMarkup: &tgapi.ReplyMarkup{ ReplyMarkup: &tgapi.ReplyMarkup{
InlineKeyboard: [][]tgapi.InlineKeyboardButton{{ InlineKeyboard: [][]tgapi.InlineKeyboardButton{{
{Text: "A", CallbackData: "b"}, {Text: "A", CallbackData: "b"},

Some files were not shown because too many files have changed in this diff Show More