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
@@ -0,0 +1,69 @@
1
+ <div class="mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900">Edit Bot</h1>
3
+ <p class="mt-1 text-sm text-gray-500 font-mono"><%= @bot.slug %></p>
4
+ </div>
5
+
6
+ <div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 max-w-2xl mb-6">
7
+ <%= form_with url: telegram_bot_engine.admin_bot_path(@bot), method: :patch, class: "space-y-4" do %>
8
+ <div>
9
+ <label for="bot_name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
10
+ <input type="text" name="bot[name]" id="bot_name" value="<%= @bot.name %>" required
11
+ 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">
12
+ </div>
13
+ <div>
14
+ <label for="bot_slug" class="block text-sm font-medium text-gray-700 mb-1">Slug</label>
15
+ <input type="text" name="bot[slug]" id="bot_slug" value="<%= @bot.slug %>" required
16
+ 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 font-mono">
17
+ </div>
18
+ <div>
19
+ <label for="bot_purpose" class="block text-sm font-medium text-gray-700 mb-1">Purpose (optional)</label>
20
+ <input type="text" name="bot[purpose]" id="bot_purpose" value="<%= @bot.purpose %>"
21
+ 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">
22
+ </div>
23
+ <div class="flex items-center space-x-2">
24
+ <input type="hidden" name="bot[active]" value="0">
25
+ <input type="checkbox" name="bot[active]" id="bot_active" value="1" <%= "checked" if @bot.active %>
26
+ class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
27
+ <label for="bot_active" class="text-sm text-gray-700">Active</label>
28
+ </div>
29
+ <div class="flex items-center space-x-2">
30
+ <input type="hidden" name="bot[default]" value="0">
31
+ <input type="checkbox" name="bot[default]" id="bot_default" value="1" <%= "checked" if @bot.default %>
32
+ class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
33
+ <label for="bot_default" class="text-sm text-gray-700">Default bot</label>
34
+ </div>
35
+ <div class="flex items-center space-x-3 pt-2">
36
+ <button type="submit"
37
+ 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">
38
+ Save
39
+ </button>
40
+ <%= link_to "Cancel", telegram_bot_engine.admin_bots_path, class: "text-sm text-gray-600 hover:text-gray-900" %>
41
+ </div>
42
+ <% end %>
43
+ </div>
44
+
45
+ <div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 max-w-2xl mb-6">
46
+ <h2 class="text-sm font-medium text-gray-700 mb-1">Rotate token</h2>
47
+ <p class="text-xs text-gray-500 mb-4">Replaces the stored token and invalidates the cached client immediately (token-rotation safe). Current token: <span class="font-mono"><%= @bot.token.to_s.length > 4 ? "••••#{@bot.token.to_s.last(4)}" : "set" %></span></p>
48
+ <%= form_with url: telegram_bot_engine.rotate_token_admin_bot_path(@bot), method: :patch, class: "flex items-end space-x-3" do %>
49
+ <div class="flex-1">
50
+ <label for="token" class="block text-sm font-medium text-gray-700 mb-1">New token</label>
51
+ <input type="password" name="token" id="token" required autocomplete="off"
52
+ 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 font-mono"
53
+ placeholder="123456:ABC-DEF...">
54
+ </div>
55
+ <button type="submit"
56
+ class="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
57
+ Rotate
58
+ </button>
59
+ <% end %>
60
+ </div>
61
+
62
+ <% unless @bot.default? %>
63
+ <div class="max-w-2xl">
64
+ <%= button_to "Delete this bot", telegram_bot_engine.admin_bot_path(@bot),
65
+ method: :delete,
66
+ data: { turbo_confirm: "Delete bot \"#{@bot.name}\" and its subscribers/allowlist?" },
67
+ class: "inline-flex items-center px-4 py-2 border border-red-300 rounded-md text-sm font-medium text-red-700 bg-white hover:bg-red-50" %>
68
+ </div>
69
+ <% end %>
@@ -0,0 +1,70 @@
1
+ <div class="mb-6 flex items-center justify-between">
2
+ <div>
3
+ <h1 class="text-2xl font-bold text-gray-900">Bots</h1>
4
+ <p class="mt-1 text-sm text-gray-500"><%= @bots.count %> total</p>
5
+ </div>
6
+ <%= link_to "Add Bot", telegram_bot_engine.new_admin_bot_path,
7
+ 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" %>
8
+ </div>
9
+
10
+ <div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
11
+ <table class="min-w-full divide-y divide-gray-200">
12
+ <thead class="bg-gray-50">
13
+ <tr>
14
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
15
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
16
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Purpose</th>
17
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Token</th>
18
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
19
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Webhook</th>
20
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
21
+ </tr>
22
+ </thead>
23
+ <tbody class="bg-white divide-y divide-gray-200">
24
+ <% @bots.each do |bot| %>
25
+ <tr>
26
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= bot.name %></td>
27
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono"><%= bot.slug %></td>
28
+ <td class="px-6 py-4 text-sm text-gray-600"><%= bot.purpose.presence || "-" %></td>
29
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
30
+ <%= bot.token.to_s.length > 4 ? "••••#{bot.token.to_s.last(4)}" : "set" %>
31
+ </td>
32
+ <td class="px-6 py-4 whitespace-nowrap space-x-1">
33
+ <% if bot.default? %>
34
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Default</span>
35
+ <% end %>
36
+ <% if bot.active? %>
37
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
38
+ <% else %>
39
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Inactive</span>
40
+ <% end %>
41
+ </td>
42
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
43
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
44
+ Secret set
45
+ </span>
46
+ <span class="block text-xs text-gray-400 mt-1">registers in Phase 3</span>
47
+ </td>
48
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
49
+ <%= link_to "Edit", telegram_bot_engine.edit_admin_bot_path(bot),
50
+ class: "inline-flex items-center px-3 py-1.5 border border-gray-300 rounded text-xs font-medium text-gray-700 bg-white hover:bg-gray-50" %>
51
+ <% unless bot.default? %>
52
+ <%= button_to "Delete", telegram_bot_engine.admin_bot_path(bot),
53
+ method: :delete,
54
+ data: { turbo_confirm: "Delete bot \"#{bot.name}\" and its subscribers/allowlist?" },
55
+ class: "inline-flex items-center px-3 py-1.5 border border-red-300 rounded text-xs font-medium text-red-700 bg-white hover:bg-red-50" %>
56
+ <% end %>
57
+ </td>
58
+ </tr>
59
+ <% end %>
60
+
61
+ <% if @bots.empty? %>
62
+ <tr>
63
+ <td colspan="7" class="px-6 py-12 text-center text-sm text-gray-500">
64
+ No bots yet. <%= link_to "Add one", telegram_bot_engine.new_admin_bot_path, class: "text-blue-600 hover:text-blue-800" %>.
65
+ </td>
66
+ </tr>
67
+ <% end %>
68
+ </tbody>
69
+ </table>
70
+ </div>
@@ -0,0 +1,53 @@
1
+ <div class="mb-6">
2
+ <h1 class="text-2xl font-bold text-gray-900">Add Bot</h1>
3
+ </div>
4
+
5
+ <div class="bg-white shadow-sm rounded-lg border border-gray-200 p-6 max-w-2xl">
6
+ <%= form_with url: telegram_bot_engine.admin_bots_path, method: :post, class: "space-y-4" do %>
7
+ <div>
8
+ <label for="bot_name" class="block text-sm font-medium text-gray-700 mb-1">Name</label>
9
+ <input type="text" name="bot[name]" id="bot_name" value="<%= @bot.name %>" required
10
+ 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"
11
+ placeholder="Assistant">
12
+ </div>
13
+ <div>
14
+ <label for="bot_slug" class="block text-sm font-medium text-gray-700 mb-1">Slug</label>
15
+ <input type="text" name="bot[slug]" id="bot_slug" value="<%= @bot.slug %>" required
16
+ 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 font-mono"
17
+ placeholder="assistant">
18
+ <p class="mt-1 text-xs text-gray-500">Stable handle used by the app (e.g. <code>Bot.resolve("assistant")</code>). Cannot collide.</p>
19
+ </div>
20
+ <div>
21
+ <label for="bot_purpose" class="block text-sm font-medium text-gray-700 mb-1">Purpose (optional)</label>
22
+ <input type="text" name="bot[purpose]" id="bot_purpose" value="<%= @bot.purpose %>"
23
+ 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"
24
+ placeholder="Ops alerts from the assistant">
25
+ </div>
26
+ <div>
27
+ <label for="bot_token" class="block text-sm font-medium text-gray-700 mb-1">Token</label>
28
+ <input type="password" name="bot[token]" id="bot_token" required autocomplete="off"
29
+ 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 font-mono"
30
+ placeholder="123456:ABC-DEF...">
31
+ <p class="mt-1 text-xs text-gray-500">Stored as-is until ActiveRecord Encryption keys are provisioned (docs/0001 §6). Rotate later from the bot's edit page.</p>
32
+ </div>
33
+ <div class="flex items-center space-x-2">
34
+ <input type="hidden" name="bot[active]" value="0">
35
+ <input type="checkbox" name="bot[active]" id="bot_active" value="1" <%= "checked" if @bot.active %>
36
+ class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
37
+ <label for="bot_active" class="text-sm text-gray-700">Active</label>
38
+ </div>
39
+ <div class="flex items-center space-x-2">
40
+ <input type="hidden" name="bot[default]" value="0">
41
+ <input type="checkbox" name="bot[default]" id="bot_default" value="1" <%= "checked" if @bot.default %>
42
+ class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500">
43
+ <label for="bot_default" class="text-sm text-gray-700">Default bot <span class="text-gray-400">(promoting this demotes the current default)</span></label>
44
+ </div>
45
+ <div class="flex items-center space-x-3 pt-2">
46
+ <button type="submit"
47
+ 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">
48
+ Create Bot
49
+ </button>
50
+ <%= link_to "Cancel", telegram_bot_engine.admin_bots_path, class: "text-sm text-gray-600 hover:text-gray-900" %>
51
+ </div>
52
+ <% end %>
53
+ </div>
@@ -2,7 +2,43 @@
2
2
  <h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
