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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/LICENSE +21 -0
  4. data/README.md +67 -0
  5. data/app/controllers/telegram_bot_engine/admin/allowlist_controller.rb +5 -2
  6. data/app/controllers/telegram_bot_engine/admin/bots_controller.rb +73 -0
  7. data/app/controllers/telegram_bot_engine/admin/dashboard_controller.rb +6 -0
  8. data/app/controllers/telegram_bot_engine/admin/events_controller.rb +3 -0
  9. data/app/controllers/telegram_bot_engine/admin/subscriptions_controller.rb +3 -1
  10. data/app/jobs/telegram_bot_engine/application_job.rb +10 -0
  11. data/app/jobs/telegram_bot_engine/delivery_job.rb +15 -8
  12. data/app/models/telegram_bot_engine/allowed_user.rb +7 -1
  13. data/app/models/telegram_bot_engine/bot.rb +177 -0
  14. data/app/models/telegram_bot_engine/event.rb +8 -3
  15. data/app/models/telegram_bot_engine/subscription.rb +14 -1
  16. data/app/views/telegram_bot_engine/admin/allowlist/index.html.erb +17 -1
  17. data/app/views/telegram_bot_engine/admin/bots/edit.html.erb +69 -0
  18. data/app/views/telegram_bot_engine/admin/bots/index.html.erb +70 -0
  19. data/app/views/telegram_bot_engine/admin/bots/new.html.erb +53 -0
  20. data/app/views/telegram_bot_engine/admin/dashboard/show.html.erb +40 -1
  21. data/app/views/telegram_bot_engine/admin/events/index.html.erb +13 -2
  22. data/app/views/telegram_bot_engine/admin/layouts/application.html.erb +2 -0
  23. data/app/views/telegram_bot_engine/admin/subscriptions/index.html.erb +22 -1
  24. data/config/routes.rb +3 -0
  25. data/db/migrate/004_create_telegram_bot_engine_bots.rb +21 -0
  26. data/db/migrate/005_add_bot_to_telegram_bot_engine_subscriptions.rb +38 -0
  27. data/db/migrate/006_add_bot_to_telegram_bot_engine_allowed_users.rb +32 -0
  28. data/db/migrate/007_add_bot_to_telegram_bot_engine_events.rb +11 -0
  29. data/db/migrate/008_add_webhook_id_to_telegram_bot_engine_bots.rb +29 -0
  30. data/lib/telegram_bot_engine/authorizer.rb +10 -6
  31. data/lib/telegram_bot_engine/configuration.rb +8 -1
  32. data/lib/telegram_bot_engine/dispatch.rb +73 -0
  33. data/lib/telegram_bot_engine/registry.rb +39 -0
  34. data/lib/telegram_bot_engine/subscriber_commands.rb +17 -7
  35. data/lib/telegram_bot_engine/version.rb +1 -1
  36. data/lib/telegram_bot_engine/webhook_registrar.rb +32 -0
  37. data/lib/telegram_bot_engine.rb +24 -9
  38. metadata +31 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5b900a886aae0dea9b6c84313b705eca2c2b435758b6346ecc3ee036cb690647
4
- data.tar.gz: df9102d007d39d35dc3c7ef5b00c56b9f432fcf6e1e1a5efcf76830e5a0bc393
3
+ metadata.gz: d27dbf5fec7cb1c9192332eca34672baea537c79d75ae46df0f6b7cfd13e576a
4
+ data.tar.gz: 188cc7fa3fb070f8b090f3bcc4b91cddbca17dbbe94354186752c612ba7e0d3d
5
5
  SHA512:
6
- metadata.gz: 18ac6b1549b2d00871a2f323b537f996d85fb879e8570d342ec7ff290ecbc0943c5f1a009547736ee8569582860ab551620a65919aafba15502a90b64f452b48
7
- data.tar.gz: 3f8046235e8b1e15e72a1ef0e82e1a2cdfee2ae8165327823ccb31c4ce5b1b90480e7c66aa5d3efb7d021f5b5584c706fb3aaaabc6b2960a8670b9bffed1019e
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
- @allowed_users = AllowedUser.order(:username)
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
- params.require(:allowed_user).permit(:username, :note)
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
- @subscriptions = Subscription.order(created_at: :desc)
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
- def perform(chat_id, text, options = {})
9
- Telegram.bot.send_message(
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 the bot - deactivate subscription
22
- TelegramBotEngine::Subscription.where(chat_id: chat_id).update_all(active: false)
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
- validates :username, presence: true, uniqueness: true
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
- create!(
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
- validates :chat_id, presence: true, uniqueness: true
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="4" class="px-6 py-12 text-center text-sm text-gray-500">
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>