botiasloop 0.0.1 → 0.0.7
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.
- checksums.yaml +4 -4
- data/LICENSE +7 -0
- data/README.md +266 -122
- data/bin/botiasloop +65 -15
- data/lib/botiasloop/agent.rb +25 -12
- data/lib/botiasloop/auto_label.rb +117 -0
- data/lib/botiasloop/channels/base.rb +48 -44
- data/lib/botiasloop/channels/cli.rb +14 -18
- data/lib/botiasloop/channels/telegram.rb +95 -42
- data/lib/botiasloop/channels_manager.rb +23 -30
- data/lib/botiasloop/chat.rb +122 -0
- data/lib/botiasloop/commands/archive.rb +34 -11
- data/lib/botiasloop/commands/compact.rb +1 -1
- data/lib/botiasloop/commands/context.rb +6 -6
- data/lib/botiasloop/commands/conversations.rb +11 -6
- data/lib/botiasloop/commands/label.rb +9 -11
- data/lib/botiasloop/commands/new.rb +2 -2
- data/lib/botiasloop/commands/status.rb +2 -2
- data/lib/botiasloop/commands/switch.rb +5 -7
- data/lib/botiasloop/commands/verbose.rb +29 -0
- data/lib/botiasloop/commands.rb +1 -0
- data/lib/botiasloop/config.rb +16 -0
- data/lib/botiasloop/conversation.rb +100 -11
- data/lib/botiasloop/database.rb +16 -4
- data/lib/botiasloop/human_id.rb +58 -0
- data/lib/botiasloop/logger.rb +45 -0
- data/lib/botiasloop/loop.rb +88 -7
- data/lib/botiasloop/systemd_service.rb +20 -10
- data/lib/botiasloop/tools/shell.rb +5 -0
- data/lib/botiasloop/version.rb +1 -1
- data/lib/botiasloop.rb +8 -1
- metadata +46 -27
- data/lib/botiasloop/conversation_manager.rb +0 -225
|
@@ -5,6 +5,10 @@ require "json"
|
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "redcarpet"
|
|
7
7
|
|
|
8
|
+
# Regex patterns for markdown parsing
|
|
9
|
+
CODE_BLOCK_REGEX = /```\w*\n?([\s\S]*?)```/
|
|
10
|
+
INLINE_CODE_REGEX = /`([^`]+)`/
|
|
11
|
+
|
|
8
12
|
module Botiasloop
|
|
9
13
|
module Channels
|
|
10
14
|
class Telegram < Base
|
|
@@ -13,9 +17,8 @@ module Botiasloop
|
|
|
13
17
|
|
|
14
18
|
# Initialize Telegram channel
|
|
15
19
|
#
|
|
16
|
-
# @param config [Config] Configuration instance
|
|
17
20
|
# @raise [Error] If bot_token is not configured
|
|
18
|
-
def initialize
|
|
21
|
+
def initialize
|
|
19
22
|
super
|
|
20
23
|
cfg = channel_config
|
|
21
24
|
@bot_token = cfg["bot_token"]
|
|
@@ -25,13 +28,13 @@ module Botiasloop
|
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
# Start the Telegram bot and listen for messages
|
|
28
|
-
def
|
|
31
|
+
def start_listening
|
|
29
32
|
if @allowed_users.empty?
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
Logger.warn "[Telegram] No allowed_users configured. No messages will be processed."
|
|
34
|
+
Logger.warn "[Telegram] Add usernames to telegram.allowed_users in config."
|
|
32
35
|
end
|
|
33
36
|
|
|
34
|
-
|
|
37
|
+
Logger.info "[Telegram] Starting bot..."
|
|
35
38
|
|
|
36
39
|
@bot = ::Telegram::Bot::Client.new(@bot_token)
|
|
37
40
|
register_bot_commands
|
|
@@ -45,15 +48,15 @@ module Botiasloop
|
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
rescue Interrupt
|
|
48
|
-
|
|
51
|
+
Logger.info "[Telegram] Shutting down..."
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
# Stop the Telegram bot
|
|
52
55
|
#
|
|
53
56
|
# Interrupts the thread running the bot to gracefully exit
|
|
54
57
|
# the blocking listen loop.
|
|
55
|
-
def
|
|
56
|
-
|
|
58
|
+
def stop_listening
|
|
59
|
+
Logger.info "[Telegram] Stopping bot..."
|
|
57
60
|
|
|
58
61
|
return unless @thread_id
|
|
59
62
|
|
|
@@ -83,7 +86,7 @@ module Botiasloop
|
|
|
83
86
|
# @param source_id [String] Source identifier (chat_id)
|
|
84
87
|
# @param raw_message [Telegram::Bot::Types::Message] Telegram message
|
|
85
88
|
# @return [String, nil] Username from message
|
|
86
|
-
def extract_user_id(
|
|
89
|
+
def extract_user_id(_source_id, raw_message)
|
|
87
90
|
raw_message.from&.username
|
|
88
91
|
end
|
|
89
92
|
|
|
@@ -93,8 +96,8 @@ module Botiasloop
|
|
|
93
96
|
# @param user_id [String] Username
|
|
94
97
|
# @param content [String] Message text
|
|
95
98
|
# @param raw_message [Telegram::Bot::Types::Message] Telegram message
|
|
96
|
-
def before_process(
|
|
97
|
-
|
|
99
|
+
def before_process(_source_id, user_id, content, _raw_message)
|
|
100
|
+
Logger.info "[Telegram] Message from @#{user_id}: #{content}"
|
|
98
101
|
end
|
|
99
102
|
|
|
100
103
|
# Log successful response after processing
|
|
@@ -103,8 +106,8 @@ module Botiasloop
|
|
|
103
106
|
# @param user_id [String] Username
|
|
104
107
|
# @param response [String] Response content
|
|
105
108
|
# @param raw_message [Telegram::Bot::Types::Message] Telegram message
|
|
106
|
-
def after_process(
|
|
107
|
-
|
|
109
|
+
def after_process(_source_id, user_id, _response, _raw_message)
|
|
110
|
+
Logger.info "[Telegram] Response sent to @#{user_id}"
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# Handle unauthorized access with specific logging
|
|
@@ -112,8 +115,8 @@ module Botiasloop
|
|
|
112
115
|
# @param source_id [String] Source identifier
|
|
113
116
|
# @param user_id [String] Username that was denied
|
|
114
117
|
# @param raw_message [Telegram::Bot::Types::Message] Telegram message
|
|
115
|
-
def handle_unauthorized(source_id, user_id,
|
|
116
|
-
|
|
118
|
+
def handle_unauthorized(source_id, user_id, _raw_message)
|
|
119
|
+
Logger.warn "[Telegram] Ignored message from unauthorized user @#{user_id} (chat_id: #{source_id})"
|
|
117
120
|
end
|
|
118
121
|
|
|
119
122
|
# Handle errors by logging only (don't notify user)
|
|
@@ -122,8 +125,8 @@ module Botiasloop
|
|
|
122
125
|
# @param user_id [String] Username
|
|
123
126
|
# @param error [Exception] The error that occurred
|
|
124
127
|
# @param raw_message [Telegram::Bot::Types::Message] Telegram message
|
|
125
|
-
def handle_error(
|
|
126
|
-
|
|
128
|
+
def handle_error(_source_id, _user_id, error, _raw_message)
|
|
129
|
+
Logger.error "[Telegram] Error processing message: #{error.message}"
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
# Check if username is in allowed list
|
|
@@ -136,11 +139,13 @@ module Botiasloop
|
|
|
136
139
|
@allowed_users.include?(username)
|
|
137
140
|
end
|
|
138
141
|
|
|
139
|
-
# Deliver a formatted
|
|
142
|
+
# Deliver a formatted message to Telegram
|
|
140
143
|
#
|
|
141
144
|
# @param chat_id [String] Telegram chat ID (as string)
|
|
142
145
|
# @param formatted_content [String] Formatted message content
|
|
143
|
-
def
|
|
146
|
+
def deliver_message(chat_id, formatted_content)
|
|
147
|
+
return if formatted_content.nil? || formatted_content.empty?
|
|
148
|
+
|
|
144
149
|
@bot.api.send_message(
|
|
145
150
|
chat_id: chat_id.to_i,
|
|
146
151
|
text: formatted_content,
|
|
@@ -148,11 +153,11 @@ module Botiasloop
|
|
|
148
153
|
)
|
|
149
154
|
end
|
|
150
155
|
|
|
151
|
-
# Format
|
|
156
|
+
# Format message for Telegram
|
|
152
157
|
#
|
|
153
|
-
# @param content [String] Raw
|
|
158
|
+
# @param content [String] Raw message content
|
|
154
159
|
# @return [String] Telegram-compatible HTML
|
|
155
|
-
def
|
|
160
|
+
def format_message(content)
|
|
156
161
|
return "" if content.nil? || content.empty?
|
|
157
162
|
|
|
158
163
|
to_telegram_html(content)
|
|
@@ -170,24 +175,64 @@ module Botiasloop
|
|
|
170
175
|
end
|
|
171
176
|
|
|
172
177
|
@bot.api.set_my_commands(commands: commands)
|
|
173
|
-
|
|
178
|
+
Logger.info "[Telegram] Registered #{commands.length} bot commands"
|
|
174
179
|
rescue => e
|
|
175
|
-
|
|
180
|
+
Logger.warn "[Telegram] Failed to register bot commands: #{e.message}"
|
|
176
181
|
end
|
|
177
182
|
|
|
178
183
|
# Convert Markdown to Telegram-compatible HTML
|
|
179
184
|
#
|
|
185
|
+
# Extracts and protects code blocks before other processing,
|
|
186
|
+
# then restores them with proper HTML tags.
|
|
187
|
+
#
|
|
180
188
|
# @param markdown [String] Markdown text
|
|
181
189
|
# @return [String] Telegram-compatible HTML
|
|
182
190
|
def to_telegram_html(markdown)
|
|
183
|
-
#
|
|
191
|
+
# Step 1: Extract and protect code blocks (fenced ```code```)
|
|
192
|
+
code_blocks = []
|
|
193
|
+
text = markdown.gsub(CODE_BLOCK_REGEX) do |_|
|
|
194
|
+
code_blocks << Regexp.last_match(1)
|
|
195
|
+
"\x00CB#{code_blocks.length - 1}\x00"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Step 2: Extract and protect inline code (`code`)
|
|
199
|
+
inline_codes = []
|
|
200
|
+
text = text.gsub(INLINE_CODE_REGEX) do |_|
|
|
201
|
+
inline_codes << Regexp.last_match(1)
|
|
202
|
+
"\x00IC#{inline_codes.length - 1}\x00"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Step 3: Convert remaining markdown to HTML
|
|
206
|
+
html = markdown_to_html(text)
|
|
207
|
+
|
|
208
|
+
# Step 4: Restore inline code with <code> tags
|
|
209
|
+
inline_codes.each_with_index do |code, i|
|
|
210
|
+
escaped = escape_html(code)
|
|
211
|
+
html = html.gsub("\x00IC#{i}\x00", "<code>#{escaped}</code>")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Step 5: Restore code blocks with <pre><code> tags
|
|
215
|
+
code_blocks.each_with_index do |code, i|
|
|
216
|
+
escaped = escape_html(code)
|
|
217
|
+
html = html.gsub("\x00CB#{i}\x00", "<pre><code>#{escaped}</code></pre>")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
html
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Convert markdown text to HTML using Redcarpet
|
|
224
|
+
#
|
|
225
|
+
# @param text [String] Markdown text (with placeholders for protected content)
|
|
226
|
+
# @return [String] HTML
|
|
227
|
+
def markdown_to_html(text)
|
|
184
228
|
renderer_options = {
|
|
185
229
|
hard_wrap: false,
|
|
186
230
|
filter_html: false
|
|
187
231
|
}
|
|
188
232
|
|
|
233
|
+
# NOTE: fenced_code_blocks is false - we handle code blocks manually
|
|
189
234
|
extensions = {
|
|
190
|
-
fenced_code_blocks:
|
|
235
|
+
fenced_code_blocks: false,
|
|
191
236
|
autolink: true,
|
|
192
237
|
strikethrough: true,
|
|
193
238
|
tables: true,
|
|
@@ -196,7 +241,7 @@ module Botiasloop
|
|
|
196
241
|
|
|
197
242
|
renderer = Redcarpet::Render::HTML.new(renderer_options)
|
|
198
243
|
markdown_parser = Redcarpet::Markdown.new(renderer, extensions)
|
|
199
|
-
html = markdown_parser.render(
|
|
244
|
+
html = markdown_parser.render(text)
|
|
200
245
|
|
|
201
246
|
# Post-process HTML for Telegram compatibility
|
|
202
247
|
process_html_for_telegram(html)
|
|
@@ -207,7 +252,7 @@ module Botiasloop
|
|
|
207
252
|
result = html
|
|
208
253
|
|
|
209
254
|
# Convert headers to bold
|
|
210
|
-
result = result.gsub(
|
|
255
|
+
result = result.gsub(%r{<h[1-6][^>]*>(.*?)</h[1-6]>}, '<b>\1</b>')
|
|
211
256
|
|
|
212
257
|
# Convert lists to formatted text
|
|
213
258
|
result = convert_lists(result)
|
|
@@ -216,7 +261,7 @@ module Botiasloop
|
|
|
216
261
|
result = convert_tables(result)
|
|
217
262
|
|
|
218
263
|
# Convert <br> tags to newlines (Telegram doesn't support <br>)
|
|
219
|
-
result = result.gsub(
|
|
264
|
+
result = result.gsub(%r{<br\s*/?>}, "\n")
|
|
220
265
|
|
|
221
266
|
# Strip unsupported HTML tags
|
|
222
267
|
strip_unsupported_tags(result)
|
|
@@ -225,36 +270,36 @@ module Botiasloop
|
|
|
225
270
|
# Convert HTML lists to formatted text with bullets/numbers
|
|
226
271
|
def convert_lists(html)
|
|
227
272
|
# Process unordered lists
|
|
228
|
-
result = html.gsub(
|
|
229
|
-
ul_block.gsub(
|
|
273
|
+
result = html.gsub(%r{<ul[^>]*>.*?</ul>}m) do |ul_block|
|
|
274
|
+
ul_block.gsub(%r{<li[^>]*>(.*?)</li>}) do |_|
|
|
230
275
|
"• #{::Regexp.last_match(1)}<br>"
|
|
231
|
-
end.gsub(
|
|
276
|
+
end.gsub(%r{</?ul>}, "")
|
|
232
277
|
end
|
|
233
278
|
|
|
234
279
|
# Process ordered lists
|
|
235
|
-
result.gsub(
|
|
280
|
+
result.gsub(%r{<ol[^>]*>.*?</ol>}m) do |ol_block|
|
|
236
281
|
counter = 0
|
|
237
|
-
ol_block.gsub(
|
|
282
|
+
ol_block.gsub(%r{<li[^>]*>(.*?)</li>}) do |_|
|
|
238
283
|
counter += 1
|
|
239
284
|
"#{counter}. #{::Regexp.last_match(1)}<br>"
|
|
240
|
-
end.gsub(
|
|
285
|
+
end.gsub(%r{</?ol>}, "")
|
|
241
286
|
end
|
|
242
287
|
end
|
|
243
288
|
|
|
244
289
|
# Convert HTML tables to properly formatted text wrapped in <pre> tags
|
|
245
290
|
def convert_tables(html)
|
|
246
|
-
html.gsub(
|
|
291
|
+
html.gsub(%r{<table[^>]*>.*?</table>}m) do |table_block|
|
|
247
292
|
# Extract headers (th elements)
|
|
248
|
-
headers = table_block.scan(
|
|
293
|
+
headers = table_block.scan(%r{<th[^>]*>(.*?)</th>}).flatten
|
|
249
294
|
|
|
250
295
|
# Extract data rows (td elements within tr elements)
|
|
251
296
|
data_rows = []
|
|
252
|
-
table_block.scan(
|
|
297
|
+
table_block.scan(%r{<tr[^>]*>(.*?)</tr>}m) do |row_match|
|
|
253
298
|
row_html = row_match[0]
|
|
254
299
|
# Skip rows that only contain th elements (header row)
|
|
255
300
|
next if row_html.include?("<th")
|
|
256
301
|
|
|
257
|
-
cells = row_html.scan(
|
|
302
|
+
cells = row_html.scan(%r{<td[^>]*>(.*?)</td>}).flatten
|
|
258
303
|
data_rows << cells if cells.any?
|
|
259
304
|
end
|
|
260
305
|
|
|
@@ -333,13 +378,21 @@ module Botiasloop
|
|
|
333
378
|
result = html.dup
|
|
334
379
|
|
|
335
380
|
# Remove all HTML tags that are not in the allowed list
|
|
336
|
-
result.gsub!(
|
|
337
|
-
tag_name = tag.gsub(
|
|
381
|
+
result.gsub!(%r{</?(\w+)[^>]*>}) do |tag|
|
|
382
|
+
tag_name = tag.gsub(%r{[<>/]}, "").split.first
|
|
338
383
|
allowed_tags.include?(tag_name) ? tag : ""
|
|
339
384
|
end
|
|
340
385
|
|
|
341
386
|
result
|
|
342
387
|
end
|
|
388
|
+
|
|
389
|
+
# Escape HTML special characters in text
|
|
390
|
+
#
|
|
391
|
+
# @param text [String] Text to escape
|
|
392
|
+
# @return [String] Escaped text
|
|
393
|
+
def escape_html(text)
|
|
394
|
+
text.gsub("&", "&").gsub("<", "<").gsub(">", ">")
|
|
395
|
+
end
|
|
343
396
|
end
|
|
344
397
|
|
|
345
398
|
# Auto-register Telegram channel when file is loaded
|
|
@@ -11,9 +11,8 @@ module Botiasloop
|
|
|
11
11
|
# independent operation and error isolation.
|
|
12
12
|
#
|
|
13
13
|
# @example Basic usage
|
|
14
|
-
#
|
|
15
|
-
# manager
|
|
16
|
-
# manager.start_channels.wait
|
|
14
|
+
# manager = Botiasloop::ChannelsManager.new
|
|
15
|
+
# manager.start_listening.wait
|
|
17
16
|
#
|
|
18
17
|
class ChannelsManager
|
|
19
18
|
# Time to wait for graceful shutdown before force-killing threads
|
|
@@ -22,14 +21,8 @@ module Botiasloop
|
|
|
22
21
|
# Channels that should not be auto-started (interactive channels)
|
|
23
22
|
EXCLUDED_CHANNELS = %i[cli].freeze
|
|
24
23
|
|
|
25
|
-
attr_reader :config
|
|
26
|
-
|
|
27
24
|
# Initialize a new ChannelsManager
|
|
28
|
-
|
|
29
|
-
# @param config [Config] Configuration instance
|
|
30
|
-
def initialize(config)
|
|
31
|
-
@config = config
|
|
32
|
-
@logger = Logger.new($stdout)
|
|
25
|
+
def initialize
|
|
33
26
|
@threads = {}
|
|
34
27
|
@instances = {}
|
|
35
28
|
@mutex = Mutex.new
|
|
@@ -46,7 +39,7 @@ module Botiasloop
|
|
|
46
39
|
#
|
|
47
40
|
# @return [ChannelsManager] self for method chaining
|
|
48
41
|
# @raise [Error] If channels are already running
|
|
49
|
-
def
|
|
42
|
+
def start_listening
|
|
50
43
|
@mutex.synchronize do
|
|
51
44
|
raise Error, "Channels are already running" if @running
|
|
52
45
|
|
|
@@ -61,10 +54,10 @@ module Botiasloop
|
|
|
61
54
|
next if EXCLUDED_CHANNELS.include?(identifier)
|
|
62
55
|
|
|
63
56
|
begin
|
|
64
|
-
instance = channel_class.new
|
|
57
|
+
instance = channel_class.new
|
|
65
58
|
rescue Error => e
|
|
66
59
|
if e.message.match?(/Missing required configuration/)
|
|
67
|
-
|
|
60
|
+
Logger.warn "[ChannelsManager] Skipping #{identifier}: #{e.message}"
|
|
68
61
|
next
|
|
69
62
|
end
|
|
70
63
|
raise
|
|
@@ -76,7 +69,7 @@ module Botiasloop
|
|
|
76
69
|
@instances[identifier] = instance
|
|
77
70
|
end
|
|
78
71
|
|
|
79
|
-
|
|
72
|
+
Logger.info "[ChannelsManager] Started #{identifier} in thread #{thread.object_id}"
|
|
80
73
|
end
|
|
81
74
|
|
|
82
75
|
# Monitor threads for crashes
|
|
@@ -91,7 +84,7 @@ module Botiasloop
|
|
|
91
84
|
# to complete. Force-kills threads that don't stop within timeout.
|
|
92
85
|
#
|
|
93
86
|
# @return [void]
|
|
94
|
-
def
|
|
87
|
+
def stop_listening
|
|
95
88
|
@mutex.synchronize do
|
|
96
89
|
return unless @running
|
|
97
90
|
|
|
@@ -99,19 +92,19 @@ module Botiasloop
|
|
|
99
92
|
@running = false
|
|
100
93
|
end
|
|
101
94
|
|
|
102
|
-
|
|
95
|
+
Logger.info "[ChannelsManager] Stopping all channels..."
|
|
103
96
|
|
|
104
97
|
# Stop all channel instances
|
|
105
98
|
@instances.each do |identifier, instance|
|
|
106
|
-
instance.
|
|
99
|
+
instance.stop_listening if instance.running?
|
|
107
100
|
rescue => e
|
|
108
|
-
|
|
101
|
+
Logger.error "[ChannelsManager] Error stopping #{identifier}: #{e.message}"
|
|
109
102
|
end
|
|
110
103
|
|
|
111
104
|
# Wait for threads to complete
|
|
112
105
|
@threads.each do |identifier, thread|
|
|
113
106
|
unless thread.join(SHUTDOWN_TIMEOUT)
|
|
114
|
-
|
|
107
|
+
Logger.warn "[ChannelsManager] Force-killing #{identifier} thread"
|
|
115
108
|
thread.kill
|
|
116
109
|
end
|
|
117
110
|
end
|
|
@@ -121,7 +114,7 @@ module Botiasloop
|
|
|
121
114
|
@instances.clear
|
|
122
115
|
end
|
|
123
116
|
|
|
124
|
-
|
|
117
|
+
Logger.info "[ChannelsManager] All channels stopped"
|
|
125
118
|
end
|
|
126
119
|
|
|
127
120
|
# Check if any channels are currently running
|
|
@@ -223,10 +216,10 @@ module Botiasloop
|
|
|
223
216
|
Thread.current.name = "botiasloop-#{identifier}"
|
|
224
217
|
|
|
225
218
|
begin
|
|
226
|
-
instance.
|
|
219
|
+
instance.start_listening
|
|
227
220
|
rescue => e
|
|
228
|
-
|
|
229
|
-
|
|
221
|
+
Logger.error "[ChannelsManager] Channel #{identifier} crashed: #{e.message}"
|
|
222
|
+
Logger.error "[ChannelsManager] #{e.backtrace&.first(5)&.join("\n")}"
|
|
230
223
|
end
|
|
231
224
|
end
|
|
232
225
|
end
|
|
@@ -248,11 +241,11 @@ module Botiasloop
|
|
|
248
241
|
next if thread.alive?
|
|
249
242
|
|
|
250
243
|
instance = @instances[identifier]
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
244
|
+
next unless instance&.running?
|
|
245
|
+
|
|
246
|
+
Logger.error "[ChannelsManager] Thread for #{identifier} died unexpectedly"
|
|
247
|
+
@instances.delete(identifier)
|
|
248
|
+
@threads.delete(identifier)
|
|
256
249
|
end
|
|
257
250
|
|
|
258
251
|
# Exit monitor if no more threads
|
|
@@ -281,8 +274,8 @@ module Botiasloop
|
|
|
281
274
|
def handle_shutdown_signal(signal)
|
|
282
275
|
# Defer to separate thread to avoid trap context limitations
|
|
283
276
|
Thread.new do
|
|
284
|
-
|
|
285
|
-
|
|
277
|
+
Logger.info "[ChannelsManager] Received #{signal}, shutting down..."
|
|
278
|
+
stop_listening
|
|
286
279
|
|
|
287
280
|
# Call original handler if it exists
|
|
288
281
|
case signal
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Botiasloop
|
|
6
|
+
# Chat model - represents a communication channel between user(s) and agent
|
|
7
|
+
# A chat belongs to a specific channel (telegram, cli) and external source (chat_id)
|
|
8
|
+
# Each chat tracks its current conversation (which can be any conversation in the system)
|
|
9
|
+
class Chat < Sequel::Model(:chats)
|
|
10
|
+
plugin :validation_helpers
|
|
11
|
+
plugin :timestamps, update_on_create: true
|
|
12
|
+
|
|
13
|
+
many_to_one :current_conversation, class: "Botiasloop::Conversation", key: :current_conversation_id
|
|
14
|
+
|
|
15
|
+
# Validations
|
|
16
|
+
def validate
|
|
17
|
+
super
|
|
18
|
+
validates_presence %i[channel external_id]
|
|
19
|
+
validates_unique %i[channel external_id]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Class method to find or create a chat by channel and external_id
|
|
23
|
+
#
|
|
24
|
+
# @param channel [String] Channel type (e.g., "telegram", "cli")
|
|
25
|
+
# @param external_id [String] External identifier (e.g., chat_id, "cli")
|
|
26
|
+
# @param user_identifier [String, nil] Optional user identifier (e.g., telegram username)
|
|
27
|
+
# @return [Chat] Found or created chat
|
|
28
|
+
def self.find_or_create(channel, external_id, user_identifier: nil)
|
|
29
|
+
chat = find(channel: channel, external_id: external_id)
|
|
30
|
+
return chat if chat
|
|
31
|
+
|
|
32
|
+
create(
|
|
33
|
+
channel: channel,
|
|
34
|
+
external_id: external_id,
|
|
35
|
+
user_identifier: user_identifier
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Get the current conversation for this chat
|
|
40
|
+
# Creates a new conversation if none exists or current is archived
|
|
41
|
+
#
|
|
42
|
+
# @return [Conversation] Current active conversation
|
|
43
|
+
def current_conversation
|
|
44
|
+
conv = super
|
|
45
|
+
|
|
46
|
+
if conv.nil? || conv.archived
|
|
47
|
+
conv = create_new_conversation
|
|
48
|
+
update(current_conversation_id: conv.id)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
conv
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Switch to a different conversation by label or conversation ID
|
|
55
|
+
#
|
|
56
|
+
# @param identifier [String] Conversation label or human-readable ID
|
|
57
|
+
# @return [Conversation] The switched-to conversation
|
|
58
|
+
# @raise [Error] If conversation not found
|
|
59
|
+
def switch_conversation(identifier)
|
|
60
|
+
identifier = identifier.to_s.strip
|
|
61
|
+
raise Error, "Usage: /switch <label-or-id>" if identifier.empty?
|
|
62
|
+
|
|
63
|
+
# First try to find by label
|
|
64
|
+
conversation = Conversation.find(label: identifier)
|
|
65
|
+
|
|
66
|
+
# If not found by label, treat as ID (case-insensitive)
|
|
67
|
+
unless conversation
|
|
68
|
+
normalized_id = HumanId.normalize(identifier)
|
|
69
|
+
conversation = Conversation.all.find { |c| HumanId.normalize(c.id) == normalized_id }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
raise Error, "Conversation '#{identifier}' not found" unless conversation
|
|
73
|
+
|
|
74
|
+
# Auto-unarchive if switching to archived conversation
|
|
75
|
+
conversation.update(archived: false) if conversation.archived
|
|
76
|
+
|
|
77
|
+
update(current_conversation_id: conversation.id)
|
|
78
|
+
conversation
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Create a new conversation and make it current for this chat
|
|
82
|
+
#
|
|
83
|
+
# @return [Conversation] The newly created conversation
|
|
84
|
+
def create_new_conversation
|
|
85
|
+
conversation = Conversation.create
|
|
86
|
+
update(current_conversation_id: conversation.id)
|
|
87
|
+
conversation
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# List all non-archived conversations in the system
|
|
91
|
+
# Sorted by updated_at in descending order (most recently updated first)
|
|
92
|
+
#
|
|
93
|
+
# @return [Array<Conversation>] Array of conversations
|
|
94
|
+
def active_conversations
|
|
95
|
+
Conversation.where(archived: false).order(Sequel.desc(:updated_at)).all
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# List all archived conversations in the system
|
|
99
|
+
#
|
|
100
|
+
# @return [Array<Conversation>] Array of archived conversations
|
|
101
|
+
def archived_conversations
|
|
102
|
+
Conversation.where(archived: true).order(Sequel.desc(:updated_at)).all
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Archive the current conversation and create a new one
|
|
106
|
+
#
|
|
107
|
+
# @return [Hash] Hash with :archived and :new_conversation keys
|
|
108
|
+
def archive_current
|
|
109
|
+
current = current_conversation if current_conversation_id
|
|
110
|
+
|
|
111
|
+
raise Error, "No current conversation to archive" unless current
|
|
112
|
+
|
|
113
|
+
current.update(archived: true)
|
|
114
|
+
new_conversation = create_new_conversation
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
archived: current,
|
|
118
|
+
new_conversation: new_conversation
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -2,29 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
module Botiasloop
|
|
4
4
|
module Commands
|
|
5
|
-
# Archive command - archives a conversation by label or
|
|
5
|
+
# Archive command - archives a conversation by label or ID, or current conversation if no args
|
|
6
6
|
class Archive < Base
|
|
7
7
|
command :archive
|
|
8
|
-
description "Archive current conversation (no args) or a specific conversation by label/
|
|
8
|
+
description "Archive current conversation (no args) or a specific conversation by label/ID"
|
|
9
9
|
|
|
10
10
|
# Execute the archive command
|
|
11
11
|
# Without args: archives current conversation and creates a new one
|
|
12
12
|
# With args: archives the specified conversation
|
|
13
13
|
#
|
|
14
14
|
# @param context [Context] Execution context
|
|
15
|
-
# @param args [String, nil] Label or
|
|
15
|
+
# @param args [String, nil] Label or ID to archive (nil = archive current)
|
|
16
16
|
# @return [String] Command response with archived conversation details
|
|
17
17
|
def execute(context, args = nil)
|
|
18
18
|
identifier = args.to_s.strip
|
|
19
|
-
result = ConversationManager.archive(context.user_id, identifier.empty? ? nil : identifier)
|
|
20
19
|
|
|
21
|
-
if
|
|
22
|
-
#
|
|
20
|
+
if identifier.empty?
|
|
21
|
+
# Archive current conversation and create new one
|
|
22
|
+
result = context.chat.archive_current
|
|
23
23
|
context.conversation = result[:new_conversation]
|
|
24
24
|
format_archive_current_response(result[:archived], result[:new_conversation])
|
|
25
25
|
else
|
|
26
|
-
#
|
|
27
|
-
|
|
26
|
+
# Archive specific conversation by label or ID
|
|
27
|
+
conversation = find_conversation(identifier)
|
|
28
|
+
raise Botiasloop::Error, "Conversation '#{identifier}' not found" unless conversation
|
|
29
|
+
|
|
30
|
+
# Cannot archive current conversation via identifier
|
|
31
|
+
if conversation.id == context.conversation.id
|
|
32
|
+
raise Botiasloop::Error,
|
|
33
|
+
"Cannot archive the current conversation. Use /archive without arguments to archive current and start new."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
conversation.archive!
|
|
37
|
+
format_archive_response(conversation)
|
|
28
38
|
end
|
|
29
39
|
rescue Botiasloop::Error => e
|
|
30
40
|
"Error: #{e.message}"
|
|
@@ -32,9 +42,22 @@ module Botiasloop
|
|
|
32
42
|
|
|
33
43
|
private
|
|
34
44
|
|
|
45
|
+
def find_conversation(identifier)
|
|
46
|
+
# First try to find by label
|
|
47
|
+
conversation = Conversation.find(label: identifier)
|
|
48
|
+
|
|
49
|
+
# If not found by label, treat as ID (case-insensitive)
|
|
50
|
+
unless conversation
|
|
51
|
+
normalized_id = HumanId.normalize(identifier)
|
|
52
|
+
conversation = Conversation.all.find { |c| HumanId.normalize(c.id) == normalized_id }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
conversation
|
|
56
|
+
end
|
|
57
|
+
|
|
35
58
|
def format_archive_response(conversation)
|
|
36
59
|
lines = ["**Conversation archived successfully**"]
|
|
37
|
-
lines << "-
|
|
60
|
+
lines << "- ID: #{conversation.uuid}"
|
|
38
61
|
|
|
39
62
|
lines << if conversation.label?
|
|
40
63
|
"- Label: #{conversation.label}"
|
|
@@ -59,7 +82,7 @@ module Botiasloop
|
|
|
59
82
|
lines = ["**Current conversation archived and new conversation started**"]
|
|
60
83
|
lines << ""
|
|
61
84
|
lines << "Archived:"
|
|
62
|
-
lines << "-
|
|
85
|
+
lines << "- ID: #{archived.uuid}"
|
|
63
86
|
|
|
64
87
|
lines << if archived.label?
|
|
65
88
|
"- Label: #{archived.label}"
|
|
@@ -79,7 +102,7 @@ module Botiasloop
|
|
|
79
102
|
|
|
80
103
|
lines << ""
|
|
81
104
|
lines << "New conversation:"
|
|
82
|
-
lines << "-
|
|
105
|
+
lines << "- ID: #{new_conversation.uuid}"
|
|
83
106
|
lines << "- Label: (no label)"
|
|
84
107
|
|
|
85
108
|
lines.join("\n")
|