3
3
  </div>
4
4
 
5
- <% if @bot_username %>
5
+ <% if @bots.any? %>
6
+ <div class="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
7
+ <div class="flex items-center justify-between px-6 py-4 border-b border-gray-200">
8
+ <h2 class="text-sm font-medium text-gray-500 uppercase tracking-wider">Bots</h2>
9
+ <%= link_to "Manage", telegram_bot_engine.admin_bots_path, class: "text-sm text-blue-600 hover:text-blue-800" %>
10
+ </div>
11
+ <table class="min-w-full divide-y divide-gray-200">
12
+ <thead class="bg-gray-50">
13
+ <tr>
14
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
15
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
16
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
17
+ <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Active Subscribers</th>
18
+ </tr>
19
+ </thead>
20
+ <tbody class="bg-white divide-y divide-gray-200">
21
+ <% @bots.each do |bot| %>
22
+ <tr>
23
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900"><%= bot.name %></td>
24
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono"><%= bot.slug %></td>
25
+ <td class="px-6 py-4 whitespace-nowrap space-x-1">
26
+ <% if bot.default? %>
27
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">Default</span>
28
+ <% end %>
29
+ <% if bot.active? %>
30
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">Active</span>
31
+ <% else %>
32
+ <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">Inactive</span>
33
+ <% end %>
34
+ </td>
35
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-semibold text-gray-900"><%= @bot_active_counts[bot] %></td>
36
+ </tr>
37
+ <% end %>
38
+ </tbody>
39
+ </table>
40
+ </div>
41
+ <% elsif @bot_username %>
6
42
  <div class="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 p-6">
