telegram_bot_engine 0.3.4 → 0.6.1

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 +48 -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 +21 -4
  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
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_dispatch"
4
+ require "active_support/security_utils"
5
+
6
+ module TelegramBotEngine
7
+ # The single inbound webhook endpoint for ALL bots (docs/0001 §3.7). The host app mounts
8
+ # this once: `mount TelegramBotEngine::Dispatch, at: config.webhook_mount_path`. For
9
+ # `POST <mount>/:webhook_secret` it resolves the Bot by its secret path segment, validates
10
+ # the X-Telegram-Bot-Api-Secret-Token header (telegram-bot 0.16 does NOT — docs/0001 §9),
11
+ # then hands off to the host's UpdatesController via `dispatch(bot.client, update, request)`.
12
+ class Dispatch
13
+ SECRET_TOKEN_HEADER = "HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN"
14
+ # The resolved Bot record is exposed to the controller here so SubscriberCommands can
15
+ # scope inbound /start·/stop to the bot the update arrived for.
16
+ ENV_BOT_KEY = "telegram_bot_engine.bot"
17
+
18
+ def self.call(env)
19
+ new.call(env)
20
+ end
21
+
22
+ def call(env)
23
+ request = ActionDispatch::Request.new(env)
24
+ return respond(405, "method not allowed") unless request.post?
25
+
26
+ bot = resolve_bot(request)
27
+ return respond(404, "unknown bot") unless bot
28
+ return respond(403, "invalid secret token") unless valid_secret_token?(request, bot)
29
+
30
+ controller = dispatch_controller
31
+ return respond(503, "dispatch_controller not configured") unless controller
32
+
33
+ begin
34
+ update = request.request_parameters
35
+ rescue ActionDispatch::Http::Parameters::ParseError
36
+ return respond(400, "bad request")
37
+ end
38
+
39
+ request.set_header(ENV_BOT_KEY, bot)
40
+ controller.dispatch(bot.client, update, request)
41
+ respond(200, "")
42
+ end
43
+
44
+ private
45
+
46
+ def resolve_bot(request)
47
+ # Route on the NON-secret webhook_id path segment; the secret_token is validated from the
48
+ # header only (docs/0001 §3.7) so the credential never reaches request/SQL logs.
49
+ webhook_id = request.path_info.to_s.split("/").reject(&:empty?).last
50
+ return nil if webhook_id.blank?
51
+
52
+ TelegramBotEngine::Bot.active.find_by(webhook_id: webhook_id)
53
+ end
54
+
55
+ def valid_secret_token?(request, bot)
56
+ provided = request.get_header(SECRET_TOKEN_HEADER).to_s
57
+ return false if provided.empty?
58
+
59
+ ActiveSupport::SecurityUtils.secure_compare(provided, bot.webhook_secret.to_s)
60
+ end
61
+
62
+ def dispatch_controller
63
+ target = TelegramBotEngine.config.dispatch_controller
64
+ return nil if target.blank?
65
+
66
+ target.respond_to?(:dispatch) ? target : target.to_s.constantize
67
+ end
68
+
69
+ def respond(status, body)
70
+ [status, { "Content-Type" => "text/plain; charset=utf-8" }, [body]]
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TelegramBotEngine
4
+ # Resolves and caches a Telegram::Bot::Client per Bot record, keyed by bot id
5
+ # (docs/0001 §2 cap 2, §3.2). This fills telegram-bot's gap: `Telegram.bots` is
6
+ # boot-memoized (`@bots ||=`) and not built for runtime hot-add or token rotation.
7
+ # We resolve a fresh `Telegram::Bot::Client.new(token)` per bot_id rather than
8
+ # leaning on `Telegram.reset_bots`, which is unsafe under concurrency (docs/0001 §9).
9
+ module Registry
10
+ @mutex = Mutex.new
11
+ @clients = {}
12
+
13
+ class << self
14
+ # The client for this bot, built once and cached by bot id.
15
+ def client_for(bot)
16
+ @mutex.synchronize do
17
+ @clients[bot.id] ||= build_client(bot)
18
+ end
19
+ end
20
+
21
+ # Drop a bot's cached client so the next resolve rebuilds it. Called from
22
+ # Bot#after_save / #after_destroy so token rotation can never serve a stale client.
23
+ def invalidate(bot)
24
+ @mutex.synchronize { @clients.delete(bot.id) }
25
+ end
26
+
27
+ # Clear the whole cache (test isolation; also safe to call on reload).
28
+ def reset!
29
+ @mutex.synchronize { @clients = {} }
30
+ end
31
+
32
+ private
33
+
34
+ def build_client(bot)
35
+ Telegram::Bot::Client.new(bot.token, bot.slug)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -12,7 +12,7 @@ module TelegramBotEngine
12
12
  # /start - create subscription if authorized
