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.
- checksums.yaml +7 -0
- data/README.md +343 -0
- data/bin/botiasloop +155 -0
- data/data/skills/skill-creator/SKILL.md +329 -0
- data/data/skills/skill-creator/assets/ruby_api_cli_template.rb +151 -0
- data/data/skills/skill-creator/references/specification.md +99 -0
- data/lib/botiasloop/agent.rb +112 -0
- data/lib/botiasloop/channels/base.rb +248 -0
- data/lib/botiasloop/channels/cli.rb +101 -0
- data/lib/botiasloop/channels/telegram.rb +348 -0
- data/lib/botiasloop/channels.rb +64 -0
- data/lib/botiasloop/channels_manager.rb +299 -0
- data/lib/botiasloop/commands/archive.rb +109 -0
- data/lib/botiasloop/commands/base.rb +54 -0
- data/lib/botiasloop/commands/compact.rb +78 -0
- data/lib/botiasloop/commands/context.rb +34 -0
- data/lib/botiasloop/commands/conversations.rb +40 -0
- data/lib/botiasloop/commands/help.rb +30 -0
- data/lib/botiasloop/commands/label.rb +64 -0
- data/lib/botiasloop/commands/new.rb +21 -0
- data/lib/botiasloop/commands/registry.rb +121 -0
- data/lib/botiasloop/commands/reset.rb +18 -0
- data/lib/botiasloop/commands/status.rb +32 -0
- data/lib/botiasloop/commands/switch.rb +76 -0
- data/lib/botiasloop/commands/system_prompt.rb +20 -0
- data/lib/botiasloop/commands.rb +22 -0
- data/lib/botiasloop/config.rb +58 -0
- data/lib/botiasloop/conversation.rb +189 -0
- data/lib/botiasloop/conversation_manager.rb +225 -0
- data/lib/botiasloop/database.rb +92 -0
- data/lib/botiasloop/loop.rb +115 -0
- data/lib/botiasloop/skills/loader.rb +58 -0
- data/lib/botiasloop/skills/registry.rb +42 -0
- data/lib/botiasloop/skills/skill.rb +75 -0
- data/lib/botiasloop/systemd_service.rb +300 -0
- data/lib/botiasloop/tool.rb +24 -0
- data/lib/botiasloop/tools/registry.rb +68 -0
- data/lib/botiasloop/tools/shell.rb +50 -0
- data/lib/botiasloop/tools/web_search.rb +64 -0
- data/lib/botiasloop/version.rb +5 -0
- data/lib/botiasloop.rb +45 -0
- 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
|