7
43
  <h2 class="text-sm font-medium text-gray-500 uppercase tracking-wider mb-2">Bot</h2>
8
44
  <p class="text-lg font-semibold text-gray-900">@<%= @bot_username %></p>
@@ -31,6 +67,9 @@
31
67
  </div>
32
68
 
33
69
  <div class="flex space-x-4">
70
+ <%= link_to "Manage Bots", telegram_bot_engine.admin_bots_path,
71
+ class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
72
+
34
73
  <%= link_to "Manage Subscriptions", telegram_bot_engine.admin_subscriptions_path,
35
74
  class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
36
75
 
@@ -29,6 +29,17 @@
29
29
  class="block rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm px-3 py-2 border"
30
30
  placeholder="e.g. 12345">
31
31
  </div>
32
+ <% if @bots.any? %>
33
+ <div>
34
+ <label for="bot_id" class="block text-sm font-medium text-gray-700 mb-1">Bot</label>
35
+ <select name="bot_id" id="bot_id" class="block rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm px-3 py-2 border">
36
+ <option value="">All</option>
37
+ <% @bots.each do |bot| %>
38
+ <option value="<%= bot.id %>" <%= "selected" if params[:bot_id].to_s == bot.id.to_s %>><%= bot.name %></option>
39
+ <% end %>
40
+ </select>
41
+ </div>
42
+ <% end %>
32
43
  <div>
