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.
@@ -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(config)
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 start
31
+ def start_listening
29
32
  if @allowed_users.empty?
30
- @logger.warn "[Telegram] No allowed_users configured. No messages will be processed."
31
- @logger.warn "[Telegram] Add usernames to telegram.allowed_users in config."
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
- @logger.info "[Telegram] Starting bot..."
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
- @logger.info "[Telegram] Shutting down..."
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 stop
56
- @logger.info "[Telegram] Stopping bot..."
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(source_id, raw_message)
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(source_id, user_id, content, raw_message)
97
- @logger.info "[Telegram] Message from @#{user_id}: #{content}"
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(source_id, user_id, response, raw_message)
107
- @logger.info "[Telegram] Response sent to @#{user_id}"
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, raw_message)
116
- @logger.warn "[Telegram] Ignored message from unauthorized user @#{user_id} (chat_id: #{source_id})"
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(source_id, user_id, error, raw_message)
126
- @logger.error "[Telegram] Error processing message: #{error.message}"
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 response to Telegram
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 deliver_response(chat_id, formatted_content)
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 response for Telegram
156
+ # Format message for Telegram
152
157
  #
153
- # @param content [String] Raw response content
158
+ # @param content [String] Raw message content
154
159
  # @return [String] Telegram-compatible HTML
155
- def format_response(content)
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
- @logger.info "[Telegram] Registered #{commands.length} bot commands"
178
+ Logger.info "[Telegram] Registered #{commands.length} bot commands"
174
179
  rescue => e
175
- @logger.warn "[Telegram] Failed to register bot commands: #{e.message}"
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
- # Configure Redcarpet renderer for Telegram-compatible HTML
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: true,
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(markdown)
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(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/, '<b>\1</b>')
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(/<br\s*\/?>/, "\n")
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(/<ul[^>]*>.*?<\/ul>/m) do |ul_block|
229
- ul_block.gsub(/<li[^>]*>(.*?)<\/li>/) do |_|
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(/<\/?ul>/, "")
276
+ end.gsub(%r{</?ul>}, "")
232
277
  end
233
278
 
234
279
  # Process ordered lists
235
- result.gsub(/<ol[^>]*>.*?<\/ol>/m) do |ol_block|
280
+ result.gsub(%r{<ol[^>]*>.*?</ol>}m) do |ol_block|
236
281
  counter = 0
237
- ol_block.gsub(/<li[^>]*>(.*?)<\/li>/) do |_|
282
+ ol_block.gsub(%r{<li[^>]*>(.*?)</li>}) do |_|
238
283
  counter += 1
239
284
  "#{counter}. #{::Regexp.last_match(1)}<br>"
240
- end.gsub(/<\/?ol>/, "")
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(/<table[^>]*>.*?<\/table>/m) do |table_block|
291
+ html.gsub(%r{<table[^>]*>.*?</table>}m) do |table_block|
247
292
  # Extract headers (th elements)
248
- headers = table_block.scan(/<th[^>]*>(.*?)<\/th>/).flatten
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(/<tr[^>]*>(.*?)<\/tr>/m) do |row_match|
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(/<td[^>]*>(.*?)<\/td>/).flatten
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!(/<\/?(\w+)[^>]*>/) do |tag|
337
- tag_name = tag.gsub(/[<>\/]/, "").split.first
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("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
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
- # config = Botiasloop::Config.load
15
- # manager = Botiasloop::ChannelsManager.new(config)
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 start_channels
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(@config)
57
+ instance = channel_class.new
65
58
  rescue Error => e
66
59
  if e.message.match?(/Missing required configuration/)
67
- @logger.warn "[ChannelsManager] Skipping #{identifier}: #{e.message}"
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
- @logger.info "[ChannelsManager] Started #{identifier} in thread #{thread.object_id}"
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 stop_all
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
- @logger.info "[ChannelsManager] Stopping all channels..."
95
+ Logger.info "[ChannelsManager] Stopping all channels..."
103
96
 
104
97
  # Stop all channel instances
105
98
  @instances.each do |identifier, instance|
106
- instance.stop if instance.running?
99
+ instance.stop_listening if instance.running?
107
100
  rescue => e
108
- @logger.error "[ChannelsManager] Error stopping #{identifier}: #{e.message}"
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
- @logger.warn "[ChannelsManager] Force-killing #{identifier} thread"
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
- @logger.info "[ChannelsManager] All channels stopped"
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.start
219
+ instance.start_listening
227
220
  rescue => e
228
- @logger.error "[ChannelsManager] Channel #{identifier} crashed: #{e.message}"
229
- @logger.error "[ChannelsManager] #{e.backtrace&.first(5)&.join("\n")}"
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
- if instance&.running?
252
- @logger.error "[ChannelsManager] Thread for #{identifier} died unexpectedly"
253
- @instances.delete(identifier)
254
- @threads.delete(identifier)
255
- end
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
- @logger.info "[ChannelsManager] Received #{signal}, shutting down..."
285
- stop_all
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 UUID, or current conversation if no args
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/UUID"
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 UUID to archive (nil = archive current)
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 result[:new_conversation]
22
- # Archived current and created new
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
- # Archived specific conversation
27
- format_archive_response(result[:archived])
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 << "- UUID: #{conversation.uuid}"
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 << "- UUID: #{archived.uuid}"
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 << "- UUID: #{new_conversation.uuid}"
105
+ lines << "- ID: #{new_conversation.uuid}"
83
106
  lines << "- Label: (no label)"
84
107
 
85
108
  lines.join("\n")
@@ -14,7 +14,7 @@ module Botiasloop
14
14
 
15
15
  def execute(context, _args = nil)
16
16
  conversation = context.conversation
17
- config = context.config
17
+ config = Config.instance
18
18
 
19
19
  messages = conversation.history
20
20