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,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">
@@ -45,12 +56,14 @@
45
56
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Timestamp</th>
46
57
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
47
58
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
59
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Bot</th>
48
60
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
49
61
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Chat ID</th>
50
62
  <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details</th>
51
63
  </tr>
52
64
  </thead>
53
65
  <tbody class="bg-white divide-y divide-gray-200">
66
+ <% bot_names = @bots.each_with_object({}) { |bot, h| h[bot.id] = bot.name } %>
54
67
  <% @events.each do |event| %>
55
68
  <tr>
56
69
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
@@ -73,6 +86,9 @@
73
86
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
74
87
  <%= event.action %>
75
88
  </td>
89
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
90
+ <%= bot_names[event.bot_id] || "-" %>
91
+ </td>
76
92
  <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
77
93
  <%= event.username || "-" %>
78
94
  </td>
@@ -80,14 +96,15 @@
80
96
  <%= event.chat_id || "-" %>
81
97
  </td>
82
98
  <td class="px-6 py-4 text-gray-500 max-w-md break-words" style="font-size: 0.7rem; line-height: 1.15rem;">
83
- <%= event.details.present? ? event.details.to_json : "-" %>
99
+ <% detail_data = event.details.is_a?(Hash) ? event.details.except("bot", :bot) : event.details %>
100
+ <%= detail_data.present? ? detail_data.to_json : "-" %>
84
101
  </td>
85
102
  </tr>
86
103
  <% end %>
87
104
 
88
105
  <% if @events.empty? %>
89
106
  <tr>
90
- <td colspan="6" class="px-6 py-12 text-center text-sm text-gray-500">
107
+ <td colspan="7" class="px-6 py-12 text-center text-sm text-gray-500">
91
108
  No events found.
92
109
  </td>
93
110
  </tr>
@@ -103,11 +120,11 @@
103
120
  </div>
104
121
  <div class="flex space-x-2">
105
122
  <% 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),
123
+ <%= 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
124
  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
125
  <% end %>
109
126
  <% 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),
127
+ <%= 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
128
  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
129
  <% end %>
113
130
  </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