33
44
  <button type="submit"
34
45
  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">
@@ -103,11 +114,11 @@
103
114
  </div>
104
115
  <div class="flex space-x-2">
105
116
  <% if @page > 1 %>
106
- <%= link_to "Previous", telegram_bot_engine.admin_events_path(type: params[:type], action_name: params[:action_name], chat_id: params[:chat_id], page: @page - 1),
117
+ <%= link_to "Previous", telegram_bot_engine.admin_events_path(type: params[:type], action_name: params[:action_name], chat_id: params[:chat_id], bot_id: params[:bot_id], page: @page - 1),
107
118
  class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
108
119
  <% end %>
109
120
  <% if @page < @total_pages %>
110
- <%= link_to "Next", telegram_bot_engine.admin_events_path(type: params[:type], action_name: params[:action_name], chat_id: params[:chat_id], page: @page + 1),
121
+ <%= link_to "Next", telegram_bot_engine.admin_events_path(type: params[:type], action_name: params[:action_name], chat_id: params[:chat_id], bot_id: params[:bot_id], page: @page + 1),
111
122
  class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50" %>
112
123
  <% end %>
113
124
  </div>
@@ -16,6 +16,8 @@
16
16
  <div class="flex space-x-4">
17
17
  <%= link_to "Dashboard", telegram_bot_engine.admin_dashboard_path,
18
18
  class: "px-3 py-2 rounded-md text-sm font-medium #{request.path == telegram_bot_engine.admin_dashboard_path || request.path == telegram_bot_engine.admin_root_path ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}" %>
19
+ <%= link_to "Bots", telegram_bot_engine.admin_bots_path,
20
+ class: "px-3 py-2 rounded-md text-sm font-medium #{request.path.include?('bots') ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}" %>
19
21
  <%= link_to "Subscriptions", telegram_bot_engine.admin_subscriptions_path,
20
22
  class: "px-3 py-2 rounded-md text-sm font-medium #{request.path.include?('subscriptions') ? 'text-blue-600 bg-blue-50' : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}" %>
21
23
  <% if TelegramBotEngine.config.allowed_usernames == :database %>
@@ -3,12 +3,30 @@
3
3
  <p class="mt-1 text-sm text-gray-500"><%= @subscriptions.count %> total</p>
4
4
  </div>
5
5
 
6
+ <% if @bots.any? %>
7
+ <div class="mb-6 bg-white shadow-sm rounded-lg border border-gray-200 p-4">
8
+ <%= form_with url: telegram_bot_engine.admin_subscriptions_path, method: :get, local: true, class: "flex items-end space-x-3" do %>
9
+ <div>
10
+ <label for="bot_id" class="block text-sm font-medium text-gray-700 mb-1">Bot</label>
11
+ <select name="bot_id" id="bot_id" class="block rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500 text-sm px-3 py-2 border">
12
+ <option value="">All bots</option>
13
+ <% @bots.each do |bot| %>
14
+ <option value="<%= bot.id %>" <%= "selected" if params[:bot_id].to_s == bot.id.to_s %>><%= bot.name %></option>
15
+ <% end %>
16
+ </select>
17
+ </div>
18
+ <button type="submit" 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">Filter</button>
19
+ <% end %>
20
+ </div>
21
+ <% end %>
22
+
6
23
  <div class="bg-white shadow-sm rounded-lg border border-gray-200 overflow-hidden">
