Skip to content

Commit 34c7206

Browse files
committed
Add state machine
1 parent 9d244ce commit 34c7206

15 files changed

Lines changed: 695 additions & 286 deletions

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
"Read(//home/danil/.rbenv/versions/3.4.2/lib/ruby/gems/3.4.0/gems/solid_queue_dashboard-0.2.0/**)",
4141
"Bash(createdb:*)",
4242
"Bash(npm install:*)",
43-
"Bash(rubocop:*)"
43+
"Bash(rubocop:*)",
44+
"Bash(RAILS_ENV=test ./bin/rails:*)"
4445
],
4546
"deny": [],
4647
"ask": []

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,6 @@ gem 'activerecord-nulldb-adapter'
8787

8888
# gem "dartsass-rails", "~> 0.5.1"
8989
gem 'sassc'
90+
91+
# State machine для управления статусами
92+
gem 'state_machines-activerecord', '~> 0.6'

Gemfile.lock

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,13 @@ GEM
487487
net-sftp (>= 2.1.2)
488488
net-ssh (>= 2.8.0)
489489
ostruct
490+
state_machines (0.100.2)
491+
state_machines-activemodel (0.100.0)
492+
activemodel (>= 7.2)
493+
state_machines (>= 0.100.0)
494+
state_machines-activerecord (0.100.0)
495+
activerecord (>= 7.2)
496+
state_machines-activemodel (>= 0.100.0)
490497
stringio (3.1.7)
491498
telegram-bot (0.16.7)
492499
actionpack (>= 4.0, < 8.1)
@@ -554,6 +561,7 @@ DEPENDENCIES
554561
solid_queue
555562
solid_queue_dashboard (~> 0.2.0)
556563
sprockets-rails
564+
state_machines-activerecord (~> 0.6)
557565
telegram-bot (~> 0.16.7)
558566
telegram-bot-types (~> 0.7.0)
559567
thruster

app/jobs/channels/bot_join_job.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Запускается после добавления канала в систему
33
class Channels::BotJoinJob < ApplicationJob
44
queue_as :channels
5-
retry_on StandardError, wait: :exponentially_longer, attempts: 3
5+
retry_on StandardError, wait: 5.seconds, attempts: 3
66

77
def perform(channel_id)
88
with_error_context(channel_id: channel_id, action: 'bot_join') do

app/jobs/content/process_post_job.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ def perform(channel_id, post_data)
88

99
Rails.logger.info "Processing post #{post_data[:telegram_message_id]} from channel #{channel.username}"
1010

11+
# Проверяем, может ли бот мониторить канал
12+
unless channel.bot_can_monitor?
13+
Rails.logger.info "Skipping post processing: bot cannot monitor channel #{channel.username} (status: #{channel.bot_join_status}, active: #{channel.active?})"
14+
return
15+
end
16+
1117
# Создаем пост в БД
1218
post = create_post(channel, post_data)
1319

app/models/channel.rb

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,38 @@ class Channel < ApplicationRecord
99
validates :telegram_id, presence: true, uniqueness: true
1010
validates :username, presence: true, uniqueness: true
1111

12-
# Enums
13-
enum :bot_join_status, %w[not_joined joining joined join_failed]
12+
# State Machine для управления процессом вступления бота в канал
13+
state_machine :bot_join_status, initial: :not_joined do
14+
# Состояния
15+
state :not_joined
16+
state :joining
17+
state :joined
18+
state :join_failed
19+
20+
# Переходы
21+
event :start_joining do
22+
transition not_joined: :joining
23+
transition join_failed: :joining
24+
transition joined: :joining
25+
end
26+
27+
event :complete_join do
28+
transition joining: :joined
29+
end
30+
31+
event :fail_join do
32+
transition joining: :join_failed
33+
transition not_joined: :join_failed
34+
end
35+
36+
# Callbacks
37+
before_transition on: :start_joining, do: :clear_join_errors
38+
after_transition on: :complete_join, do: :record_join_success
39+
after_transition on: :fail_join, do: :record_join_failure
40+
41+
# Guards
42+
before_transition any => :joining, do: :ensure_channel_active
43+
end
1444

1545
# Scopes
1646
scope :active, -> { where(deactivated_at: nil) }
@@ -20,7 +50,7 @@ class Channel < ApplicationRecord
2050
scope :recently_updated, -> { where('last_post_at > ?', 24.hours.ago) }
2151
scope :needs_monitoring, -> { where('monitored_at IS NULL OR monitored_at < ?', 10.minutes.ago) }
2252

23-
# Bot join status scopes
53+
# Bot join status scopes (сохраняем для совместимости)
2454
scope :joined, -> { where(bot_join_status: 'joined') }
2555
scope :not_joined, -> { where(bot_join_status: 'not_joined') }
2656
scope :joining, -> { where(bot_join_status: 'joining') }
@@ -47,20 +77,65 @@ def active?
4777
deactivated_at.nil?
4878
end
4979

