botiasloop 0.0.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +343 -0
  3. data/bin/botiasloop +155 -0
  4. data/data/skills/skill-creator/SKILL.md +329 -0
  5. data/data/skills/skill-creator/assets/ruby_api_cli_template.rb +151 -0
  6. data/data/skills/skill-creator/references/specification.md +99 -0
  7. data/lib/botiasloop/agent.rb +112 -0
  8. data/lib/botiasloop/channels/base.rb +248 -0
  9. data/lib/botiasloop/channels/cli.rb +101 -0
  10. data/lib/botiasloop/channels/telegram.rb +348 -0
  11. data/lib/botiasloop/channels.rb +64 -0
  12. data/lib/botiasloop/channels_manager.rb +299 -0
  13. data/lib/botiasloop/commands/archive.rb +109 -0
  14. data/lib/botiasloop/commands/base.rb +54 -0
  15. data/lib/botiasloop/commands/compact.rb +78 -0
  16. data/lib/botiasloop/commands/context.rb +34 -0
  17. data/lib/botiasloop/commands/conversations.rb +40 -0
  18. data/lib/botiasloop/commands/help.rb +30 -0
  19. data/lib/botiasloop/commands/label.rb +64 -0
  20. data/lib/botiasloop/commands/new.rb +21 -0
  21. data/lib/botiasloop/commands/registry.rb +121 -0
  22. data/lib/botiasloop/commands/reset.rb +18 -0
  23. data/lib/botiasloop/commands/status.rb +32 -0
  24. data/lib/botiasloop/commands/switch.rb +76 -0
  25. data/lib/botiasloop/commands/system_prompt.rb +20 -0
  26. data/lib/botiasloop/commands.rb +22 -0
  27. data/lib/botiasloop/config.rb +58 -0
  28. data/lib/botiasloop/conversation.rb +189 -0
  29. data/lib/botiasloop/conversation_manager.rb +225 -0
  30. data/lib/botiasloop/database.rb +92 -0
  31. data/lib/botiasloop/loop.rb +115 -0
  32. data/lib/botiasloop/skills/loader.rb +58 -0
  33. data/lib/botiasloop/skills/registry.rb +42 -0
  34. data/lib/botiasloop/skills/skill.rb +75 -0
  35. data/lib/botiasloop/systemd_service.rb +300 -0
  36. data/lib/botiasloop/tool.rb +24 -0
  37. data/lib/botiasloop/tools/registry.rb +68 -0
  38. data/lib/botiasloop/tools/shell.rb +50 -0
  39. data/lib/botiasloop/tools/web_search.rb +64 -0
  40. data/lib/botiasloop/version.rb +5 -0
  41. data/lib/botiasloop.rb +45 -0
  42. metadata +250 -0
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "logger"
6
+
7
+ module Botiasloop
8
+ module Channels
9
+ class Base
10
+ class << self
11
+ attr_writer :channel_identifier
12
+
13
+ # Get or set the channel identifier
14
+ # @param name [Symbol] Channel identifier (e.g., :telegram)
15
+ # @return [Symbol] The channel identifier
16
+ def channel_name(name = nil)
17
+ if name
18
+ @channel_identifier = name
19
+ end
20
+ @channel_identifier
21
+ end
22
+
23
+ alias_method :channel_identifier, :channel_name
24
+
25
+ # Declare required configuration keys
26
+ # @param keys [Array<Symbol>] Required configuration keys
27
+ def requires_config(*keys)
28
+ @required_config_keys ||= []
29
+ @required_config_keys.concat(keys) if keys.any?
30
+ @required_config_keys
31
+ end
32
+
33
+ # Get required configuration keys
34
+ # @return [Array<Symbol>] Required configuration keys
35
+ def required_config_keys
36
+ @required_config_keys ||= []
37
+ end
38
+ end
39
+
40
+ # Initialize the channel
41
+ #
42
+ # @param config [Config] Configuration instance
43
+ # @raise [Error] If required configuration is missing
44
+ def initialize(config)
45
+ @config = config
46
+ @logger = Logger.new($stderr)
47
+
48
+ validate_required_config!
49
+ end
50
+
51
+ # Get channel-specific configuration
52
+ # Override in subclasses for custom config access
53
+ #
54
+ # @return [Hash] Channel configuration hash
55
+ def channel_config
56
+ @config.channels[self.class.channel_identifier.to_s] || {}
57
+ end
58
+
59
+ # Start the channel and begin listening for messages
60
+ # @raise [NotImplementedError] Subclass must implement
61
+ def start
62
+ raise NotImplementedError, "Subclass must implement #start"
63
+ end
64
+
65
+ # Stop the channel and cleanup
66
+ # @raise [NotImplementedError] Subclass must implement
67
+ def stop
68
+ raise NotImplementedError, "Subclass must implement #stop"
69
+ end
70
+
71
+ # Check if the channel is currently running
72
+ # @return [Boolean] True if running
73
+ # @raise [NotImplementedError] Subclass must implement
74
+ def running?
75
+ raise NotImplementedError, "Subclass must implement #running?"
76
+ end
77
+
78
+ # Process an incoming message using template method pattern
79
+ #
80
+ # @param source_id [String] Unique identifier for the message source (e.g., chat_id, user_id)
81
+ # @param raw_message [Object] Raw message object (varies by channel)
82
+ # @param metadata [Hash] Additional metadata about the message
83
+ def process_message(source_id, raw_message, metadata = {})
84
+ # Hook: Extract content from raw message
85
+ content = extract_content(raw_message)
86
+ return if content.nil? || content.to_s.empty?
87
+
88
+ # Hook: Extract user ID for authorization
89
+ user_id = extract_user_id(source_id, raw_message)
90
+
91
+ # Authorization check
92
+ unless authorized?(user_id)
93
+ handle_unauthorized(source_id, user_id, raw_message)
94
+ return
95
+ end
96
+
97
+ # Hook: Pre-processing
98
+ before_process(source_id, user_id, content, raw_message)
99
+
100
+ # Core processing logic
101
+ conversation = conversation_for(source_id)
102
+
103
+ response = if Commands.command?(content)
104
+ context = Commands::Context.new(
105
+ conversation: conversation,
106
+ config: @config,
107
+ channel: self,
108
+ user_id: source_id
109
+ )
110
+ Commands.execute(content, context)
111
+ else
112
+ agent = Agent.new(@config)
113
+ agent.chat(content, conversation: conversation)
114
+ end
115
+
116
+ send_response(source_id, response)
117
+
118
+ # Hook: Post-processing
119
+ after_process(source_id, user_id, response, raw_message)
120
+ rescue => e
121
+ handle_error(source_id, user_id, e, raw_message)
122
+ end
123
+
124
+ # Extract content from raw message. Subclasses must implement.
125
+ #
126
+ # @param raw_message [Object] Raw message object
127
+ # @return [String] Extracted message content
128
+ # @raise [NotImplementedError] Subclass must implement
129
+ def extract_content(raw_message)
130
+ raise NotImplementedError, "Subclass must implement #extract_content"
131
+ end
132
+
133
+ # Extract user ID from raw message for authorization
134
+ # Override in subclasses if user ID differs from source_id
135
+ #
136
+ # @param source_id [String] Source identifier
137
+ # @param raw_message [Object] Raw message object
138
+ # @return [String] User ID for authorization
139
+ def extract_user_id(source_id, raw_message)
140
+ source_id
141
+ end
142
+
143
+ # Hook called before processing a message
144
+ # Override in subclasses for custom pre-processing (e.g., logging)
145
+ #
146
+ # @param source_id [String] Source identifier
147
+ # @param user_id [String] User ID
148
+ # @param content [String] Message content
149
+ # @param raw_message [Object] Raw message object
150
+ def before_process(source_id, user_id, content, raw_message)
151
+ # No-op by default
152
+ end
153
+
154
+ # Hook called after processing a message
155
+ # Override in subclasses for custom post-processing (e.g., logging)
156
+ #
157
+ # @param source_id [String] Source identifier
158
+ # @param user_id [String] User ID
159
+ # @param response [String] Response content
160
+ # @param raw_message [Object] Raw message object
161
+ def after_process(source_id, user_id, response, raw_message)
162
+ # No-op by default
163
+ end
164
+
165
+ # Handle unauthorized access
166
+ # Override in subclasses for custom unauthorized handling
167
+ #
168
+ # @param source_id [String] Source identifier
169
+ # @param user_id [String] User ID that was denied
170
+ # @param raw_message [Object] Raw message object
171
+ def handle_unauthorized(source_id, user_id, raw_message)
172
+ @logger.warn "[#{self.class.channel_identifier}] Unauthorized access from #{user_id} (source: #{source_id})"
173
+ end
174
+
175
+ # Handle errors during message processing
176
+ # Override in subclasses for custom error handling
177
+ #
178
+ # @param source_id [String] Source identifier
179
+ # @param user_id [String] User ID
180
+ # @param error [Exception] The error that occurred
181
+ # @param raw_message [Object] Raw message object
182
+ def handle_error(source_id, user_id, error, raw_message)
183
+ @logger.error "[#{self.class.channel_identifier}] Error processing message: #{error.message}"
184
+ raise error
185
+ end
186
+
187
+ # Check if a source is authorized to use this channel
188
+ #
189
+ # @param source_id [String] Source identifier to check
190
+ # @return [Boolean] False by default (secure default)
191
+ def authorized?(source_id)
192
+ false
193
+ end
194
+
195
+ # Get or create a conversation for a source
196
+ # Uses the global ConversationManager for state management.
197
+ #
198
+ # @param source_id [String] Source identifier
199
+ # @return [Conversation] Conversation instance
200
+ def conversation_for(source_id)
201
+ ConversationManager.current_for(source_id)
202
+ end
203
+
204
+ # Format a response for this channel
205
+ #
206
+ # @param content [String] Raw response content
207
+ # @return [String] Formatted response
208
+ def format_response(content)
209
+ content
210
+ end
211
+
212
+ # Send a response to a source
213
+ #
214
+ # @param source_id [String] Source identifier
215
+ # @param response [String] Response content
216
+ def send_response(source_id, response)
217
+ formatted = format_response(response)
218
+ deliver_response(source_id, formatted)
219
+ end
220
+
221
+ # Deliver a formatted response to a source
222
+ #
223
+ # @param source_id [String] Source identifier
224
+ # @param formatted_content [String] Formatted response content
225
+ # @raise [NotImplementedError] Subclass must implement
226
+ def deliver_response(source_id, formatted_content)
227
+ raise NotImplementedError, "Subclass must implement #deliver_response"
228
+ end
229
+
230
+ private
231
+
232
+ def validate_required_config!
233
+ required_keys = self.class.required_config_keys
234
+ return if required_keys.empty?
235
+
236
+ cfg = channel_config
237
+ missing_keys = required_keys.reject do |key|
238
+ str_key = key.to_s
239
+ cfg.key?(str_key) && !cfg[str_key].nil? && cfg[str_key] != ""
240
+ end
241
+
242
+ return if missing_keys.empty?
243
+
244
+ raise Error, "#{self.class.channel_identifier}: Missing required configuration: #{missing_keys.join(", ")}"
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Botiasloop
6
+ module Channels
7
+ class CLI < Base
8
+ channel_name :cli
9
+
10
+ EXIT_COMMANDS = %w[exit quit \q].freeze
11
+ SOURCE_ID = "cli"
12
+
13
+ # Initialize CLI channel
14
+ #
15
+ # @param config [Config] Configuration instance
16
+ def initialize(config)
17
+ super
18
+ @running = false
19
+ end
20
+
21
+ # Start the CLI interactive mode
22
+ def start
23
+ @running = true
24
+ @logger.info "[CLI] Starting interactive mode..."
25
+
26
+ puts "botiasloop v#{VERSION} - Interactive Mode"
27
+ puts "Type 'exit', 'quit', or '\\q' to exit"
28
+ puts
29
+
30
+ while @running
31
+ print "You: "
32
+ input = $stdin.gets&.chomp
33
+ break if input.nil? || EXIT_COMMANDS.include?(input.downcase)
34
+
35
+ puts
36
+ process_message(SOURCE_ID, input)
37
+ end
38
+
39
+ @running = false
40
+ @logger.info "[CLI] Interactive mode ended"
41
+ rescue Interrupt
42
+ @running = false
43
+ puts "\nGoodbye!"
44
+ @logger.info "[CLI] Interrupted by user"
45
+ end
46
+
47
+ # Stop the CLI channel
48
+ def stop
49
+ @running = false
50
+ @logger.info "[CLI] Stopping..."
51
+ end
52
+
53
+ # Check if CLI channel is running
54
+ #
55
+ # @return [Boolean] True if running
56
+ def running?
57
+ @running
58
+ end
59
+
60
+ # Extract content from raw message
61
+ # For CLI, the raw message is already the content string
62
+ #
63
+ # @param raw_message [String] Raw message (already a string)
64
+ # @return [String] The content
65
+ def extract_content(raw_message)
66
+ raw_message
67
+ end
68
+
69
+ # Check if source is authorized (CLI is always authorized)
70
+ #
71
+ # @param source_id [String] Source identifier to check
72
+ # @return [Boolean] Always true for CLI
73
+ def authorized?(source_id)
74
+ true
75
+ end
76
+
77
+ # Handle errors by sending error message to user
78
+ #
79
+ # @param source_id [String] Source identifier
80
+ # @param user_id [String] User ID
81
+ # @param error [Exception] The error that occurred
82
+ # @param raw_message [Object] Raw message object
83
+ def handle_error(source_id, user_id, error, raw_message)
84
+ @logger.error "[CLI] Error processing message: #{error.message}"
85
+ send_response(source_id, "Error: #{error.message}")
86
+ end
87
+
88
+ # Deliver a formatted response to the CLI
89
+ #
90
+ # @param source_id [String] Source identifier
91
+ # @param formatted_content [String] Formatted response content
92
+ def deliver_response(source_id, formatted_content)
93
+ puts "Agent: #{formatted_content}"
94
+ puts
95
+ end
96
+
97
+ # Auto-register CLI channel when file is loaded
98
+ Botiasloop::Channels.registry.register(CLI)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "telegram/bot"
4
+ require "json"
5
+ require "fileutils"
6
+ require "redcarpet"
7
+
8
+ module Botiasloop
9
+ module Channels
10
+ class Telegram < Base
11
+ channel_name :telegram
12
+ requires_config :bot_token
13
+
14
+ # Initialize Telegram channel
15
+ #
16
+ # @param config [Config] Configuration instance
17
+ # @raise [Error] If bot_token is not configured
18
+ def initialize(config)
19
+ super
20
+ cfg = channel_config
21
+ @bot_token = cfg["bot_token"]
22
+ @allowed_users = cfg["allowed_users"] || []
23
+ @bot = nil
24
+ @thread_id = nil
25
+ end
26
+
27
+ # Start the Telegram bot and listen for messages
28
+ def start
29
+ 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."
32
+ end
33
+
34
+ @logger.info "[Telegram] Starting bot..."
35
+
36
+ @bot = ::Telegram::Bot::Client.new(@bot_token)
37
+ register_bot_commands
38
+ @thread_id = Thread.current.object_id
39
+
40
+ @bot.run do |bot|
41
+ bot.listen do |message|
42
+ next unless message.is_a?(::Telegram::Bot::Types::Message) && message.text
43
+
44
+ process_message(message.chat.id.to_s, message)
45
+ end
46
+ end
47
+ rescue Interrupt
48
+ @logger.info "[Telegram] Shutting down..."
49
+ end
50
+
51
+ # Stop the Telegram bot
52
+ #
53
+ # Interrupts the thread running the bot to gracefully exit
54
+ # the blocking listen loop.
55
+ def stop
56
+ @logger.info "[Telegram] Stopping bot..."
57
+
58
+ return unless @thread_id
59
+
60
+ thread = Thread.list.find { |t| t.object_id == @thread_id }
61
+ return unless thread&.alive?
62
+
63
+ thread.raise Interrupt
64
+ end
65
+
66
+ # Check if bot is running
67
+ #
68
+ # @return [Boolean] True if bot is running
69
+ def running?
70
+ !@bot.nil?
71
+ end
72
+
73
+ # Extract content from Telegram message object
74
+ #
75
+ # @param raw_message [Telegram::Bot::Types::Message] Telegram message
76
+ # @return [String] Message text
77
+ def extract_content(raw_message)
78
+ raw_message.text
79
+ end
80
+
81
+ # Extract username from Telegram message for authorization
82
+ #
83
+ # @param source_id [String] Source identifier (chat_id)
84
+ # @param raw_message [Telegram::Bot::Types::Message] Telegram message
85
+ # @return [String, nil] Username from message
86
+ def extract_user_id(source_id, raw_message)
87
+ raw_message.from&.username
88
+ end
89
+
90
+ # Log message before processing
91
+ #
92
+ # @param source_id [String] Source identifier
93
+ # @param user_id [String] Username
94
+ # @param content [String] Message text
95
+ # @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}"
98
+ end
99
+
100
+ # Log successful response after processing
101
+ #
102
+ # @param source_id [String] Source identifier
103
+ # @param user_id [String] Username
104
+ # @param response [String] Response content
105
+ # @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}"
108
+ end
109
+
110
+ # Handle unauthorized access with specific logging
111
+ #
112
+ # @param source_id [String] Source identifier
113
+ # @param user_id [String] Username that was denied
114
+ # @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})"
117
+ end
118
+
119
+ # Handle errors by logging only (don't notify user)
120
+ #
121
+ # @param source_id [String] Source identifier
122
+ # @param user_id [String] Username
123
+ # @param error [Exception] The error that occurred
124
+ # @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}"
127
+ end
128
+
129
+ # Check if username is in allowed list
130
+ #
131
+ # @param username [String, nil] Telegram username
132
+ # @return [Boolean] True if allowed
133
+ def authorized?(username)
134
+ return false if username.nil? || @allowed_users.empty?
135
+
136
+ @allowed_users.include?(username)
137
+ end
138
+
139
+ # Deliver a formatted response to Telegram
140
+ #
141
+ # @param chat_id [String] Telegram chat ID (as string)
142
+ # @param formatted_content [String] Formatted message content
143
+ def deliver_response(chat_id, formatted_content)
144
+ @bot.api.send_message(
145
+ chat_id: chat_id.to_i,
146
+ text: formatted_content,
147
+ parse_mode: "HTML"
148
+ )
149
+ end
150
+
151
+ # Format response for Telegram
152
+ #
153
+ # @param content [String] Raw response content
154
+ # @return [String] Telegram-compatible HTML
155
+ def format_response(content)
156
+ return "" if content.nil? || content.empty?
157
+
158
+ to_telegram_html(content)
159
+ end
160
+
161
+ private
162
+
163
+ # Register bot commands with Telegram
164
+ def register_bot_commands
165
+ commands = Botiasloop::Commands.registry.all.map do |cmd_class|
166
+ {
167
+ command: cmd_class.command_name.to_s,
168
+ description: cmd_class.description || "No description"
169
+ }
170
+ end
171
+
172
+ @bot.api.set_my_commands(commands: commands)
173
+ @logger.info "[Telegram] Registered #{commands.length} bot commands"
174
+ rescue => e
175
+ @logger.warn "[Telegram] Failed to register bot commands: #{e.message}"
176
+ end
177
+
178
+ # Convert Markdown to Telegram-compatible HTML
179
+ #
180
+ # @param markdown [String] Markdown text
181
+ # @return [String] Telegram-compatible HTML
182
+ def to_telegram_html(markdown)
183
+ # Configure Redcarpet renderer for Telegram-compatible HTML
184
+ renderer_options = {
185
+ hard_wrap: false,
186
+ filter_html: false
187
+ }
188
+
189
+ extensions = {
190
+ fenced_code_blocks: true,
191
+ autolink: true,
192
+ strikethrough: true,
193
+ tables: true,
194
+ no_intra_emphasis: true
195
+ }
196
+
197
+ renderer = Redcarpet::Render::HTML.new(renderer_options)
198
+ markdown_parser = Redcarpet::Markdown.new(renderer, extensions)
199
+ html = markdown_parser.render(markdown)
200
+
201
+ # Post-process HTML for Telegram compatibility
202
+ process_html_for_telegram(html)
203
+ end
204
+
205
+ # Process HTML to make it Telegram-compatible
206
+ def process_html_for_telegram(html)
207
+ result = html
208
+
209
+ # Convert headers to bold
210
+ result = result.gsub(/<h[1-6][^>]*>(.*?)<\/h[1-6]>/, '<b>\1</b>')
211
+
212
+ # Convert lists to formatted text
213
+ result = convert_lists(result)
214
+
215
+ # Convert tables to columns on separate lines
216
+ result = convert_tables(result)
217
+
218
+ # Convert <br> tags to newlines (Telegram doesn't support <br>)
219
+ result = result.gsub(/<br\s*\/?>/, "\n")
220
+
221
+ # Strip unsupported HTML tags
222
+ strip_unsupported_tags(result)
223
+ end
224
+
225
+ # Convert HTML lists to formatted text with bullets/numbers
226
+ def convert_lists(html)
227
+ # Process unordered lists
228
+ result = html.gsub(/<ul[^>]*>.*?<\/ul>/m) do |ul_block|
229
+ ul_block.gsub(/<li[^>]*>(.*?)<\/li>/) do |_|
230
+ "• #{::Regexp.last_match(1)}<br>"
231
+ end.gsub(/<\/?ul>/, "")
232
+ end
233
+
234
+ # Process ordered lists
235
+ result.gsub(/<ol[^>]*>.*?<\/ol>/m) do |ol_block|
236
+ counter = 0
237
+ ol_block.gsub(/<li[^>]*>(.*?)<\/li>/) do |_|
238
+ counter += 1
239
+ "#{counter}. #{::Regexp.last_match(1)}<br>"
240
+ end.gsub(/<\/?ol>/, "")
241
+ end
242
+ end
243
+
244
+ # Convert HTML tables to properly formatted text wrapped in <pre> tags
245
+ def convert_tables(html)
246
+ html.gsub(/<table[^>]*>.*?<\/table>/m) do |table_block|
247
+ # Extract headers (th elements)
248
+ headers = table_block.scan(/<th[^>]*>(.*?)<\/th>/).flatten
249
+
250
+ # Extract data rows (td elements within tr elements)
251
+ data_rows = []
252
+ table_block.scan(/<tr[^>]*>(.*?)<\/tr>/m) do |row_match|
253
+ row_html = row_match[0]
254
+ # Skip rows that only contain th elements (header row)
255
+ next if row_html.include?("<th")
256
+
257
+ cells = row_html.scan(/<td[^>]*>(.*?)<\/td>/).flatten
258
+ data_rows << cells if cells.any?
259
+ end
260
+
261
+ # Calculate column widths (minimum 3 characters)
262
+ num_columns = [headers.length, data_rows.map(&:length).max || 0].max
263
+ col_widths = Array.new(num_columns, 3)
264
+
265
+ # Update widths based on header lengths
266
+ headers.each_with_index do |header, i|
267
+ col_widths[i] = [col_widths[i], strip_html_tags(header).length].max
268
+ end
269
+
270
+ # Update widths based on data cell lengths
271
+ data_rows.each do |row|
272
+ row.each_with_index do |cell, i|
273
+ col_widths[i] = [col_widths[i], strip_html_tags(cell).length].max
274
+ end
275
+ end
276
+
277
+ # Format the table
278
+ lines = []
279
+
280
+ # Format header row with bold tags
281
+ formatted_headers = headers.map.with_index do |header, i|
282
+ text = strip_html_tags(header).ljust(col_widths[i])
283
+ "<b>#{text}</b>"
284
+ end
285
+ lines << formatted_headers.join(" ")
286
+
287
+ # Format data rows
288
+ data_rows.each do |row|
289
+ formatted_cells = row.map.with_index do |cell, i|
290
+ text = strip_html_tags(cell).ljust(col_widths[i])
291
+ # Convert inline markdown to HTML within cells
292
+ convert_inline_markdown(text)
293
+ end
294
+ lines << formatted_cells.join(" ")
295
+ end
296
+
297
+ # Wrap in <pre> tags
298
+ "<pre>#{lines.join("\n")}</pre>"
299
+ end
300
+ end
301
+
302
+ # Strip HTML tags from text (helper for width calculation)
303
+ def strip_html_tags(html)
304
+ html.gsub(/<[^>]+>/, "")
305
+ end
306
+
307
+ # Convert inline markdown to HTML (for table cell content)
308
+ def convert_inline_markdown(text)
309
+ result = text.dup
310
+
311
+ # Bold: **text** -> <strong>text</strong>
312
+ result.gsub!(/\*\*(.+?)\*\*/, '<strong>\1</strong>')
313
+
314
+ # Italic: *text* -> <em>text</em>
315
+ result.gsub!(/\*(.+?)\*/, '<em>\1</em>')
316
+
317
+ # Code: `text` -> <code>text</code>
318
+ result.gsub!(/`(.+?)`/, '<code>\1</code>')
319
+
320
+ # Strikethrough: ~~text~~ -> <del>text</del>
321
+ result.gsub!(/~~(.+?)~~/, '<del>\1</del>')
322
+
323
+ result
324
+ end
325
+
326
+ # Strip HTML tags not supported by Telegram
327
+ def strip_unsupported_tags(html)
328
+ # Telegram supports: <b>, <strong>, <i>, <em>, <u>, <ins>, <s>, <strike>, <del>, <code>, <pre>, <a>
329
+ # Remove all other tags but keep their content
330
+ # Note: <br> is converted to newlines before this method is called
331
+ allowed_tags = %w[b strong i em u ins s strike del code pre a]
332
+
333
+ result = html.dup
334
+
335
+ # Remove all HTML tags that are not in the allowed list
336
+ result.gsub!(/<\/?(\w+)[^>]*>/) do |tag|
337
+ tag_name = tag.gsub(/[<>\/]/, "").split.first
338
+ allowed_tags.include?(tag_name) ? tag : ""
339
+ end
340
+
341
+ result
342
+ end
343
+ end
344
+
345
+ # Auto-register Telegram channel when file is loaded
346
+ Botiasloop::Channels.registry.register(Telegram)
347
+ end
348
+ end