7
24
  <table class="min-w-full divide-y divide-gray-200">
8
25
  <thead class="bg-gray-50">
9
26
  <tr>
10
27
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
11
28
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">First Name</th>
29
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bot</th>
12
30
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chat ID</th>
13
31
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
14
32
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Subscribed</th>
@@ -24,6 +42,9 @@
24
42
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
25
43
  <%= subscription.first_name || "-" %>
26
44
  </td>
45
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
46
+ <%= subscription.bot&.slug || "default" %>
47
+ </td>
27
48
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
28
49
  <%= subscription.chat_id %>
29
50
  </td>
@@ -53,7 +74,7 @@
53
74
 
54
75
  <% if @subscriptions.empty? %>
55
76
  <tr>
56
- <td colspan="6" class="px-6 py-12 text-center text-sm text-gray-500">
77
+ <td colspan="7" class="px-6 py-12 text-center text-sm text-gray-500">
57
78
  No subscriptions yet.
58
79
  </td>
59
80
  </tr>
data/config/routes.rb CHANGED
@@ -4,6 +4,9 @@ TelegramBotEngine::Engine.routes.draw do
4
4
  scope module: :admin, as: :admin do
5
5
  root to: "dashboard#show"
6
6
  get "dashboard", to: "dashboard#show", as: :dashboard
7
+ resources :bots, except: %i[show] do
8
+ member { patch :rotate_token }
9
+ end
7
10
  resources :subscriptions, only: %i[index update destroy]
8
11
  resources :allowlist, only: %i[index create destroy]