13
13
  def start!(*)
14
14
  subscription = TelegramBotEngine::Subscription.find_or_initialize_by(
15
- chat_id: chat["id"]
15
+ chat_id: chat["id"], bot_id: current_bot&.id
16
16
  )
17
17
  subscription.assign_attributes(
18
18
  user_id: from["id"],
@@ -24,7 +24,7 @@ module TelegramBotEngine
24
24
 
25
25
  TelegramBotEngine::Event.log(
26
26
  event_type: "command", action: "start",
27
- chat_id: chat["id"], username: from["username"]
27
+ chat_id: chat["id"], username: from["username"], bot_id: current_bot&.id
28
28
  )
29
29
 
30
30
  welcome = TelegramBotEngine.config.welcome_message % {
@@ -36,12 +36,12 @@ module TelegramBotEngine
36
36
 
37
37
  # /stop - deactivate subscription
38
38
  def stop!(*)
39
- subscription = TelegramBotEngine::Subscription.find_by(chat_id: chat["id"])
39
+ subscription = TelegramBotEngine::Subscription.find_by(chat_id: chat["id"], bot_id: current_bot&.id)
40
40
  subscription&.update(active: false)
41
41
 
42
42
  TelegramBotEngine::Event.log(
43
43
  event_type: "command", action: "stop",
44
- chat_id: chat["id"], username: from["username"]
44
+ chat_id: chat["id"], username: from["username"], bot_id: current_bot&.id
45
45
  )
46
46
 
47
47
  respond_with :message, text: "You've been unsubscribed. Send /start to resubscribe."
@@ -51,7 +51,7 @@ module TelegramBotEngine
51
51
  def help!(*)
52
52
  TelegramBotEngine::Event.log(
53
53
  event_type: "command", action: "help",
54
- chat_id: chat["id"], username: from["username"]
54
+ chat_id: chat["id"], username: from["username"], bot_id: current_bot&.id
55
55
  )
56
56
 
57
57
  respond_with :message, text: "📋 *Available Commands*\n\n#{available_commands_text}", parse_mode: "Markdown"
@@ -60,17 +60,27 @@ module TelegramBotEngine
60
60
  private
61
61
 
62
62
  def authorize_user!
63
- return if TelegramBotEngine::Authorizer.authorized?(from["username"])
63
+ return if TelegramBotEngine::Authorizer.authorized?(from["username"], bot: current_bot)
64
64
 
65
65
  TelegramBotEngine::Event.log(
66
66
  event_type: "auth_failure", action: "unauthorized",
67
- chat_id: chat["id"], username: from["username"]
67
+ chat_id: chat["id"], username: from["username"], bot_id: current_bot&.id
68
68
  )
69
69
 
70
70
  respond_with :message, text: TelegramBotEngine.config.unauthorized_message
71
71
  throw :abort
72
72
  end
73
73
 
74
+ # The Bot this inbound update arrived for, set by TelegramBotEngine::Dispatch in the
75
+ # request env (docs/0001 §3.7). nil when there is no inbound request context (e.g. the
76
+ # poller, or a host controller without webhook_request) → default/global behavior, unchanged.
77
+ def current_bot
78
+ return unless respond_to?(:webhook_request)
79
+
80
+ request = webhook_request
81
+ request && request.env["telegram_bot_engine.bot"]
82
+ end
83
+
74
84
  # Auto-generates command list from public methods ending with !
75
85
  def available_commands_text
76
86
  commands = self.class.public_instance_methods(false)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TelegramBotEngine
4
- VERSION = "0.3.4"
4
+ VERSION = "0.6.1"
5
5
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TelegramBotEngine
4
+ # Registers/removes a bot's Telegram webhook (docs/0001 §3.6). The webhook URL embeds the
5
+ # bot's per-bot `webhook_secret` path segment, and `secret_token` is set to the same secret
6
+ # so the inbound dispatcher can authenticate Telegram via the X-Telegram-Bot-Api-Secret-Token
7
+ # header. setWebhook is idempotent (overwrites), so re-registering is always safe.
8
+ module WebhookRegistrar
9
+ class << self
10
+ def register(bot, base_url: nil)
11
+ base_url ||= TelegramBotEngine.config.webhook_base_url
12
+ return false if base_url.blank?
13
+
14
+ TelegramBotEngine.client_for(bot).set_webhook(
15
+ url: webhook_url(bot, base_url),
16
+ secret_token: bot.webhook_secret
17
+ )
18
+ true
19
+ end
20
+
21
+ def remove(bot)
22
+ TelegramBotEngine.client_for(bot).delete_webhook
23
+ true
24
+ end
25
+
26
+ def webhook_url(bot, base_url)
27
+ # The URL path carries the NON-secret webhook_id; the secret stays in secret_token only.
28
+ "#{base_url.to_s.chomp('/')}#{TelegramBotEngine.config.webhook_mount_path}/#{bot.webhook_id}"
29
+ end
30
+ end
31
+ end
32
+ end
@@ -3,6 +3,9 @@
3
3
  require "telegram/bot"
4
4
  require "telegram_bot_engine/version"
5
5
  require "telegram_bot_engine/configuration"
6
+ require "telegram_bot_engine/registry"
7
+ require "telegram_bot_engine/webhook_registrar"
8
+ require "telegram_bot_engine/dispatch"
6
9
  require "telegram_bot_engine/authorizer"
7
10
  require "telegram_bot_engine/subscriber_commands"
8
11
  require "telegram_bot_engine/engine"
@@ -21,11 +24,20 @@ module TelegramBotEngine
21
24
  @config = Configuration.new
22
25
  end
23
26
 
24
- # Broadcast to all active subscribers via background jobs
25
- def broadcast(text, **options)
27
+ # The Telegram::Bot::Client for a Bot record, resolved from the DB and cached by
28
+ # bot id (token-rotation safe). See TelegramBotEngine::Registry / docs/0001 §3.2.
29
+ def client_for(bot)
30
+ Registry.client_for(bot)
31
+ end
32
+
33
+ # Broadcast to all active subscribers via background jobs, delivered through `bot`.
34
+ # `bot:` omitted ⇒ the default bot ⇒ today's behavior, unchanged (docs/0001 §3.3).
35
+ def broadcast(text, bot: nil, **options)
36
+ bot ||= TelegramBotEngine::Bot.default
26
37
  subscriber_count = 0
27
- TelegramBotEngine::Subscription.active.find_each do |subscription|
38
+ TelegramBotEngine::Subscription.active.for_bot(bot).find_each do |subscription|
28
39
  TelegramBotEngine::DeliveryJob.perform_later(
40
+ bot.id,
29
41
  subscription.chat_id,
30
42
  text,
31
43
  options
@@ -35,18 +47,21 @@ module TelegramBotEngine
35
47
 
36
48
  TelegramBotEngine::Event.log(
37
49
  event_type: "delivery", action: "broadcast",
38
- details: { subscriber_count: subscriber_count, text_preview: text.to_s[0, 100] }
50
+ bot_id: bot.id,
51
+ details: { bot: bot.slug, subscriber_count: subscriber_count, text_preview: text.to_s[0, 100] }
39
52
  )
40
53
  end
41
54
 
42
- # Send to a specific chat via background job
43
- def notify(chat_id:, text:, **options)
44
- TelegramBotEngine::DeliveryJob.perform_later(chat_id, text, options)
55
+ # Send to a specific chat via background job, delivered through `bot`.
56
+ # `bot:` omitted ⇒ the default bot ⇒ today's behavior, unchanged (docs/0001 §3.3).
57
+ def notify(chat_id:, text:, bot: nil, **options)
58
+ bot ||= TelegramBotEngine::Bot.default
59
+ TelegramBotEngine::DeliveryJob.perform_later(bot.id, chat_id, text, options)
45
60
 
46
61
  TelegramBotEngine::Event.log(
47
62
  event_type: "delivery", action: "notify",
48
- chat_id: chat_id,
49
- details: { text_preview: text.to_s[0, 100] }
63
+ chat_id: chat_id, bot_id: bot.id,
64
+ details: { bot: bot.slug, text_preview: text.to_s[0, 100] }
50
65
  )
51
66
  end
52
67
  end
metadata CHANGED
@@ -1,10 +1,10 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: telegram_bot_engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.4
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
- - TelegramBotEngine Contributors
7
+ - Tomáš Landovský
8
8
  bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
@@ -16,6 +16,9 @@ dependencies:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: '7.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -23,6 +26,9 @@ dependencies:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: '7.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: telegram-bot
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -39,21 +45,31 @@ dependencies:
39
45
  version: '0.16'
40
46
  description: A mountable Rails engine that adds subscriber persistence, authorization,
41
47
  broadcasting, and an admin UI on top of the telegram-bot gem.
48
+ email:
49
+ - landovsky@gmail.com
42
50
  executables: []
43
51
  extensions: []
44
52
  extra_rdoc_files: []
45
53
  files:
54
+ - CHANGELOG.md
55
+ - LICENSE
46
56
  - README.md
47
57
  - app/controllers/telegram_bot_engine/admin/allowlist_controller.rb
48
58
  - app/controllers/telegram_bot_engine/admin/base_controller.rb
59
+ - app/controllers/telegram_bot_engine/admin/bots_controller.rb
49
60
  - app/controllers/telegram_bot_engine/admin/dashboard_controller.rb
50
61
  - app/controllers/telegram_bot_engine/admin/events_controller.rb
51
62
  - app/controllers/telegram_bot_engine/admin/subscriptions_controller.rb
63
+ - app/jobs/telegram_bot_engine/application_job.rb
52
64
  - app/jobs/telegram_bot_engine/delivery_job.rb
53
65
  - app/models/telegram_bot_engine/allowed_user.rb
66
+ - app/models/telegram_bot_engine/bot.rb
54
67
  - app/models/telegram_bot_engine/event.rb
55
68
  - app/models/telegram_bot_engine/subscription.rb
56
69
  - app/views/telegram_bot_engine/admin/allowlist/index.html.erb
70
+ - app/views/telegram_bot_engine/admin/bots/edit.html.erb
71
+ - app/views/telegram_bot_engine/admin/bots/index.html.erb
72
+ - app/views/telegram_bot_engine/admin/bots/new.html.erb
57
73
  - app/views/telegram_bot_engine/admin/dashboard/show.html.erb
58
74
  - app/views/telegram_bot_engine/admin/events/index.html.erb
59
75
  - app/views/telegram_bot_engine/admin/layouts/application.html.erb
@@ -62,17 +78,29 @@ files:
62
78
  - db/migrate/001_create_telegram_bot_engine_subscriptions.rb
63
79
  - db/migrate/002_create_telegram_bot_engine_allowed_users.rb
64
80
  - db/migrate/003_create_telegram_bot_engine_events.rb
81
+ - db/migrate/004_create_telegram_bot_engine_bots.rb
82
+ - db/migrate/005_add_bot_to_telegram_bot_engine_subscriptions.rb
83
+ - db/migrate/006_add_bot_to_telegram_bot_engine_allowed_users.rb
84
+ - db/migrate/007_add_bot_to_telegram_bot_engine_events.rb
85
+ - db/migrate/008_add_webhook_id_to_telegram_bot_engine_bots.rb
65
86
  - lib/tasks/telegram_bot_engine.rake
66
87
  - lib/telegram_bot_engine.rb
67
88
  - lib/telegram_bot_engine/authorizer.rb
68
89
  - lib/telegram_bot_engine/configuration.rb
90
+ - lib/telegram_bot_engine/dispatch.rb
69
91
  - lib/telegram_bot_engine/engine.rb
92
+ - lib/telegram_bot_engine/registry.rb
70
93
  - lib/telegram_bot_engine/subscriber_commands.rb
71
94
  - lib/telegram_bot_engine/version.rb
95
+ - lib/telegram_bot_engine/webhook_registrar.rb
72
96
  homepage: https://github.com/landovsky/telegram-bot-channels-gem
73
97
  licenses:
74
98
  - MIT
75
- metadata: {}
99
+ metadata:
100
+ source_code_uri: https://github.com/landovsky/telegram-bot-channels-gem
101
+ changelog_uri: https://github.com/landovsky/telegram-bot-channels-gem/blob/main/CHANGELOG.md
102
+ bug_tracker_uri: https://github.com/landovsky/telegram-bot-channels-gem/issues
103
+ rubygems_mfa_required: 'true'
76
104
  rdoc_options: []
77
105
  require_paths:
78
106
  - lib