telegram_bot_engine 0.3.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/LICENSE +21 -0
- data/README.md +67 -0
- data/app/controllers/telegram_bot_engine/admin/allowlist_controller.rb +5 -2
- data/app/controllers/telegram_bot_engine/admin/bots_controller.rb +73 -0
- data/app/controllers/telegram_bot_engine/admin/dashboard_controller.rb +6 -0
- data/app/controllers/telegram_bot_engine/admin/events_controller.rb +3 -0
- data/app/controllers/telegram_bot_engine/admin/subscriptions_controller.rb +3 -1
- data/app/jobs/telegram_bot_engine/application_job.rb +10 -0
- data/app/jobs/telegram_bot_engine/delivery_job.rb +15 -8
- data/app/models/telegram_bot_engine/allowed_user.rb +7 -1
- data/app/models/telegram_bot_engine/bot.rb +177 -0
- data/app/models/telegram_bot_engine/event.rb +8 -3
- data/app/models/telegram_bot_engine/subscription.rb +14 -1
- data/app/views/telegram_bot_engine/admin/allowlist/index.html.erb +17 -1
- data/app/views/telegram_bot_engine/admin/bots/edit.html.erb +69 -0
- data/app/views/telegram_bot_engine/admin/bots/index.html.erb +70 -0
- data/app/views/telegram_bot_engine/admin/bots/new.html.erb +53 -0
- data/app/views/telegram_bot_engine/admin/dashboard/show.html.erb +40 -1
- data/app/views/telegram_bot_engine/admin/events/index.html.erb +13 -2
- data/app/views/telegram_bot_engine/admin/layouts/application.html.erb +2 -0
- data/app/views/telegram_bot_engine/admin/subscriptions/index.html.erb +22 -1
- data/config/routes.rb +3 -0
- data/db/migrate/004_create_telegram_bot_engine_bots.rb +21 -0
- data/db/migrate/005_add_bot_to_telegram_bot_engine_subscriptions.rb +38 -0
- data/db/migrate/006_add_bot_to_telegram_bot_engine_allowed_users.rb +32 -0
- data/db/migrate/007_add_bot_to_telegram_bot_engine_events.rb +11 -0
- data/db/migrate/008_add_webhook_id_to_telegram_bot_engine_bots.rb +29 -0
- data/lib/telegram_bot_engine/authorizer.rb +10 -6
- data/lib/telegram_bot_engine/configuration.rb +8 -1
- data/lib/telegram_bot_engine/dispatch.rb +73 -0
- data/lib/telegram_bot_engine/registry.rb +39 -0
- data/lib/telegram_bot_engine/subscriber_commands.rb +17 -7
- data/lib/telegram_bot_engine/version.rb +1 -1
- data/lib/telegram_bot_engine/webhook_registrar.rb +32 -0
- data/lib/telegram_bot_engine.rb +24 -9
- metadata +31 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d27dbf5fec7cb1c9192332eca34672baea537c79d75ae46df0f6b7cfd13e576a
|
|
4
|
+
data.tar.gz: 188cc7fa3fb070f8b090f3bcc4b91cddbca17dbbe94354186752c612ba7e0d3d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe5cc14a578d95d084f9ca21adee8b201fc3763100d0e32814e15938c31e1a0e8cbb2b25813636457501f0ea6ed021ed6bf308176cf4a8bfcb57be284c901297
|
|
7
|
+
data.tar.gz: 1e3bac86d76c1a1e871eee18d68b884c41ced5500ad718c5eb9fa98b18a9253dbfd547533825eeebc291c3edf4b88060f55d4b9b6148747607a435b71a9ae604
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
While the gem is pre-1.0, minor bumps may widen the API in backward-compatible ways.
|
|
8
|
+
|
|
9
|
+
## [0.6.0] - 2026-06-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Inbound dispatcher** (`TelegramBotEngine::Dispatch`) — a single Rack endpoint, mounted once at `config.webhook_mount_path`, that routes `POST <mount>/:webhook_id` to the resolved bot's `UpdatesController`. Validates the `X-Telegram-Bot-Api-Secret-Token` header with a constant-time compare (which `telegram-bot` 0.16 does not do).
|
|
13
|
+
- **Webhook registrar** (`TelegramBotEngine::WebhookRegistrar`) — registers/removes each bot's Telegram webhook with a per-bot secret; auto-(un)registers on `Bot` save/destroy when `config.webhook_base_url` is set.
|
|
14
|
+
- Configuration keys: `webhook_base_url`, `webhook_mount_path`, `dispatch_controller`, `auto_register_webhooks`.
|
|
15
|
+
- `webhook_id` (non-secret routing id, carried in the URL) and `webhook_secret` (bearer credential, header only) on `Bot`, with backfill for existing rows.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
- `DeliveryJob` now inherits an engine-local `TelegramBotEngine::ApplicationJob` instead of the host app's `::ApplicationJob`, so the engine installs cleanly into API-only or non-standard hosts that don't define one.
|
|
19
|
+
|
|
20
|
+
## [0.5.0] - 2026-06-25
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **Per-bot subscribers and allowlist** — `Subscription` and `AllowedUser` are scoped to a bot via `bot_id` and the `for_bot` scope; composite and partial-unique indexes keep per-bot and global rows distinct.
|
|
24
|
+
- **Bots admin UI** — index / new / edit, plus a bot column across the subscriptions, events, and allowlist views.
|
|
25
|
+
- Bot-aware `Authorizer` and `SubscriberCommands` so inbound `/start`·`/stop` scope to the bot the update arrived for.
|
|
26
|
+
|
|
27
|
+
## [0.4.0] - 2026-06-25
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **`Bot` model** (`telegram_bot_engine_bots`) — a first-class, persisted Telegram bot identity, with an auto-seeded default-bot anchor for backward compatibility.
|
|
31
|
+
- **Client `Registry`** — a per-bot `Telegram::Bot::Client` cache keyed by bot id (token-rotation safe).
|
|
32
|
+
- **Bot-aware delivery** — `broadcast` and `notify` accept a `bot:` argument and deliver through that bot's own client; omitting `bot:` targets the default bot, preserving existing single-bot behavior.
|
|
33
|
+
|
|
34
|
+
## [0.3.4] and earlier
|
|
35
|
+
|
|
36
|
+
Single-bot baseline: subscriber management, authorization, broadcasting, an event log, and an admin UI on top of the [`telegram-bot`](https://github.com/telegram-bot-rb/telegram-bot) gem.
|
|
37
|
+
|
|
38
|
+
[0.6.0]: https://github.com/landovsky/telegram-bot-channels-gem/releases/tag/v0.6.0
|
|
39
|
+
[0.5.0]: https://github.com/landovsky/telegram-bot-channels-gem/releases/tag/v0.5.0
|
|
40
|
+
[0.4.0]: https://github.com/landovsky/telegram-bot-channels-gem/releases/tag/v0.4.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tomáš Landovský
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -91,6 +91,8 @@ Rails.application.routes.draw do
|
|
|
91
91
|
end
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
+
> Running more than one bot? Replace `telegram_webhook` with the engine's inbound dispatcher — see [Multiple bots](#multiple-bots).
|
|
95
|
+
|
|
94
96
|
### Set webhook
|
|
95
97
|
|
|
96
98
|
These rake tasks come from the `telegram-bot` gem:
|
|
@@ -183,6 +185,71 @@ TelegramBotEngine::Event.purge_old!
|
|
|
183
185
|
|
|
184
186
|
Disable event logging entirely with `config.event_logging = false`.
|
|
185
187
|
|
|
188
|
+
## Multiple bots
|
|
189
|
+
|
|
190
|
+
The engine manages many Telegram bots from one Rails host. Everything keyed by a Telegram bot identity — subscribers, allowlist, delivery, and inbound routing — is scoped per bot. Existing single-bot setups keep working unchanged: every call without a `bot:` argument targets the **default bot**, which is seeded automatically from your existing `telegram.bot` credentials (or `TELEGRAM_BOT_TOKEN`).
|
|
191
|
+
|
|
192
|
+
### The Bot model
|
|
193
|
+
|
|
194
|
+
Each bot is a persisted `TelegramBotEngine::Bot` record (token, slug, and per-bot webhook credentials). Manage them in the admin UI, a seed, or the console:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
TelegramBotEngine::Bot.create!(
|
|
198
|
+
name: "Support bot",
|
|
199
|
+
slug: "support",
|
|
200
|
+
token: Rails.application.credentials.dig(:telegram, :support_bot, :token),
|
|
201
|
+
active: true
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
TelegramBotEngine::Bot.default # the back-compat anchor (auto-seeded on first use)
|
|
205
|
+
TelegramBotEngine::Bot.resolve("support") # look up by slug
|
|
206
|
+
bot.client # this bot's memoized Telegram::Bot::Client
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`webhook_id` (the non-secret id that appears in the webhook URL) and `webhook_secret` (the bearer credential, sent only in the `X-Telegram-Bot-Api-Secret-Token` header) are generated automatically.
|
|
210
|
+
|
|
211
|
+
### Bot-aware delivery
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
support = TelegramBotEngine::Bot.resolve("support")
|
|
215
|
+
|
|
216
|
+
# Broadcast / notify through a specific bot's own client
|
|
217
|
+
TelegramBotEngine.broadcast("Support is online", bot: support)
|
|
218
|
+
TelegramBotEngine.notify(chat_id: 123456789, text: "Ticket updated", bot: support)
|
|
219
|
+
|
|
220
|
+
# Omit bot: to target the default bot (unchanged single-bot behavior)
|
|
221
|
+
TelegramBotEngine.broadcast("Deployment complete!")
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Subscribers and allowlist entries are scoped per bot: a user who blocks the support bot is deactivated only for that bot, and a per-bot allowlist entry never escalates into a global one.
|
|
225
|
+
|
|
226
|
+
### Inbound updates for multiple bots
|
|
227
|
+
|
|
228
|
+
`telegram-bot`'s `telegram_webhook` route serves a single bot. For multiple bots, mount the engine's inbound dispatcher **once** — it resolves the target bot from the URL, validates the secret-token header, then hands off to your `UpdatesController`:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
# config/initializers/telegram_bot_engine.rb
|
|
232
|
+
TelegramBotEngine.configure do |config|
|
|
233
|
+
config.webhook_base_url = "https://app.example.com" # host public HTTPS base
|
|
234
|
+
config.webhook_mount_path = "/telegram/bot" # must match the mount below
|
|
235
|
+
config.dispatch_controller = "TelegramWebhookController" # your UpdatesController (incl. SubscriberCommands)
|
|
236
|
+
config.auto_register_webhooks = true # (un)register webhooks on Bot save/destroy
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# config/routes.rb
|
|
242
|
+
mount TelegramBotEngine::Dispatch, at: "/telegram/bot"
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
With `webhook_base_url` set and `auto_register_webhooks` enabled, saving an active `Bot` registers its Telegram webhook (`<base_url>/telegram/bot/<webhook_id>`, secret in the header) and deactivating it removes the webhook — no manual `set_webhook` per bot. You can also (re)register explicitly:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
TelegramBotEngine::WebhookRegistrar.register(TelegramBotEngine::Bot.resolve("support"))
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Unlike `telegram-bot` 0.16, the dispatcher validates the `X-Telegram-Bot-Api-Secret-Token` header (constant-time) on every inbound request, so only Telegram can drive your bots.
|
|
252
|
+
|
|
186
253
|
## Requirements
|
|
187
254
|
|
|
188
255
|
- Ruby >= 3.3.0
|
|
@@ -6,7 +6,9 @@ module TelegramBotEngine
|
|
|
6
6
|
before_action :require_database_mode!
|
|
7
7
|
|
|
8
8
|
def index
|
|
9
|
-
@
|
|
9
|
+
@bots = Bot.order(:name)
|
|
10
|
+
@allowed_users = AllowedUser.includes(:bot).order(:username)
|
|
11
|
+
@allowed_users = @allowed_users.where(bot_id: params[:bot_id]) if params[:bot_id].present?
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def create
|
|
@@ -25,7 +27,8 @@ module TelegramBotEngine
|
|
|
25
27
|
private
|
|
26
28
|
|
|
27
29
|
def allowed_user_params
|
|
28
|
-
|
|
30
|
+
# bot_id blank ⇒ a global allow entry that applies to every bot (docs/0001 §3.5).
|
|
31
|
+
params.require(:allowed_user).permit(:username, :note, :bot_id)
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def require_database_mode!
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TelegramBotEngine
|
|
4
|
+
module Admin
|
|
5
|
+
# CRUD + token rotation + webhook status for bots-as-data (docs/0001 §3.8).
|
|
6
|
+
class BotsController < BaseController
|
|
7
|
+
before_action :set_bot, only: %i[edit update destroy rotate_token]
|
|
8
|
+
|
|
9
|
+
def index
|
|
10
|
+
@bots = Bot.order(:name)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def new
|
|
14
|
+
@bot = Bot.new(active: true)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def create
|
|
18
|
+
@bot = Bot.new(create_params)
|
|
19
|
+
if @bot.save
|
|
20
|
+
redirect_to admin_bots_path, notice: "Bot \"#{@bot.name}\" created."
|
|
21
|
+
else
|
|
22
|
+
flash.now[:alert] = @bot.errors.full_messages.to_sentence
|
|
23
|
+
render :new, status: :unprocessable_entity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def edit; end
|
|
28
|
+
|
|
29
|
+
def update
|
|
30
|
+
# Token is never touched here — it is rotated through #rotate_token so a normal
|
|
31
|
+
# edit can't blank it. See update_params.
|
|
32
|
+
if @bot.update(update_params)
|
|
33
|
+
redirect_to admin_bots_path, notice: "Bot \"#{@bot.name}\" updated."
|
|
34
|
+
else
|
|
35
|
+
flash.now[:alert] = @bot.errors.full_messages.to_sentence
|
|
36
|
+
render :edit, status: :unprocessable_entity
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def destroy
|
|
41
|
+
if @bot.default?
|
|
42
|
+
redirect_to admin_bots_path, alert: "Cannot delete the default bot. Promote another bot to default first."
|
|
43
|
+
else
|
|
44
|
+
@bot.destroy
|
|
45
|
+
redirect_to admin_bots_path, notice: "Bot \"#{@bot.name}\" deleted."
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rotate_token
|
|
50
|
+
if params[:token].present?
|
|
51
|
+
@bot.update!(token: params[:token])
|
|
52
|
+
redirect_to admin_bots_path, notice: "Token rotated for \"#{@bot.name}\"."
|
|
53
|
+
else
|
|
54
|
+
redirect_to edit_admin_bot_path(@bot), alert: "Enter a new token to rotate."
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def set_bot
|
|
61
|
+
@bot = Bot.find(params[:id])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def create_params
|
|
65
|
+
params.require(:bot).permit(:name, :slug, :purpose, :token, :active, :default)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def update_params
|
|
69
|
+
params.require(:bot).permit(:name, :slug, :purpose, :active, :default)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -4,9 +4,15 @@ module TelegramBotEngine
|
|
|
4
4
|
module Admin
|
|
5
5
|
class DashboardController < BaseController
|
|
6
6
|
def show
|
|
7
|
+
@bots = Bot.order(:name)
|
|
8
|
+
@bot_active_counts = @bots.index_with { |bot| Subscription.active.for_bot(bot).count }
|
|
9
|
+
|
|
7
10
|
@total_subscriptions = Subscription.count
|
|
8
11
|
@active_subscriptions = Subscription.active.count
|
|
9
12
|
@inactive_subscriptions = @total_subscriptions - @active_subscriptions
|
|
13
|
+
|
|
14
|
+
# Legacy single-bot fallback (docs/0001 §3.8): only shown until bots-as-data
|
|
15
|
+
# rows exist, so pre-multi-bot installs still render their bot identity.
|
|
10
16
|
@bot_username = bot_username
|
|
11
17
|
end
|
|
12
18
|
|
|
@@ -20,10 +20,12 @@ module TelegramBotEngine
|
|
|
20
20
|
return
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
@bots = Bot.order(:name)
|
|
23
24
|
@events = Event.recent
|
|
24
25
|
@events = @events.by_type(params[:type]) if params[:type].present?
|
|
25
26
|
@events = @events.by_action(params[:action_name]) if params[:action_name].present?
|
|
26
27
|
@events = @events.by_chat_id(params[:chat_id]) if params[:chat_id].present?
|
|
28
|
+
@events = @events.by_bot(params[:bot_id]) if params[:bot_id].present?
|
|
27
29
|
|
|
28
30
|
@total_count = @events.count
|
|
29
31
|
@page = [params[:page].to_i, 1].max
|
|
@@ -39,6 +41,7 @@ module TelegramBotEngine
|
|
|
39
41
|
id: e.id,
|
|
40
42
|
event_type: e.event_type,
|
|
41
43
|
action: e.action,
|
|
44
|
+
bot_id: e.bot_id,
|
|
42
45
|
chat_id: e.chat_id,
|
|
43
46
|
username: e.username,
|
|
44
47
|
details: e.details,
|
|
@@ -4,7 +4,9 @@ module TelegramBotEngine
|
|
|
4
4
|
module Admin
|
|
5
5
|
class SubscriptionsController < BaseController
|
|
6
6
|
def index
|
|
7
|
-
@
|
|
7
|
+
@bots = Bot.order(:name)
|
|
8
|
+
@subscriptions = Subscription.includes(:bot).order(created_at: :desc)
|
|
9
|
+
@subscriptions = @subscriptions.where(bot_id: params[:bot_id]) if params[:bot_id].present?
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def update
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TelegramBotEngine
|
|
4
|
+
# Engine-local base job so DeliveryJob doesn't depend on the host app defining a
|
|
5
|
+
# top-level ::ApplicationJob — an API-only or non-standard host may not have one,
|
|
6
|
+
# which would otherwise raise NameError when the job is loaded. Inheriting
|
|
7
|
+
# ActiveJob::Base directly keeps the engine self-contained.
|
|
8
|
+
class ApplicationJob < ActiveJob::Base
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -5,8 +5,14 @@ module TelegramBotEngine
|
|
|
5
5
|
queue_as :default
|
|
6
6
|
retry_on StandardError, wait: :polynomially_longer, attempts: 3
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
# Delivers through the chosen bot's own client (resolved via the registry),
|
|
9
|
+
# not the process-global Telegram.bot (docs/0001 §3.3). `bot_id` is resolved
|
|
10
|
+
# to a Bot record; a missing/blank id falls back to the default bot so an
|
|
11
|
+
# in-flight job enqueued before a rollback still delivers.
|
|
12
|
+
def perform(bot_id, chat_id, text, options = {})
|
|
13
|
+
bot = TelegramBotEngine::Bot.find_by(id: bot_id) || TelegramBotEngine::Bot.default
|
|
14
|
+
|
|
15
|
+
TelegramBotEngine.client_for(bot).send_message(
|
|
10
16
|
chat_id: chat_id,
|
|
11
17
|
text: text,
|
|
12
18
|
**options.symbolize_keys
|
|
@@ -14,18 +20,19 @@ module TelegramBotEngine
|
|
|
14
20
|
|
|
15
21
|
TelegramBotEngine::Event.log(
|
|
16
22
|
event_type: "delivery", action: "delivered",
|
|
17
|
-
chat_id: chat_id,
|
|
18
|
-
details: { text_preview: text.to_s[0, 100] }
|
|
23
|
+
chat_id: chat_id, bot_id: bot.id,
|
|
24
|
+
details: { bot: bot.slug, text_preview: text.to_s[0, 100] }
|
|
19
25
|
)
|
|
20
26
|
rescue Telegram::Bot::Forbidden
|
|
21
|
-
# User blocked
|
|
22
|
-
|
|
27
|
+
# User blocked this bot - deactivate only *this bot's* subscription for the chat,
|
|
28
|
+
# not the chat's subscriptions to other bots (docs/0001 §3.4).
|
|
29
|
+
TelegramBotEngine::Subscription.for_bot(bot).where(chat_id: chat_id).update_all(active: false)
|
|
23
30
|
Rails.logger.info("[TelegramBotEngine] Deactivated subscription for blocked chat: #{chat_id}")
|
|
24
31
|
|
|
25
32
|
TelegramBotEngine::Event.log(
|
|
26
33
|
event_type: "delivery", action: "blocked",
|
|
27
|
-
chat_id: chat_id,
|
|
28
|
-
details: { text_preview: text.to_s[0, 100] }
|
|
34
|
+
chat_id: chat_id, bot_id: bot&.id,
|
|
35
|
+
details: { bot: bot&.slug, text_preview: text.to_s[0, 100] }
|
|
29
36
|
)
|
|
30
37
|
end
|
|
31
38
|
end
|
|
@@ -4,6 +4,12 @@ module TelegramBotEngine
|
|
|
4
4
|
class AllowedUser < ActiveRecord::Base
|
|
5
5
|
self.table_name = "telegram_bot_engine_allowed_users"
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
belongs_to :bot, class_name: "TelegramBotEngine::Bot", optional: true
|
|
8
|
+
|
|
9
|
+
# Entries authorizing inbound commands for a bot: a nil bot_id is a GLOBAL allow
|
|
10
|
+
# that applies to every bot, layered with that bot's own entries (docs/0001 §3.5).
|
|
11
|
+
scope :for_bot, ->(bot) { bot ? where(bot_id: [nil, bot.id]) : all }
|
|
12
|
+
|
|
13
|
+
validates :username, presence: true, uniqueness: { scope: :bot_id }
|
|
8
14
|
end
|
|
9
15
|
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module TelegramBotEngine
|
|
6
|
+
# A Telegram bot identity, persisted as data (docs/0001 §3.1). This is the gem's
|
|
7
|
+
# next first-class entity beside Subscription/AllowedUser/Event: everything keyed
|
|
8
|
+
# by a *Telegram bot identity* lives here; only source/channel routing keyed by an
|
|
9
|
+
# *app source name* stays in the host app.
|
|
10
|
+
class Bot < ActiveRecord::Base
|
|
11
|
+
self.table_name = "telegram_bot_engine_bots"
|
|
12
|
+
|
|
13
|
+
# `token` becomes encrypted at rest via ActiveRecord Encryption once the host app
|
|
14
|
+
# provisions encryption keys (docs/0001 §6). Until keys are live it is seeded from
|
|
15
|
+
# ENV as a plain column; the public API is identical either way. To enable:
|
|
16
|
+
# encrypts :token
|
|
17
|
+
|
|
18
|
+
# Destroying a bot removes its own subscribers and allow-entries so they can't
|
|
19
|
+
# leak: nullifying subscriptions would silently fold them into the default
|
|
20
|
+
# audience, and nullifying allow-entries would escalate a per-bot allow to a
|
|
21
|
+
# global one. Global entries (nil bot_id) belong to no bot and are untouched.
|
|
22
|
+
has_many :subscriptions, class_name: "TelegramBotEngine::Subscription", dependent: :destroy
|
|
23
|
+
has_many :allowed_users, class_name: "TelegramBotEngine::AllowedUser", dependent: :destroy
|
|
24
|
+
|
|
25
|
+
scope :active, -> { where(active: true) }
|
|
26
|
+
|
|
27
|
+
# Transient: set on the implicit ensure_default! seed so a no-`bot:` broadcast/notify
|
|
28
|
+
# never triggers a synchronous setWebhook from inside the back-compat path.
|
|
29
|
+
attr_accessor :skip_webhook_autoregister
|
|
30
|
+
|
|
31
|
+
validates :name, presence: true
|
|
32
|
+
validates :slug, presence: true, uniqueness: true
|
|
33
|
+
validates :token, presence: true
|
|
34
|
+
# webhook_secret is the bearer credential — it goes ONLY in the X-Telegram-Bot-Api-Secret-Token
|
|
35
|
+
# header, never in a URL. webhook_id is the stable, NON-secret routing id that appears in the
|
|
36
|
+
# webhook URL path (so request/SQL logs never leak the secret). See docs/0001 §3.1/§3.6/§3.7.
|
|
37
|
+
validates :webhook_secret, presence: true, uniqueness: true
|
|
38
|
+
validates :webhook_id, presence: true, uniqueness: true
|
|
39
|
+
# "exactly one row true" has two halves: at most one (demote_other_defaults) and
|
|
40
|
+
# at least one. Without the second half an admin could uncheck "Default" on the
|
|
41
|
+
# only default bot, leaving zero defaults — which makes every no-`bot:` broadcast/
|
|
42
|
+
# notify call (the mandatory back-compat path) raise once Bot.default re-seeds onto
|
|
43
|
+
# the still-present "default" slug. So refuse to clear the last default.
|
|
44
|
+
validate :keep_at_least_one_default
|
|
45
|
+
|
|
46
|
+
before_validation :ensure_inbound_credentials, on: :create
|
|
47
|
+
before_save :demote_other_defaults, if: -> { default? && will_save_change_to_default? }
|
|
48
|
+
after_save :invalidate_client_cache
|
|
49
|
+
after_save :sync_webhook
|
|
50
|
+
after_destroy :invalidate_client_cache
|
|
51
|
+
after_destroy :remove_webhook
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# The default Bot — the back-compat anchor every no-`bot:` call site resolves to.
|
|
55
|
+
# Seeds one on first use so the host's existing single-bot config keeps working.
|
|
56
|
+
def default
|
|
57
|
+
find_by(default: true) || ensure_default!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A Bot by its stable slug handle (e.g. "default", "assistant").
|
|
61
|
+
def resolve(slug)
|
|
62
|
+
find_by!(slug: slug)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Idempotently seed the default bot from the host's existing single-bot config/ENV
|
|
66
|
+
# so nothing breaks on first boot (docs/0001 §4, §6). Safe to call repeatedly.
|
|
67
|
+
def ensure_default!
|
|
68
|
+
existing = find_by(default: true)
|
|
69
|
+
return existing if existing
|
|
70
|
+
|
|
71
|
+
bot = new(
|
|
72
|
+
name: default_seed_name,
|
|
73
|
+
slug: "default",
|
|
74
|
+
token: default_seed_token,
|
|
75
|
+
active: true,
|
|
76
|
+
default: true
|
|
77
|
+
)
|
|
78
|
+
# This is a lazy seed reachable from the back-compat broadcast/notify path; keep it
|
|
79
|
+
# free of synchronous Telegram I/O. The host registers the default's webhook explicitly
|
|
80
|
+
# (admin save, or WebhookRegistrar.register(Bot.default)) — see docs/0001 §3.6.
|
|
81
|
+
bot.skip_webhook_autoregister = true
|
|
82
|
+
bot.save!
|
|
83
|
+
bot
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def default_seed_token
|
|
89
|
+
ENV["TELEGRAM_BOT_TOKEN"].presence ||
|
|
90
|
+
telegram_default_config[:token].presence ||
|
|
91
|
+
raise(ArgumentError, "Cannot seed the default Bot: set ENV['TELEGRAM_BOT_TOKEN'] " \
|
|
92
|
+
"or configure telegram.bot in the host app before broadcasting.")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def default_seed_name
|
|
96
|
+
telegram_default_config[:username].presence || "default"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def telegram_default_config
|
|
100
|
+
(Telegram.bots_config[:default] || {}).to_h.symbolize_keys
|
|
101
|
+
rescue StandardError
|
|
102
|
+
{}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The memoized Telegram::Bot::Client for this bot's token, resolved via the registry.
|
|
107
|
+
def client
|
|
108
|
+
TelegramBotEngine.client_for(self)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def ensure_inbound_credentials
|
|
114
|
+
self.webhook_secret = SecureRandom.hex(16) if webhook_secret.blank?
|
|
115
|
+
self.webhook_id = SecureRandom.hex(16) if webhook_id.blank?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# "exactly one row true": promoting a bot to default demotes whoever held it.
|
|
119
|
+
def demote_other_defaults
|
|
120
|
+
self.class.where(default: true).where.not(id: id).update_all(default: false)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Reject demoting the only default bot — promote another first (closes the UI hole
|
|
124
|
+
# where unchecking "Default" then deleting would strand the back-compat anchor).
|
|
125
|
+
def keep_at_least_one_default
|
|
126
|
+
return if default? # staying/becoming default is always fine
|
|
127
|
+
return if new_record? # creating a non-default bot is fine
|
|
128
|
+
return unless default_changed? # an edit that doesn't touch `default` is fine
|
|
129
|
+
return if self.class.where(default: true).where.not(id: id).exists?
|
|
130
|
+
|
|
131
|
+
errors.add(:default, "can't be unset on the only default bot — promote another bot to default first")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def invalidate_client_cache
|
|
135
|
+
TelegramBotEngine::Registry.invalidate(self)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Auto-(un)register the Telegram webhook on save (docs/0001 §3.6): an active bot gets a
|
|
139
|
+
# webhook, an inactive one has it removed. Best-effort — a Telegram hiccup is logged, never
|
|
140
|
+
# raised, so an admin save can't 500. Gated on a configured base_url so the gem's own suite
|
|
141
|
+
# (and any host without webhook config) makes no network calls.
|
|
142
|
+
def sync_webhook
|
|
143
|
+
return unless webhook_autoregister?
|
|
144
|
+
|
|
145
|
+
if active?
|
|
146
|
+
TelegramBotEngine::WebhookRegistrar.register(self)
|
|
147
|
+
else
|
|
148
|
+
TelegramBotEngine::WebhookRegistrar.remove(self)
|
|
149
|
+
end
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
log_webhook_failure(e)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def remove_webhook
|
|
155
|
+
return unless webhook_autoregister?
|
|
156
|
+
|
|
157
|
+
TelegramBotEngine::WebhookRegistrar.remove(self)
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
log_webhook_failure(e)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def webhook_autoregister?
|
|
163
|
+
return false if skip_webhook_autoregister
|
|
164
|
+
|
|
165
|
+
TelegramBotEngine.config.webhook_base_url.present? &&
|
|
166
|
+
TelegramBotEngine.config.auto_register_webhooks
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def log_webhook_failure(error)
|
|
170
|
+
TelegramBotEngine::Event.log(
|
|
171
|
+
event_type: "webhook", action: "register_failed",
|
|
172
|
+
bot_id: id, details: { bot: slug, error: error.message }
|
|
173
|
+
)
|
|
174
|
+
Rails.logger.warn("[TelegramBotEngine] webhook sync failed for bot #{slug}: #{error.message}") if defined?(Rails)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -8,22 +8,27 @@ module TelegramBotEngine
|
|
|
8
8
|
scope :by_type, ->(type) { where(event_type: type) if type.present? }
|
|
9
9
|
scope :by_action, ->(action) { where(action: action) if action.present? }
|
|
10
10
|
scope :by_chat_id, ->(chat_id) { where(chat_id: chat_id) if chat_id.present? }
|
|
11
|
+
scope :by_bot, ->(bot_id) { where(bot_id: bot_id) if bot_id.present? }
|
|
11
12
|
scope :since, ->(time) { where("created_at >= ?", time) }
|
|
12
13
|
|
|
13
14
|
validates :event_type, presence: true
|
|
14
15
|
validates :action, presence: true
|
|
15
16
|
|
|
16
|
-
def self.log(event_type:, action:, chat_id: nil, username: nil, details: {})
|
|
17
|
+
def self.log(event_type:, action:, chat_id: nil, username: nil, bot_id: nil, details: {})
|
|
17
18
|
return unless TelegramBotEngine.config.event_logging
|
|
18
19
|
return unless table_exists?
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
attrs = {
|
|
21
22
|
event_type: event_type,
|
|
22
23
|
action: action,
|
|
23
24
|
chat_id: chat_id,
|
|
24
25
|
username: username,
|
|
25
26
|
details: details
|
|
26
|
-
|
|
27
|
+
}
|
|
28
|
+
# Guard the "code references bot_id before the column is migrated in" boot crash
|
|
29
|
+
# (docs/0001 §9): only write bot_id once the column actually exists.
|
|
30
|
+
attrs[:bot_id] = bot_id if column_names.include?("bot_id")
|
|
31
|
+
create!(attrs)
|
|
27
32
|
|
|
28
33
|
purge_old_randomly!
|
|
29
34
|
end
|
|
@@ -4,8 +4,21 @@ module TelegramBotEngine
|
|
|
4
4
|
class Subscription < ActiveRecord::Base
|
|
5
5
|
self.table_name = "telegram_bot_engine_subscriptions"
|
|
6
6
|
|
|
7
|
+
belongs_to :bot, class_name: "TelegramBotEngine::Bot", optional: true
|
|
8
|
+
|
|
7
9
|
scope :active, -> { where(active: true) }
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
# The audience for a bot (docs/0001 §3.4). A subscription with a nil bot_id is
|
|
12
|
+
# treated as belonging to the default bot, so pre-multi-bot subscribers keep
|
|
13
|
+
# receiving default broadcasts without a data migration (§4 back-compat).
|
|
14
|
+
scope :for_bot, lambda { |bot|
|
|
15
|
+
if bot&.default?
|
|
16
|
+
where(bot_id: [bot.id, nil])
|
|
17
|
+
else
|
|
18
|
+
where(bot_id: bot&.id)
|
|
19
|
+
end
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
validates :chat_id, presence: true, uniqueness: { scope: :bot_id }
|
|
10
23
|
end
|
|
11
24
|
end
|
|
@@ -18,6 +18,18 @@
|
|
|
18
18
|
class="block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm px-3 py-2 border"
|
|
19
19
|
placeholder="e.g. Tom - developer">
|
|
20
20
|
</div>
|
|
21
|
+
<% if @bots.any? %>
|
|
22
|
+
<div>
|
|
23
|
+
<label for="allowed_user_bot_id" class="block text-sm font-medium text-gray-700 mb-1">Scope</label>
|
|
24
|
+
<select name="allowed_user[bot_id]" id="allowed_user_bot_id"
|
|
25
|
+
class="block rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm px-3 py-2 border">
|
|
26
|
+
<option value="">All bots (global)</option>
|
|
27
|
+
<% @bots.each do |bot| %>
|
|
28
|
+
<option value="<%= bot.id %>"><%= bot.name %></option>
|
|
29
|
+
<% end %>
|
|
30
|
+
</select>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
21
33
|
<div>
|
|
22
34
|
<button type="submit"
|
|
23
35
|
class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
|
@@ -32,6 +44,7 @@
|
|
|
32
44
|
<thead class="bg-gray-50">
|
|
33
45
|
<tr>
|
|
34
46
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
|
|
47
|
+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Scope</th>
|
|
35
48
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Note</th>
|
|
36
49
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Added</th>
|
|
37
50
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
@@ -43,6 +56,9 @@
|
|
|
43
56
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
44
57
|
@<%= user.username %>
|
|
45
58
|
</td>
|
|
59
|
+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
60
|
+
<%= user.bot&.name || "All bots" %>
|
|
61
|
+
</td>
|
|
46
62
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
47
63
|
<%= user.note || "-" %>
|
|
48
64
|
</td>
|
|
@@ -61,7 +77,7 @@
|
|
|
61
77
|
|
|
62
78
|
<% if @allowed_users.empty? %>
|
|
63
79
|
<tr>
|
|
64
|
-
<td colspan="
|
|
80
|
+
<td colspan="5" class="px-6 py-12 text-center text-sm text-gray-500">
|
|
65
81
|
No usernames in the allowlist yet.
|
|
66
82
|
</td>
|
|
67
83
|
</tr>
|