9
12
  resources :events, only: %i[index]
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTelegramBotEngineBots < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :telegram_bot_engine_bots do |t|
6
+ t.string :name, null: false
7
+ t.string :slug, null: false
8
+ t.string :purpose
9
+ t.string :token, null: false # encrypted at rest once keys are provisioned (docs/0001 §6)
10
+ t.string :webhook_secret, null: false # per-bot inbound secret path segment
11
+ t.boolean :active, null: false, default: true
12
+ t.boolean :default, null: false, default: false # exactly one row true (enforced in the model)
13
+
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :telegram_bot_engine_bots, :slug, unique: true
18
+ add_index :telegram_bot_engine_bots, :webhook_secret, unique: true
19
+ add_index :telegram_bot_engine_bots, :active
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-bot subscribers (docs/0001 §3.4): a chat can now subscribe to more than one bot,
4
+ # so global chat_id uniqueness becomes (bot_id, chat_id) uniqueness.
5
+ #
6
+ # Additive + rollback-safe: bot_id is nullable and there is NO data backfill. Back-compat
7
+ # is handled at the read layer (Subscription.for_bot(default_bot) folds nil-bot_id rows in),
8
+ # so a rolled-back gem that knows nothing about bot_id still reads every row (docs/0001 §9).
9
+ #
10
+ # A partial unique index on (chat_id WHERE bot_id IS NULL) preserves the duplicate guard the
11
+ # old global unique index gave the pre-multi-bot (default-bot) population — composite unique
12
+ # indexes treat NULL bot_id as distinct, so that set would otherwise lose DB-level protection.
13
+ #
14
+ # Explicit up/down so a rollback faithfully restores the original UNIQUE(chat_id) index
15
+ # instead of silently leaving it non-unique (the default `change` inverse would).
16
+ class AddBotToTelegramBotEngineSubscriptions < ActiveRecord::Migration[7.0]
17
+ DEFAULT_BOT_UNIQUE = "index_tbe_subscriptions_on_chat_id_default_bot"
18
+
19
+ def up
20
+ add_column :telegram_bot_engine_subscriptions, :bot_id, :bigint
21
+ add_index :telegram_bot_engine_subscriptions, :bot_id
22
+
23
+ remove_index :telegram_bot_engine_subscriptions, :chat_id # drop the global UNIQUE(chat_id)
24
+ add_index :telegram_bot_engine_subscriptions, :chat_id # keep a non-unique lookup index
25
+ add_index :telegram_bot_engine_subscriptions, %i[bot_id chat_id], unique: true
26
+ add_index :telegram_bot_engine_subscriptions, :chat_id, unique: true,
27
+ where: "bot_id IS NULL", name: DEFAULT_BOT_UNIQUE
28
+ end
29
+
30
+ def down
31
+ remove_index :telegram_bot_engine_subscriptions, name: DEFAULT_BOT_UNIQUE
32
+ remove_index :telegram_bot_engine_subscriptions, column: %i[bot_id chat_id]
33
+ remove_index :telegram_bot_engine_subscriptions, :chat_id
34
+ add_index :telegram_bot_engine_subscriptions, :chat_id, unique: true # restore original guard
35
+ remove_index :telegram_bot_engine_subscriptions, :bot_id
36
+ remove_column :telegram_bot_engine_subscriptions, :bot_id
37
+ end
38
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-bot allowlist (docs/0001 §3.5): an AllowedUser may be scoped to a bot. A nil bot_id
4
+ # is a GLOBAL allow entry that applies to every bot — exactly today's behavior, so existing
5
+ # rows (all nil) keep authorizing as before. Additive + nullable.
6
+ #
7
+ # Partial unique index on (username WHERE bot_id IS NULL) preserves the duplicate guard the
8
+ # old global unique(username) index gave global entries (NULLs are distinct in the composite
9
+ # index). Explicit up/down so rollback restores the original UNIQUE(username) faithfully.
10
+ class AddBotToTelegramBotEngineAllowedUsers < ActiveRecord::Migration[7.0]
11
+ GLOBAL_UNIQUE = "index_tbe_allowed_users_on_username_global"
12
+
13
+ def up
14
+ add_column :telegram_bot_engine_allowed_users, :bot_id, :bigint
15
+ add_index :telegram_bot_engine_allowed_users, :bot_id
16
+
17
+ remove_index :telegram_bot_engine_allowed_users, :username # drop the global UNIQUE(username)
18
+ add_index :telegram_bot_engine_allowed_users, :username # keep a non-unique lookup index
19
+ add_index :telegram_bot_engine_allowed_users, %i[bot_id username], unique: true
20
+ add_index :telegram_bot_engine_allowed_users, :username, unique: true,
21
+ where: "bot_id IS NULL", name: GLOBAL_UNIQUE
22
+ end
23
+
24
+ def down
25
+ remove_index :telegram_bot_engine_allowed_users, name: GLOBAL_UNIQUE
26
+ remove_index :telegram_bot_engine_allowed_users, column: %i[bot_id username]
27
+ remove_index :telegram_bot_engine_allowed_users, :username
28
+ add_index :telegram_bot_engine_allowed_users, :username, unique: true # restore original guard
29
+ remove_index :telegram_bot_engine_allowed_users, :bot_id
30
+ remove_column :telegram_bot_engine_allowed_users, :bot_id
31
+ end
32
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Per-bot event log (docs/0001 §3.8): tag delivery events with the bot that produced
4
+ # them so the events admin screen can be scoped per bot. Nullable + additive; command
5
+ # events (start/stop/help) stay nil until the inbound dispatcher knows the bot (Phase 3).
6
+ class AddBotToTelegramBotEngineEvents < ActiveRecord::Migration[7.0]
7
+ def change
8
+ add_column :telegram_bot_engine_events, :bot_id, :bigint
9
+ add_index :telegram_bot_engine_events, :bot_id
10
+ end
11
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ # Decouple the inbound ROUTING key from the bearer SECRET (security hardening, docs/0001
6
+ # §3.6/§3.7). Previously the webhook URL embedded `webhook_secret`, which Rails logs verbatim
7
+ # in request lines and SQL binds — leaking the secret_token to any log reader. `webhook_id` is
8
+ # a stable, NON-secret public id that goes in the URL path; `webhook_secret` now lives ONLY in
9
+ # the X-Telegram-Bot-Api-Secret-Token header. Additive + backfilled; uses update_columns via an
10
+ # anonymous model so no Bot callbacks/validations fire during the migration.
11
+ class AddWebhookIdToTelegramBotEngineBots < ActiveRecord::Migration[7.0]
12
+ def up
13
+ add_column :telegram_bot_engine_bots, :webhook_id, :string
14
+
15
+ seam = Class.new(ActiveRecord::Base) { self.table_name = "telegram_bot_engine_bots" }
16
+ seam.reset_column_information
17
+ seam.where(webhook_id: nil).find_each do |row|
18
+ row.update_columns(webhook_id: SecureRandom.hex(16))
19
+ end
20
+
21
+ change_column_null :telegram_bot_engine_bots, :webhook_id, false
22
+ add_index :telegram_bot_engine_bots, :webhook_id, unique: true
23
+ end
24
+
25
+ def down
26
+ remove_index :telegram_bot_engine_bots, :webhook_id
27
+ remove_column :telegram_bot_engine_bots, :webhook_id
28
+ end
29
+ end
@@ -2,16 +2,17 @@
2
2
 