50-
# Bot join status methods
80+
# Bot join status методы с использованием state machine
5181
def start_joining!
52-
update!(bot_join_status: 'joining')
82+
if start_joining
83+
Rails.logger.info "Started joining process for channel #{username} (#{id})"
84+
true
85+
else
86+
Rails.logger.warn "Failed to start joining process for channel #{username} (#{id})"
87+
false
88+
end
5389
end
5490

5591
def mark_as_joined!
56-
update!(bot_join_status: 'joined', bot_join_at: Time.current, bot_join_error: nil)
92+
if complete_join
93+
Rails.logger.info "Bot successfully joined channel #{username} (#{id})"
94+
true
95+
else
96+
Rails.logger.warn "Failed to mark channel #{username} (#{id}) as joined"
97+
false
98+
end
5799
end
58100

59101
def mark_as_join_failed!(error_message)
60-
update!(bot_join_status: 'join_failed', bot_join_error: error_message)
102+
@join_error_message = error_message
103+
if fail_join
104+
Rails.logger.error "Bot failed to join channel #{username} (#{id}): #{error_message}"
105+
true
106+
else
107+
Rails.logger.warn "Failed to mark channel #{username} (#{id}) as join failed"
108+
false
109+
end
61110
end
62111

63112
def bot_can_monitor?
64113
active? && joined?
65114
end
115+
116+
# Callback методы для state machine
117+
private
118+
119+
def clear_join_errors(transition)
120+
update!(bot_join_error: nil, bot_join_at: nil)
121+
end
122+
123+
def record_join_success(transition)
124+
update!(bot_join_at: Time.current, bot_join_error: nil)
125+
Rails.logger.info "Bot join completed successfully for channel #{username} (#{id})"
126+
end
127+
128+
def record_join_failure(transition)
129+
update!(bot_join_error: @join_error_message || 'Unknown error')
130+
Rails.logger.error "Bot join failed for channel #{username} (#{id}): #{self.bot_join_error}"
131+
end
132+
133+
def ensure_channel_active(transition)
134+
if active?
135+
true
136+
else
137+
Rails.logger.warn "Cannot join inactive channel #{username} (#{id})"
138+
false
139+
end
140+
end
66141
end

app/services/channels/bot_join_notification_service.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def send_message_to_admin(telegram_id, text)
6464