3
3
  module TelegramBotEngine
4
4
  class Authorizer
5
- def self.authorized?(username)
5
+ # Authorizes an inbound command username. `bot: nil` ⇒ global behavior, unchanged
6
+ # (docs/0001 §3.5). When a bot is given in :database mode, both global (nil bot_id)
7
+ # and that bot's own allow entries apply.
8
+ def self.authorized?(username, bot: nil)
6
9
  return true if TelegramBotEngine.config.allowed_usernames.nil?
7
10
 
8
- allowed = resolve_allowed_usernames
11
+ allowed = resolve_allowed_usernames(bot)
9
12
  allowed.map(&:downcase).include?(username&.downcase)
10
13
  end
11
14
 
12
- private
13
-
14
- def self.resolve_allowed_usernames
15
+ def self.resolve_allowed_usernames(bot = nil)
15
16
  config = TelegramBotEngine.config.allowed_usernames
16
17
 
17
18
  case config
@@ -20,10 +21,13 @@ module TelegramBotEngine
20
21
  when Proc
21
22
  config.call
22
23
  when :database
23
- TelegramBotEngine::AllowedUser.pluck(:username)
24
+ scope = bot ? TelegramBotEngine::AllowedUser.for_bot(bot) : TelegramBotEngine::AllowedUser.all
25
+ scope.pluck(:username)
24
26
  else
25
27
  []
26
28
  end
27
29
  end
30
+
31
+ private_class_method :resolve_allowed_usernames
28
32
  end
29
33
  end
@@ -3,7 +3,9 @@
3
3
  module TelegramBotEngine
4
4
  class Configuration
5
5
  attr_accessor :allowed_usernames, :admin_enabled, :unauthorized_message, :welcome_message,
6
- :event_logging, :event_retention_days
6
+ :event_logging, :event_retention_days,
7
+ # Phase 3 — webhook registrar (§3.6) + inbound dispatcher (§3.7)
8
+ :webhook_base_url, :webhook_mount_path, :dispatch_controller, :auto_register_webhooks
7
9
 
8
10
  def initialize
9
11
  @allowed_usernames = nil
@@ -12,6 +14,11 @@ module TelegramBotEngine
12
14
  @welcome_message = "Welcome %{username}! Available commands:\n%{commands}"
13
15
  @event_logging = true
14
16
  @event_retention_days = 30
17
+
18
+ @webhook_base_url = nil # host public https base, e.g. "https://app.example.com"
19
+ @webhook_mount_path = "/telegram/bot" # MUST match `mount TelegramBotEngine::Dispatch, at:`
20
+ @dispatch_controller = nil # host UpdatesController (String/class) incl. SubscriberCommands
21
+ @auto_register_webhooks = true # (un)register webhooks on Bot save/destroy when base_url present
15
22
  end
16
23
  end
17
24
  end
@@ -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