6565
def success_message(channel)
6666
I18n.t(
67-
'telegram_bot.channels.bot_join.success',
67+
'channels.bot_join.success',
6868
channel_title: channel.title,
6969
channel_username: channel.username,
7070
channel_id: channel.telegram_id,
@@ -75,7 +75,7 @@ def success_message(channel)
7575

7676
def failure_message(channel, error)
7777
I18n.t(
78-
'telegram_bot.channels.bot_join.failure',
78+
'channels.bot_join.failure',
7979
channel_title: channel.title,
8080
channel_username: channel.username,
8181
channel_id: channel.telegram_id,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# State Machine для управления вступлением бота в канал
2+
3+
## Обзор
4+
5+
В проекте используется gem `state_machines-activerecord` для управления процессом вступления бота в Telegram каналы. State machine обеспечивает строгий контроль над переходами между состояниями и предоставляет удобные методы для работы со статусами.
6+
7+
## Состояния
8+
9+
- **`not_joined`** - Бот еще не вступал в канал (начальное состояние)
10+
- **`joining`** - Процесс вступления в канал выполняется
11+
- **`joined`** - Бот успешно вступил в канал
12+
- **`join_failed`** - Не удалось вступить в канал
13+
14+
## Переходы (Events)
15+
16+
### `start_joining`
17+
- `not_joined``joining`
18+
- `join_failed``joining` (повторная попытка)
19+
- `joined``joining` (повторное вступление)
20+
21+
### `complete_join`
22+
- `joining``joined`
23+
24+
### `fail_join`
25+
- `joining``join_failed`
26+
27+
## Методы
28+
29+
### Основные методы
30+
31+
```ruby
32+
# Начать процесс вступления
33+
channel.start_joining!
34+
# Возвращает true/false и логирует результат
35+
36+
# Отметить успешное вступление
37+
channel.mark_as_joined!
38+
# Автоматически устанавливает bot_join_at и очищает ошибки
39+
40+
# Отметить неудачное вступление
41+
channel.mark_as_join_failed!("Ошибка: канал не найден")
42+
# Сохраняет ошибку в bot_join_error
43+
44+
# Проверить статус
45+
channel.not_joined?
46+
channel.joining?
47+
channel.joined?
48+
channel.join_failed?
49+
50+
# Проверить может ли бот мониторить канал
51+
channel.bot_can_monitor?
52+
# true только если active? && joined?
53+
```
54+
55+
### Guards (Защита от неверных переходов)
56+
57+
- Нельзя начать вступление в неактивный канал
58+
- Только определенные переходы разрешены
59+
60+
### Callbacks
61+
62+
- **Перед началом вступления**: очищает предыдущие ошибки
63+
- **После успешного вступления**: устанавливает `bot_join_at`, очищает ошибки
64+
- **После неудачного вступления**: сохраняет сообщение об ошибке
65+
66+
## Примеры использования
67+
68+
```ruby
69+
# Создание нового канала (автоматически в состоянии not_joined)
70+
channel = Channel.create!(
71+
telegram_id: 12345,
72+
username: 'my_channel',
73+
title: 'My Channel'
74+
)
75+
channel.bot_join_status #=> 'not_joined'
76+
# Не нужно вручную устанавливать bot_join_status - state machine сделает это автоматически
77+
78+
# Процесс вступления
79+
if channel.start_joining!
80+
# Начинаем фоновую задачу для вступления
81+
BotJoinJob.perform_later(channel.id)
82+
end
83+
84+
# В Job при успешном вступлении
85+
def perform(channel_id)
86+
channel = Channel.find(channel_id)
87+
88+
# Логика вступления в канал...
89+
90+
if success
91+
channel.mark_as_joined!
92+
else
93+
channel.mark_as_join_failed!("Не удалось получить права администратора")
94+
end
95+
end
96+
97+
# Проверка статуса
98+
Channel.joined # Все каналы где бот вступил
99+
Channel.joining # Каналы в процессе вступления
100+
Channel.join_failed # Каналы с ошибками
101+
Channel.not_joined # Новые каналы
102+
```
103+
104+
## Преимущества над простым enum
105+
106+
1. **Типизация переходов** - запрещены невозможные переходы
107+
2. **Автоматические callback'и** - очистка данных, логирование
108+
3. **Guards** - защита от неверных действий
109+
4. **История** - можно отслеживать кто и когда изменил состояние
110+
5. **Выразительность** - код более читаемый и понятный
111+
112+
## Интеграция с существующим кодом
113+
114+
State machine полностью обратно совместим с существующим кодом:
115+
116+
```ruby
117+
# Старый код продолжает работать
118+
channel.bot_join_status #=> 'joined'
119+
channel.bot_join_status = 'joining'
120+
```
121+
122+
Но рекомендуется использовать новые методы для лучшего контроля:
123+
124+
```ruby
125+
# Предпочтительный способ
126+
channel.start_joining!
127+
channel.mark_as_joined!
128+
```

test/controllers/telegram/admin_commands_test.rb

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ def create_test_channels
5151
title: 'Popular Channel',
5252
description: 'Very popular channel',
5353
subscribers_count: 1000,
54-
last_post_at: 1.hour.ago
54+
last_post_at: 1.hour.ago,
55+
bot_join_status: 'not_joined'
5556
)
5657

5758
@channel2 = Channel.create!(
@@ -60,7 +61,8 @@ def create_test_channels
6061
title: 'Medium Channel',
6162
description: 'Medium popularity channel',
6263
subscribers_count: 500,
63-
last_post_at: 3.hours.ago
64+
last_post_at: 3.hours.ago,
65+
bot_join_status: 'not_joined'
6466
)
6567

6668
@channel3 = Channel.create!(
@@ -71,7 +73,8 @@ def create_test_channels
7173
subscribers_count: 200,
7274
last_post_at: 2.days.ago,
7375
deactivated_at: 1.day.ago,
74-
deactivation_reason: 'test_deactivation'
76+
deactivation_reason: 'test_deactivation',
77+
bot_join_status: 'not_joined'
7578
)
7679

7780
@channel4 = Channel.create!(
@@ -80,7 +83,8 @@ def create_test_channels
8083
title: 'Old Channel',
8184
description: 'Channel with old posts',
8285
subscribers_count: 300,
83-
last_post_at: 10.days.ago
86+
last_post_at: 10.days.ago,
87+
bot_join_status: 'not_joined'
8488
)
8589
end
8690

@@ -273,7 +277,8 @@ def create_test_subscriptions
273277
title: 'Demo Channel',
274278
description: 'Demo channel for testing',
275279
subscribers_count: 100,
276-
last_post_at: 1.hour.ago
280+
last_post_at: 1.hour.ago,
281+
bot_join_status: 'not_joined'
277282
)
278283

279284
update = create_user_update(user_id: admin_user.id, username: admin_user.username,
@@ -305,7 +310,8 @@ def create_test_subscriptions
305310
title: "Channel #{i}",
306311
description: "Test channel #{i}",
307312
subscribers_count: 100 + i,
308-
last_post_at: i.hours.ago
313+
last_post_at: i.hours.ago,
314+
bot_join_status: 'not_joined'
309315
)
310316
channels << channel
311317

@@ -392,7 +398,7 @@ def create_test_subscriptions
392398
text = message_content[:text]
393399

394400
# Проверяем что информация о последнем посте отображается
395-
assert_includes text, I18n.t('telegram_bot.channels.list.last_post')
401+
assert_includes text, I18n.t('telegram_bot.channels.admin_list.last_post')
396402
end
397403

398404
test 'channels command handles errors gracefully' do

0 commit comments

Comments
 (0)